update: Major Overhaul to all subsystems

This commit is contained in:
2026-03-07 11:32:18 +02:00
parent 810e81b323
commit c62188fda6
107 changed files with 20414 additions and 929 deletions

View File

@@ -30,6 +30,13 @@ import DeviceInventoryDetail from "./manufacturing/DeviceInventoryDetail";
import ProvisioningWizard from "./manufacturing/ProvisioningWizard";
import FirmwareManager from "./firmware/FirmwareManager";
import DashboardPage from "./dashboard/DashboardPage";
import ApiReferencePage from "./developer/ApiReferencePage";
import { ProductList, ProductForm } from "./crm/products";
import { CustomerList, CustomerForm, CustomerDetail } from "./crm/customers";
import { OrderList, OrderForm, OrderDetail } from "./crm/orders";
import { QuotationForm } from "./crm/quotations";
import CommsPage from "./crm/inbox/CommsPage";
import MailPage from "./crm/mail/MailPage";
function ProtectedRoute({ children }) {
const { user, loading } = useAuth();
@@ -150,6 +157,30 @@ export default function App() {
<Route path="manufacturing/devices/:sn" element={<PermissionGate section="manufacturing"><DeviceInventoryDetail /></PermissionGate>} />
<Route path="firmware" element={<PermissionGate section="manufacturing"><FirmwareManager /></PermissionGate>} />
{/* Mail */}
<Route path="mail" element={<PermissionGate section="crm"><MailPage /></PermissionGate>} />
{/* CRM */}
<Route path="crm/comms" element={<PermissionGate section="crm"><CommsPage /></PermissionGate>} />
<Route path="crm/inbox" element={<Navigate to="/crm/comms" replace />} />
<Route path="crm/products" element={<PermissionGate section="crm"><ProductList /></PermissionGate>} />
<Route path="crm/products/new" element={<PermissionGate section="crm" action="edit"><ProductForm /></PermissionGate>} />
<Route path="crm/products/:id" element={<PermissionGate section="crm"><ProductForm /></PermissionGate>} />
<Route path="crm/customers" element={<PermissionGate section="crm"><CustomerList /></PermissionGate>} />
<Route path="crm/customers/new" element={<PermissionGate section="crm" action="edit"><CustomerForm /></PermissionGate>} />
<Route path="crm/customers/:id" element={<PermissionGate section="crm"><CustomerDetail /></PermissionGate>} />
<Route path="crm/customers/:id/edit" element={<PermissionGate section="crm" action="edit"><CustomerForm /></PermissionGate>} />
<Route path="crm/orders" element={<PermissionGate section="crm"><OrderList /></PermissionGate>} />
<Route path="crm/orders/new" element={<PermissionGate section="crm" action="edit"><OrderForm /></PermissionGate>} />
<Route path="crm/orders/:id" element={<PermissionGate section="crm"><OrderDetail /></PermissionGate>} />
<Route path="crm/orders/:id/edit" element={<PermissionGate section="crm" action="edit"><OrderForm /></PermissionGate>} />
<Route path="crm/quotations/new" element={<PermissionGate section="crm" action="edit"><QuotationForm /></PermissionGate>} />
<Route path="crm/quotations/:id" element={<PermissionGate section="crm" action="edit"><QuotationForm /></PermissionGate>} />
{/* Developer */}
{/* TODO: replace RoleGate with a dedicated "developer" permission once granular permissions are implemented */}
<Route path="developer/api" element={<RoleGate roles={["sysadmin", "admin"]}><ApiReferencePage /></RoleGate>} />
{/* Settings - Staff Management */}
<Route path="settings/staff" element={<RoleGate roles={["sysadmin", "admin"]}><StaffList /></RoleGate>} />
<Route path="settings/staff/new" element={<RoleGate roles={["sysadmin", "admin"]}><StaffForm /></RoleGate>} />

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg height="800px" width="800px" version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512" xml:space="preserve">
<style type="text/css">
.st0{fill:#000000;}
</style>
<g>
<path class="st0" d="M500.177,55.798c0,0-21.735-7.434-39.551-11.967C411.686,31.369,308.824,24.727,256,24.727
S100.314,31.369,51.374,43.831c-17.816,4.534-39.551,11.967-39.551,11.967c-7.542,2.28-12.444,9.524-11.76,17.374l8.507,97.835
c0.757,8.596,7.957,15.201,16.581,15.201h84.787c8.506,0,15.643-6.416,16.553-14.878l4.28-39.973
c0.847-7.93,7.2-14.138,15.148-14.815c0,0,68.484-6.182,110.081-6.182c41.586,0,110.08,6.182,110.08,6.182
c7.949,0.676,14.302,6.885,15.148,14.815l4.29,39.973c0.9,8.462,8.038,14.878,16.545,14.878h84.777
c8.632,0,15.832-6.605,16.589-15.201l8.507-97.835C512.621,65.322,507.72,58.078,500.177,55.798z"/>
<path class="st0" d="M357.503,136.629h-55.365v46.137h-92.275v-46.137h-55.365c0,0-9.228,119.957-119.957,207.618
c0,32.296,0,129.95,0,129.95c0,7.218,5.857,13.076,13.075,13.076h416.768c7.218,0,13.076-5.858,13.076-13.076
c0,0,0-97.654,0-129.95C366.73,256.586,357.503,136.629,357.503,136.629z M338.768,391.42v37.406h-37.396V391.42H338.768z
M338.768,332.27v37.406h-37.396V332.27H338.768z M301.372,310.518v-37.396h37.396v37.396H301.372z M274.698,391.42v37.406h-37.396
V391.42H274.698z M274.698,332.27v37.406h-37.396V332.27H274.698z M274.698,273.122v37.396h-37.396v-37.396H274.698z
M210.629,391.42v37.406h-37.397V391.42H210.629z M210.629,332.27v37.406h-37.397V332.27H210.629z M210.629,273.122v37.396h-37.397
v-37.396H210.629z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" ?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 6.3500002 6.3500002" id="svg1976" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:svg="http://www.w3.org/2000/svg">
<defs id="defs1970"/>
<g id="layer1" style="display:inline">

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.16421 9.66421L15.4142 3.41421L12.5858 0.585785L6.33579 6.83578L3.5 4L2 5.5V14H10.5L12 12.5L9.16421 9.66421Z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 365 B

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg height="800px" width="800px" version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512" xml:space="preserve">
<style type="text/css">
.st0{fill:#000000;}
</style>
<g>
<path class="st0" d="M458.159,404.216c-18.93-33.65-49.934-71.764-100.409-93.431c-28.868,20.196-63.938,32.087-101.745,32.087
c-37.828,0-72.898-11.89-101.767-32.087c-50.474,21.667-81.479,59.782-100.398,93.431C28.731,448.848,48.417,512,91.842,512
c43.426,0,164.164,0,164.164,0s120.726,0,164.153,0C463.583,512,483.269,448.848,458.159,404.216z"/>
<path class="st0" d="M256.005,300.641c74.144,0,134.231-60.108,134.231-134.242v-32.158C390.236,60.108,330.149,0,256.005,0
c-74.155,0-134.252,60.108-134.252,134.242V166.4C121.753,240.533,181.851,300.641,256.005,300.641z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 18V9C6 7.34315 7.34315 6 9 6H39C40.6569 6 42 7.34315 42 9V18" stroke="#000000" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M32 24V31" stroke="#000000" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M24 15V31" stroke="#000000" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16 19V31" stroke="#000000" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 30V39C6 40.6569 7.34315 42 9 42H39C40.6569 42 42 40.6569 42 39V30" stroke="#000000" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 855 B

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" id="phone-out" class="icon glyph"><path d="M21,15v3.93a2,2,0,0,1-2.29,2A18,18,0,0,1,3.14,5.29,2,2,0,0,1,5.13,3H9a1,1,0,0,1,1,.89,10.74,10.74,0,0,0,1,3.78,1,1,0,0,1-.42,1.26l-.86.49a1,1,0,0,0-.33,1.46,14.08,14.08,0,0,0,3.69,3.69,1,1,0,0,0,1.46-.33l.49-.86A1,1,0,0,1,16.33,13a10.74,10.74,0,0,0,3.78,1A1,1,0,0,1,21,15Z" style="fill:#231f20"></path><path d="M21,10a1,1,0,0,1-1-1,5,5,0,0,0-5-5,1,1,0,0,1,0-2,7,7,0,0,1,7,7A1,1,0,0,1,21,10Z" style="fill:#231f20"></path><path d="M17,10a1,1,0,0,1-1-1,1,1,0,0,0-1-1,1,1,0,0,1,0-2,3,3,0,0,1,3,3A1,1,0,0,1,17,10Z" style="fill:#231f20"></path></svg>

After

Width:  |  Height:  |  Size: 795 B

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" id="create-note" class="icon glyph"><path d="M20.71,3.29a2.91,2.91,0,0,0-2.2-.84,3.25,3.25,0,0,0-2.17,1L9.46,10.29s0,0,0,0a.62.62,0,0,0-.11.17,1,1,0,0,0-.1.18l0,0L8,14.72A1,1,0,0,0,9,16a.9.9,0,0,0,.28,0l4-1.17,0,0,.18-.1a.62.62,0,0,0,.17-.11l0,0,6.87-6.88a3.25,3.25,0,0,0,1-2.17A2.91,2.91,0,0,0,20.71,3.29Z"></path><path d="M20,22H4a2,2,0,0,1-2-2V4A2,2,0,0,1,4,2h8a1,1,0,0,1,0,2H4V20H20V12a1,1,0,0,1,2,0v8A2,2,0,0,1,20,22Z" style="fill:#231f20"></path></svg>

After

Width:  |  Height:  |  Size: 666 B

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 2H5.50003L4.00003 3.5L6.83581 6.33579L0.585815 12.5858L3.41424 15.4142L9.66424 9.16421L12.5 12L14 10.5L14 2Z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 367 B

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><path d="M256 32C114.6 32 0 125.1 0 240c0 49.6 21.4 95 57 130.7C44.5 421.1 2.7 466 2.2 466.5c-2.2 2.3-2.8 5.7-1.5 8.7 1.3 3 4.1 4.8 7.3 4.8 66.3 0 116-31.8 140.6-51.4 32.7 12.3 69 19.4 107.4 19.4 141.4 0 256-93.1 256-208S397.4 32 256 32zM128.2 304H116c-4.4 0-8-3.6-8-8v-16c0-4.4 3.6-8 8-8h12.3c6 0 10.4-3.5 10.4-6.6 0-1.3-.8-2.7-2.1-3.8l-21.9-18.8c-8.5-7.2-13.3-17.5-13.3-28.1 0-21.3 19-38.6 42.4-38.6H156c4.4 0 8 3.6 8 8v16c0 4.4-3.6 8-8 8h-12.3c-6 0-10.4 3.5-10.4 6.6 0 1.3.8 2.7 2.1 3.8l21.9 18.8c8.5 7.2 13.3 17.5 13.3 28.1.1 21.3-19 38.6-42.4 38.6zm191.8-8c0 4.4-3.6 8-8 8h-16c-4.4 0-8-3.6-8-8v-68.2l-24.8 55.8c-2.9 5.9-11.4 5.9-14.3 0L224 227.8V296c0 4.4-3.6 8-8 8h-16c-4.4 0-8-3.6-8-8V192c0-8.8 7.2-16 16-16h16c6.1 0 11.6 3.4 14.3 8.8l17.7 35.4 17.7-35.4c2.7-5.4 8.3-8.8 14.3-8.8h16c8.8 0 16 7.2 16 16v104zm48.3 8H356c-4.4 0-8-3.6-8-8v-16c0-4.4 3.6-8 8-8h12.3c6 0 10.4-3.5 10.4-6.6 0-1.3-.8-2.7-2.1-3.8l-21.9-18.8c-8.5-7.2-13.3-17.5-13.3-28.1 0-21.3 19-38.6 42.4-38.6H396c4.4 0 8 3.6 8 8v16c0 4.4-3.6 8-8 8h-12.3c-6 0-10.4 3.5-10.4 6.6 0 1.3.8 2.7 2.1 3.8l21.9 18.8c8.5 7.2 13.3 17.5 13.3 28.1.1 21.3-18.9 38.6-42.3 38.6z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Icons" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 32 32" xml:space="preserve">
<path d="M17,0C8.7,0,2,6.7,2,15c0,3.4,1.1,6.6,3.2,9.2l-2.1,6.4c-0.1,0.4,0,0.8,0.3,1.1C3.5,31.9,3.8,32,4,32c0.1,0,0.3,0,0.4-0.1
l6.9-3.1C13.1,29.6,15,30,17,30c8.3,0,15-6.7,15-15S25.3,0,17,0z M25.7,20.5c-0.4,1.2-1.9,2.2-3.2,2.4C22.2,23,21.9,23,21.5,23
c-0.8,0-2-0.2-4.1-1.1c-2.4-1-4.8-3.1-6.7-5.8L10.7,16C10.1,15.1,9,13.4,9,11.6c0-2.2,1.1-3.3,1.5-3.8c0.5-0.5,1.2-0.8,2-0.8
c0.2,0,0.3,0,0.5,0c0.7,0,1.2,0.2,1.7,1.2l0.4,0.8c0.3,0.8,0.7,1.7,0.8,1.8c0.3,0.6,0.3,1.1,0,1.6c-0.1,0.3-0.3,0.5-0.5,0.7
c-0.1,0.2-0.2,0.3-0.3,0.3c-0.1,0.1-0.1,0.1-0.2,0.2c0.3,0.5,0.9,1.4,1.7,2.1c1.2,1.1,2.1,1.4,2.6,1.6l0,0c0.2-0.2,0.4-0.6,0.7-0.9
l0.1-0.2c0.5-0.7,1.3-0.9,2.1-0.6c0.4,0.2,2.6,1.2,2.6,1.2l0.2,0.1c0.3,0.2,0.7,0.3,0.9,0.7C26.2,18.5,25.9,19.8,25.7,20.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -71,6 +71,25 @@ export function AuthProvider({ children }) {
return roles.includes(user.role);
};
/**
* hasPermission(section, action)
*
* Sections and their action keys:
* melodies: view, add, delete, safe_edit, full_edit, archetype_access, settings_access, compose_access
* devices: view, add, delete, safe_edit, edit_bells, edit_clock, edit_warranty, full_edit, control
* app_users: view, add, delete, safe_edit, full_edit
* issues_notes: view, add, delete, edit
* mail: view, compose, reply
* crm: activity_log
* crm_customers: full_access, overview, orders_view, orders_edit, quotations_view, quotations_edit,
* comms_view, comms_log, comms_edit, comms_compose, add, delete,
* files_view, files_edit, devices_view, devices_edit
* crm_orders: view (→ crm_customers.orders_view), edit (→ crm_customers.orders_edit) [derived]
* crm_products: view, add, edit
* mfg: view_inventory, edit, provision, firmware_view, firmware_edit
* api_reference: access
* mqtt: access
*/
const hasPermission = (section, action) => {
if (!user) return false;
// sysadmin and admin have full access
@@ -79,13 +98,22 @@ export function AuthProvider({ children }) {
const perms = user.permissions;
if (!perms) return false;
// MQTT is a global flag
if (section === "mqtt") {
return !!perms.mqtt;
// crm_orders is derived from crm_customers
if (section === "crm_orders") {
const cc = perms.crm_customers;
if (!cc) return false;
if (cc.full_access) return true;
if (action === "view") return !!cc.orders_view;
if (action === "edit") return !!cc.orders_edit;
return false;
}
const sectionPerms = perms[section];
if (!sectionPerms) return false;
// crm_customers.full_access grants everything in that section
if (section === "crm_customers" && sectionPerms.full_access) return true;
return !!sectionPerms[action];
};

View File

@@ -0,0 +1,141 @@
import emailIconRaw from "../../assets/comms/email.svg?raw";
import inpersonIconRaw from "../../assets/comms/inperson.svg?raw";
import noteIconRaw from "../../assets/comms/note.svg?raw";
import smsIconRaw from "../../assets/comms/sms.svg?raw";
import whatsappIconRaw from "../../assets/comms/whatsapp.svg?raw";
import callIconRaw from "../../assets/comms/call.svg?raw";
import inboundIconRaw from "../../assets/comms/inbound.svg?raw";
import outboundIconRaw from "../../assets/comms/outbound.svg?raw";
import internalIconRaw from "../../assets/comms/internal.svg?raw";
const TYPE_TONES = {
email: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" },
whatsapp: { bg: "#dcfce7", color: "#166534" },
call: { bg: "#fef9c3", color: "#854d0e" },
sms: { bg: "#fef3c7", color: "#92400e" },
note: { bg: "var(--bg-card-hover)", color: "var(--text-secondary)" },
in_person: { bg: "#ede9fe", color: "#5b21b6" },
};
const DIR_TONES = {
inbound: { bg: "#2c1a1a", color: "#ef4444", title: "Inbound" },
outbound: { bg: "#13261a", color: "#16a34a", title: "Outbound" },
internal: { bg: "#102335", color: "#4dabf7", title: "Internal" },
};
function IconWrap({ title, bg, color, size = 22, children }) {
return (
<span
title={title}
style={{
width: size,
height: size,
borderRadius: "999px",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: bg,
color,
flexShrink: 0,
}}
>
{children}
</span>
);
}
function InlineRawSvg({ raw, size = 12, forceRootFill = true }) {
if (!raw) return null;
let normalized = raw
.replace(/<\?xml[\s\S]*?\?>/gi, "")
.replace(/<!DOCTYPE[\s\S]*?>/gi, "")
.replace(/#000000/gi, "currentColor")
.replace(/#000\b/gi, "currentColor")
.replace(/\sfill="(?!none|currentColor|url\()[^"]*"/gi, ' fill="currentColor"')
.replace(/\sstroke="(?!none|currentColor|url\()[^"]*"/gi, ' stroke="currentColor"')
.replace(/fill\s*:\s*(?!none|currentColor|url\()[^;"]+/gi, "fill:currentColor")
.replace(/stroke\s*:\s*(?!none|currentColor|url\()[^;"]+/gi, "stroke:currentColor");
normalized = forceRootFill
? normalized.replace(/<svg\b/i, '<svg width="100%" height="100%" fill="currentColor"')
: normalized.replace(/<svg\b/i, '<svg width="100%" height="100%"');
return (
<span
aria-hidden="true"
style={{
width: size,
height: size,
display: "inline-flex",
}}
dangerouslySetInnerHTML={{ __html: normalized }}
/>
);
}
const TYPE_ICON_SRC = {
email: emailIconRaw,
whatsapp: whatsappIconRaw,
call: callIconRaw,
sms: smsIconRaw,
note: noteIconRaw,
in_person: inpersonIconRaw,
};
const DIRECTION_ICON_SRC = {
inbound: inboundIconRaw,
outbound: outboundIconRaw,
internal: internalIconRaw,
};
const TYPE_ICON_COLORS = {
note: "#ffffff",
whatsapp: "#06bd00",
call: "#2c2c2c",
sms: "#002981",
};
function TypeSvg({ type }) {
const src = TYPE_ICON_SRC[type];
if (src) {
return <InlineRawSvg raw={src} />;
}
const props = { width: 12, height: 12, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.9, strokeLinecap: "round", strokeLinejoin: "round" };
// Fallback for missing custom icon files (e.g. call.svg).
if (type === "call") {
return <svg {...props}><path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.4 19.4 0 0 1-6-6 19.8 19.8 0 0 1-3.1-8.7A2 2 0 0 1 4 2h3a2 2 0 0 1 2 1.7c.1.8.4 1.6.7 2.3a2 2 0 0 1-.5 2.1L8 9.3a16 16 0 0 0 6.7 6.7l1.2-1.2a2 2 0 0 1 2.1-.5c.7.3 1.5.6 2.3.7A2 2 0 0 1 22 16.9Z"/></svg>;
}
return <svg {...props}><path d="M6 4h9l3 3v13H6z"/><path d="M15 4v4h4"/><path d="M9 13h6"/></svg>;
}
function DirSvg({ direction }) {
const src = DIRECTION_ICON_SRC[direction];
if (src) {
return <InlineRawSvg raw={src} forceRootFill={false} />;
}
const props = { width: 12, height: 12, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round" };
if (direction === "inbound") return <svg {...props}><path d="M20 4 9 15"/><path d="M9 6v9h9"/></svg>;
if (direction === "outbound") return <svg {...props}><path d="m4 20 11-11"/><path d="M15 18V9H6"/></svg>;
return <svg {...props}><path d="M7 7h10"/><path d="m13 3 4 4-4 4"/><path d="M17 17H7"/><path d="m11 13-4 4 4 4"/></svg>;
}
export function getCommTypeTone(type) {
return TYPE_TONES[type] || TYPE_TONES.note;
}
export function CommTypeIconBadge({ type, size = 22 }) {
const tone = getCommTypeTone(type);
const iconColor = TYPE_ICON_COLORS[type] || tone.color;
return (
<IconWrap title={type} bg={tone.bg} color={iconColor} size={size}>
<TypeSvg type={type} />
</IconWrap>
);
}
export function CommDirectionIcon({ direction, size = 22 }) {
const tone = DIR_TONES[direction] || DIR_TONES.internal;
return (
<span title={tone.title} style={{ color: tone.color, display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
<DirSvg direction={direction} />
</span>
);
}

View File

@@ -0,0 +1,928 @@
/**
* ComposeEmailModal
* A full-featured email compose modal using Quill.js (loaded from CDN).
* Features: To / CC / Subject, WYSIWYG rich body, Ctrl+V image paste, file attachments.
*
* Props:
* open boolean
* onClose () => void
* defaultTo string (pre-fill To field)
* defaultSubject string
* customerId string | null (linked customer, optional)
* onSent (entry) => void (called after successful send)
*/
import { useState, useEffect, useRef, useCallback } from "react";
import api from "../../api/client";
// ── Quill loader ──────────────────────────────────────────────────────────────
let _quillReady = false;
let _quillCallbacks = [];
function loadQuill(cb) {
if (_quillReady) { cb(); return; }
_quillCallbacks.push(cb);
if (document.getElementById("__quill_css__")) return; // already loading
// CSS
const link = document.createElement("link");
link.id = "__quill_css__";
link.rel = "stylesheet";
link.href = "https://cdn.quilljs.com/1.3.7/quill.snow.css";
document.head.appendChild(link);
// JS
const script = document.createElement("script");
script.id = "__quill_js__";
script.src = "https://cdn.quilljs.com/1.3.7/quill.min.js";
script.onload = () => {
_quillReady = true;
_quillCallbacks.forEach((fn) => fn());
_quillCallbacks = [];
};
document.head.appendChild(script);
}
// ── Attachment item ───────────────────────────────────────────────────────────
function AttachmentPill({ name, size, onRemove }) {
const kb = size ? ` (${Math.ceil(size / 1024)} KB)` : "";
return (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-full text-xs"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-secondary)" }}
>
<span className="truncate" style={{ maxWidth: 160 }}>{name}{kb}</span>
<button
type="button"
onClick={onRemove}
className="flex-shrink-0 cursor-pointer hover:opacity-70"
style={{ color: "var(--danger)", background: "none", border: "none", padding: 0, lineHeight: 1 }}
>
×
</button>
</div>
);
}
// ── Main component ────────────────────────────────────────────────────────────
export default function ComposeEmailModal({
open,
onClose,
defaultTo = "",
defaultSubject = "",
defaultFromAccount = "",
requireFromAccount = true,
defaultServerAttachments = [],
customerId = null,
onSent,
}) {
const [to, setTo] = useState(defaultTo);
const [cc, setCc] = useState("");
const [subject, setSubject] = useState(defaultSubject);
const [fromAccount, setFromAccount] = useState(defaultFromAccount || "");
const [mailAccounts, setMailAccounts] = useState([]);
const [attachments, setAttachments] = useState([]); // { file: File, name: string, size?: number }[]
const [sending, setSending] = useState(false);
const [error, setError] = useState("");
const [quillLoaded, setQuillLoaded] = useState(_quillReady);
const [showServerFiles, setShowServerFiles] = useState(false);
const [serverFiles, setServerFiles] = useState([]);
const [serverFilesLoading, setServerFilesLoading] = useState(false);
const [serverFileSearch, setServerFileSearch] = useState("");
const [serverFileType, setServerFileType] = useState("all");
const [previewFile, setPreviewFile] = useState(null); // { path, filename, mime_type }
const [editorPreviewDark, setEditorPreviewDark] = useState(true);
const editorRef = useRef(null);
const quillRef = useRef(null);
const fileInputRef = useRef(null);
// Reset fields when opened
useEffect(() => {
if (open) {
setTo(defaultTo);
setCc("");
setSubject(defaultSubject);
setFromAccount(defaultFromAccount || "");
setAttachments([]);
setError("");
setEditorPreviewDark(true);
}
}, [open, defaultTo, defaultSubject, defaultFromAccount]);
useEffect(() => {
if (!open) return;
let cancelled = false;
api.get("/crm/comms/email/accounts")
.then((data) => {
if (cancelled) return;
const accounts = data.accounts || [];
setMailAccounts(accounts);
if (!defaultFromAccount && accounts.length === 1) {
setFromAccount(accounts[0].key);
}
})
.catch(() => {
if (!cancelled) setMailAccounts([]);
});
return () => { cancelled = true; };
}, [open, defaultFromAccount]);
// Load Quill
useEffect(() => {
if (!open) return;
loadQuill(() => setQuillLoaded(true));
}, [open]);
// ESC to close
useEffect(() => {
if (!open) return;
const handler = (e) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
// Init Quill editor
useEffect(() => {
if (!open || !quillLoaded || !editorRef.current) return;
if (quillRef.current) return; // already initialized
const quill = new window.Quill(editorRef.current, {
theme: "snow",
placeholder: "Write your message...",
modules: {
toolbar: [
[{ size: ["small", false, "large", "huge"] }],
["bold", "italic", "underline", "strike"],
[{ color: [] }, { background: [] }],
[{ align: [] }],
[{ list: "ordered" }, { list: "bullet" }, { indent: "-1" }, { indent: "+1" }],
["code-block", "blockquote", "link", "image"],
["clean"],
],
clipboard: { matchVisual: false },
},
});
quillRef.current = quill;
// Force single-row toolbar via JS (defeats Quill's float-based layout)
const container = editorRef.current;
const toolbar = container.querySelector(".ql-toolbar");
const qlContainer = container.querySelector(".ql-container");
if (toolbar && qlContainer) {
// Make editorRef a flex column
container.style.cssText += ";display:flex!important;flex-direction:column!important;";
// Toolbar: single flex row
toolbar.style.cssText += ";display:flex!important;flex-wrap:nowrap!important;align-items:center!important;flex-shrink:0!important;overflow:visible!important;padding:3px 8px!important;";
// Kill floats on every .ql-formats, button, .ql-picker
toolbar.querySelectorAll(".ql-formats").forEach(el => {
el.style.cssText += ";float:none!important;display:inline-flex!important;flex-wrap:nowrap!important;align-items:center!important;flex-shrink:0!important;";
});
toolbar.querySelectorAll("button").forEach(el => {
el.style.cssText += ";float:none!important;display:inline-flex!important;align-items:center!important;justify-content:center!important;width:24px!important;height:24px!important;";
});
toolbar.querySelectorAll(".ql-picker").forEach(el => {
el.style.cssText += ";float:none!important;display:inline-flex!important;align-items:center!important;flex-shrink:0!important;height:24px!important;overflow:visible!important;";
});
// Editor container fills remaining space
qlContainer.style.cssText += ";flex:1!important;min-height:0!important;overflow:hidden!important;";
qlContainer.querySelector(".ql-editor").style.cssText += ";height:100%!important;overflow-y:auto!important;";
}
// Keep color picker icons in sync with currently selected text/highlight colors.
const syncPickerIndicators = () => {
if (!toolbar) return;
const formats = quill.getFormat();
const textColor = typeof formats.color === "string" ? formats.color : "";
const highlightColor = typeof formats.background === "string" ? formats.background : "";
toolbar.style.setProperty("--ql-current-color", textColor || "var(--text-secondary)");
toolbar.style.setProperty("--ql-current-bg", highlightColor || "var(--bg-input)");
toolbar.classList.toggle("ql-has-bg-color", Boolean(highlightColor));
const colorLabel =
toolbar.querySelector(".ql-picker.ql-color-picker.ql-color .ql-picker-label") ||
toolbar.querySelector(".ql-picker.ql-color .ql-picker-label");
const bgLabel =
toolbar.querySelector(".ql-picker.ql-color-picker.ql-background .ql-picker-label") ||
toolbar.querySelector(".ql-picker.ql-background .ql-picker-label");
if (colorLabel) {
colorLabel.style.boxShadow = `inset 0 -3px 0 ${textColor || "var(--text-secondary)"}`;
colorLabel.querySelectorAll(".ql-stroke, .ql-stroke-miter").forEach((el) => {
el.style.setProperty("stroke", textColor || "var(--text-secondary)", "important");
});
colorLabel.querySelectorAll(".ql-fill, .ql-color-label").forEach((el) => {
el.style.setProperty("fill", textColor || "var(--text-secondary)", "important");
});
let swatch = colorLabel.querySelector(".compose-picker-swatch");
if (!swatch) {
swatch = document.createElement("span");
swatch.className = "compose-picker-swatch";
swatch.style.cssText = "position:absolute;right:2px;top:2px;width:7px;height:7px;border-radius:999px;border:1px solid rgba(255,255,255,0.35);pointer-events:none;";
colorLabel.appendChild(swatch);
}
swatch.style.background = textColor || "var(--text-secondary)";
}
if (bgLabel) {
bgLabel.style.boxShadow = highlightColor
? `inset 0 -7px 0 ${highlightColor}`
: "inset 0 -7px 0 transparent";
bgLabel.style.borderBottom = "1px solid var(--border-secondary)";
bgLabel.querySelectorAll(".ql-fill, .ql-color-label").forEach((el) => {
el.style.setProperty("fill", "var(--text-secondary)", "important");
});
let swatch = bgLabel.querySelector(".compose-picker-swatch");
if (!swatch) {
swatch = document.createElement("span");
swatch.className = "compose-picker-swatch";
swatch.style.cssText = "position:absolute;right:2px;top:2px;width:7px;height:7px;border-radius:999px;border:1px solid rgba(255,255,255,0.35);pointer-events:none;";
bgLabel.appendChild(swatch);
}
swatch.style.background = highlightColor || "transparent";
swatch.style.borderColor = highlightColor ? "rgba(255,255,255,0.35)" : "var(--border-secondary)";
}
};
quill.on("selection-change", syncPickerIndicators);
quill.on("text-change", syncPickerIndicators);
quill.on("editor-change", syncPickerIndicators);
syncPickerIndicators();
// Handle Ctrl+V image paste
quill.root.addEventListener("paste", (e) => {
const items = (e.clipboardData || e.originalEvent?.clipboardData)?.items;
if (!items) return;
for (const item of items) {
if (item.type.startsWith("image/")) {
e.preventDefault();
const blob = item.getAsFile();
const reader = new FileReader();
reader.onload = (ev) => {
const range = quill.getSelection(true);
quill.insertEmbed(range.index, "image", ev.target.result);
};
reader.readAsDataURL(blob);
break;
}
}
});
return () => {
quill.off("selection-change", syncPickerIndicators);
quill.off("text-change", syncPickerIndicators);
quill.off("editor-change", syncPickerIndicators);
quillRef.current = null;
};
}, [open, quillLoaded]);
const getContent = useCallback(() => {
const q = quillRef.current;
if (!q) return { html: "", text: "" };
const html = q.root.innerHTML;
const text = q.getText().trim();
return { html, text };
}, []);
const handleFileAdd = (files) => {
const newFiles = Array.from(files).map((f) => ({ file: f, name: f.name }));
setAttachments((prev) => [...prev, ...newFiles]);
};
useEffect(() => {
if (!open || !defaultServerAttachments?.length) return;
let cancelled = false;
(async () => {
for (const f of defaultServerAttachments) {
try {
const token = localStorage.getItem("access_token");
const resp = await fetch(`/api/crm/nextcloud/file?path=${encodeURIComponent(f.path)}&token=${encodeURIComponent(token)}`);
if (!resp.ok) continue;
const blob = await resp.blob();
if (cancelled) return;
const file = new File([blob], f.filename, { type: f.mime_type || "application/octet-stream" });
setAttachments((prev) => {
if (prev.some((a) => a.name === f.filename)) return prev;
return [...prev, { file, name: f.filename }];
});
} catch {
// ignore pre-attachment failures
}
}
})();
return () => { cancelled = true; };
}, [open, defaultServerAttachments]);
// Open server file picker and load files for this customer
const openServerFiles = async () => {
setShowServerFiles(true);
setServerFileSearch("");
setServerFileType("all");
if (serverFiles.length > 0) return; // already loaded
setServerFilesLoading(true);
try {
const token = localStorage.getItem("access_token");
const resp = await fetch(`/api/crm/nextcloud/browse-all?customer_id=${customerId}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) throw new Error("Failed to load files");
const data = await resp.json();
setServerFiles((data.items || []).filter((f) => !f.is_dir));
} catch {
setServerFiles([]);
} finally {
setServerFilesLoading(false);
}
};
// Attach a server file by downloading it as a Blob and adding to attachments
const attachServerFile = async (f) => {
try {
const token = localStorage.getItem("access_token");
const resp = await fetch(`/api/crm/nextcloud/file?path=${encodeURIComponent(f.path)}&token=${encodeURIComponent(token)}`);
if (!resp.ok) throw new Error("Download failed");
const blob = await resp.blob();
const file = new File([blob], f.filename, { type: f.mime_type || "application/octet-stream" });
setAttachments((prev) => [...prev, { file, name: f.filename }]);
} catch (err) {
setError(`Could not attach ${f.filename}: ${err.message}`);
}
setShowServerFiles(false);
};
// Determine file type category for filter
function getFileCategory(f) {
const mime = f.mime_type || "";
const sub = f.subfolder || "";
if (sub === "quotations") return "quotation";
if (mime === "application/pdf" || sub.includes("invoic") || sub.includes("document")) return "document";
if (mime.startsWith("image/") || mime.startsWith("video/") || mime.startsWith("audio/") || sub.includes("media")) return "media";
return "document";
}
const handleSend = async () => {
const { html, text } = getContent();
const toClean = to.trim();
const emailOk = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(toClean);
if (requireFromAccount && !fromAccount) { setError("Please select a sender account."); return; }
if (!to.trim()) { setError("Please enter a recipient email address."); return; }
if (!emailOk) { setError("Please enter a valid recipient email address."); return; }
if (!subject.trim()) { setError("Please enter a subject."); return; }
if (!text && !html.replace(/<[^>]*>/g, "").trim()) { setError("Please write a message."); return; }
setError("");
setSending(true);
try {
const ccList = cc.split(",").map((s) => s.trim()).filter(Boolean);
const token = localStorage.getItem("access_token");
const fd = new FormData();
if (customerId) fd.append("customer_id", customerId);
if (fromAccount) fd.append("from_account", fromAccount);
fd.append("to", to.trim());
fd.append("subject", subject.trim());
fd.append("body", text);
fd.append("body_html", html);
fd.append("cc", JSON.stringify(ccList));
for (const { file } of attachments) {
fd.append("files", file, file.name);
}
const resp = await fetch("/api/crm/comms/email/send", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: fd,
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `Server error ${resp.status}`);
}
const data = await resp.json();
if (onSent) onSent(data.entry || data);
onClose();
} catch (err) {
setError(err.message || "Failed to send email.");
} finally {
setSending(false);
}
};
if (!open) return null;
const inputStyle = {
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-primary)",
color: "var(--text-primary)",
border: "1px solid",
borderRadius: 6,
padding: "7px 10px",
fontSize: 13,
width: "100%",
outline: "none",
};
return (
<div
style={{
position: "fixed", inset: 0, zIndex: 1000,
backgroundColor: "rgba(0,0,0,0.55)",
display: "flex", alignItems: "center", justifyContent: "center",
padding: 80,
}}
>
<div
style={{
backgroundColor: "var(--bg-card)",
border: "1px solid var(--border-primary)",
borderRadius: 12,
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
overflow: "hidden",
boxShadow: "0 20px 60px rgba(0,0,0,0.4)",
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-5 py-4"
style={{ borderBottom: "1px solid var(--border-primary)" }}
>
<h2 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>
New Email
</h2>
<button
type="button"
onClick={onClose}
className="cursor-pointer hover:opacity-70"
style={{ color: "var(--text-muted)", background: "none", border: "none", fontSize: 20, lineHeight: 1 }}
>
×
</button>
</div>
{/* Fields */}
<div className="px-5 py-4" style={{ borderBottom: "1px solid var(--border-secondary)" }}>
<div style={{ display: "grid", gridTemplateColumns: "200px 1fr 1fr", gap: 10, marginBottom: 10 }}>
<div>
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>Send As</label>
<select
className="compose-email-input"
style={inputStyle}
value={fromAccount}
onChange={(e) => setFromAccount(e.target.value)}
>
<option value="">Select sender...</option>
{mailAccounts.filter((a) => a.allow_send).map((a) => (
<option key={a.key} value={a.key}>{a.label} ({a.email})</option>
))}
</select>
</div>
<div>
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>To</label>
<input
className="compose-email-input"
style={inputStyle}
value={to}
onChange={(e) => setTo(e.target.value)}
placeholder="recipient@example.com"
type="email"
/>
</div>
<div>
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>CC</label>
<input
className="compose-email-input"
style={inputStyle}
value={cc}
onChange={(e) => setCc(e.target.value)}
placeholder="cc1@example.com, cc2@..."
/>
</div>
</div>
<div>
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>Subject</label>
<input
className="compose-email-input"
style={inputStyle}
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Email subject"
/>
</div>
</div>
{/* Quill Editor — Quill injects toolbar+editor into editorRef */}
<div className="quill-compose-wrapper" style={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0, position: "relative" }}>
<div style={{ position: "absolute", top: 8, right: 12, zIndex: 20 }}>
<button
type="button"
onClick={() => setEditorPreviewDark((v) => !v)}
title={editorPreviewDark ? "Switch to light preview" : "Switch to dark preview"}
style={{
padding: "4px 10px", fontSize: 11, borderRadius: 6, cursor: "pointer",
border: "1px solid rgba(128,128,128,0.4)",
backgroundColor: editorPreviewDark ? "rgba(0,0,0,0.55)" : "rgba(255,255,255,0.7)",
color: editorPreviewDark ? "#e0e0e0" : "#333",
backdropFilter: "blur(4px)",
}}
>
{editorPreviewDark ? "☀ Light" : "🌙 Dark"}
</button>
</div>
{quillLoaded ? (
<div ref={editorRef} style={{ flex: 1, minHeight: 0 }} />
) : (
<div className="flex items-center justify-center py-12" style={{ color: "var(--text-muted)" }}>
Loading editor...
</div>
)}
</div>
{/* Attachments */}
{attachments.length > 0 && (
<div className="px-5 py-3 flex flex-wrap gap-2" style={{ borderTop: "1px solid var(--border-secondary)" }}>
{attachments.map((a, i) => (
<AttachmentPill
key={i}
name={a.name}
size={a.file.size}
onRemove={() => setAttachments((prev) => prev.filter((_, j) => j !== i))}
/>
))}
</div>
)}
{/* Footer */}
<div
className="flex items-center justify-between px-5 py-4"
style={{ borderTop: "1px solid var(--border-primary)" }}
>
<div className="flex items-center gap-3">
{customerId && (
<button
type="button"
onClick={openServerFiles}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card)" }}
>
🗂 Attach from Server
</button>
)}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card)" }}
>
📎 Attach
</button>
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: "none" }}
onChange={(e) => handleFileAdd(e.target.files)}
/>
<button
type="button"
onClick={() => {
const sig = localStorage.getItem("mail_signature") || "";
if (!sig.trim() || sig === "<p><br></p>") return;
const q = quillRef.current;
if (!q) return;
const current = q.root.innerHTML;
q.clipboard.dangerouslyPasteHTML(current + '<p><br></p><hr/><div class="mail-sig">' + sig + "</div>");
}}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card)" }}
title="Append signature to message"
>
Add Signature
</button>
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
Tip: Paste images directly with Ctrl+V
</span>
</div>
<div className="flex items-center gap-3">
{error && (
<span className="text-xs" style={{ color: "var(--danger-text)" }}>{error}</span>
)}
<button
type="button"
onClick={onClose}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card)" }}
>
Cancel
</button>
<button
type="button"
onClick={handleSend}
disabled={sending}
className="px-4 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-90"
style={{
backgroundColor: "var(--btn-primary)",
color: "var(--text-white)",
opacity: sending ? 0.7 : 1,
}}
>
{sending ? "Sending..." : "Send"}
</button>
</div>
</div>
</div>
{/* Server File Picker Modal */}
{showServerFiles && (
<div
style={{
position: "fixed", inset: 0, zIndex: 1100,
backgroundColor: "rgba(0,0,0,0.6)",
display: "flex", alignItems: "center", justifyContent: "center",
}}
onClick={() => setShowServerFiles(false)}
>
<div
style={{
backgroundColor: "var(--bg-card)",
border: "1px solid var(--border-primary)",
borderRadius: 12,
width: 540,
maxHeight: "70vh",
display: "flex",
flexDirection: "column",
overflow: "hidden",
boxShadow: "0 16px 48px rgba(0,0,0,0.5)",
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4" style={{ borderBottom: "1px solid var(--border-primary)", flexShrink: 0 }}>
<h3 className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>Attach File from Server</h3>
<button type="button" onClick={() => setShowServerFiles(false)} style={{ color: "var(--text-muted)", background: "none", border: "none", fontSize: 20, cursor: "pointer", lineHeight: 1 }}>×</button>
</div>
{/* Search + Type filter */}
<div className="px-4 py-3 flex gap-2" style={{ borderBottom: "1px solid var(--border-secondary)", flexShrink: 0 }}>
<input
type="text"
placeholder="Search by filename..."
value={serverFileSearch}
onChange={(e) => setServerFileSearch(e.target.value)}
autoFocus
className="flex-1 px-3 py-1.5 text-sm rounded border"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-primary)", color: "var(--text-primary)" }}
/>
<select
value={serverFileType}
onChange={(e) => setServerFileType(e.target.value)}
className="px-3 py-1.5 text-sm rounded border"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-primary)", color: "var(--text-primary)" }}
>
<option value="all">All types</option>
<option value="document">Documents</option>
<option value="quotation">Quotations</option>
<option value="media">Media</option>
</select>
</div>
{/* File list */}
<div style={{ overflowY: "auto", flex: 1 }}>
{serverFilesLoading && (
<div className="text-center py-8 text-sm" style={{ color: "var(--text-muted)" }}>Loading files...</div>
)}
{!serverFilesLoading && serverFiles.length === 0 && (
<div className="text-center py-8 text-sm" style={{ color: "var(--text-muted)" }}>No files found for this customer.</div>
)}
{!serverFilesLoading && (() => {
const token = localStorage.getItem("access_token");
const filtered = serverFiles.filter((f) => {
const matchSearch = !serverFileSearch.trim() || f.filename.toLowerCase().includes(serverFileSearch.toLowerCase());
const matchType = serverFileType === "all" || getFileCategory(f) === serverFileType;
return matchSearch && matchType && !f.is_dir;
});
if (filtered.length === 0 && serverFiles.length > 0) {
return <div className="text-center py-8 text-sm" style={{ color: "var(--text-muted)" }}>No files match your search.</div>;
}
return filtered.map((f) => {
const alreadyAttached = attachments.some((a) => a.name === f.filename);
const cat = getFileCategory(f);
const catColors = {
quotation: { bg: "#1a2d1e", color: "#6aab7a" },
document: { bg: "#1e1a2d", color: "#a78bfa" },
media: { bg: "#2d2a1a", color: "#c9a84c" },
};
const c = catColors[cat] || catColors.document;
const kb = f.size > 0 ? `${(f.size / 1024).toFixed(0)} KB` : "";
const isImage = (f.mime_type || "").startsWith("image/");
const thumbUrl = `/api/crm/nextcloud/file?path=${encodeURIComponent(f.path)}&token=${encodeURIComponent(token)}`;
// Thumbnail / icon
const thumb = isImage ? (
<img
src={thumbUrl}
alt=""
onClick={(e) => { e.stopPropagation(); setPreviewFile(f); }}
style={{ width: 40, height: 40, objectFit: "cover", borderRadius: 5, flexShrink: 0, cursor: "zoom-in", border: "1px solid var(--border-secondary)" }}
/>
) : (
<div
onClick={(e) => { e.stopPropagation(); setPreviewFile(f); }}
style={{ width: 40, height: 40, borderRadius: 5, flexShrink: 0, backgroundColor: c.bg, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 20, cursor: "zoom-in", border: "1px solid var(--border-secondary)" }}
>
{cat === "quotation" ? "🧾" : cat === "media" ? "🎵" : "📄"}
</div>
);
return (
<div
key={f.path}
onClick={() => !alreadyAttached && attachServerFile(f)}
style={{
display: "flex", alignItems: "center", gap: 12,
padding: "8px 16px",
borderBottom: "1px solid var(--border-secondary)",
cursor: alreadyAttached ? "default" : "pointer",
opacity: alreadyAttached ? 0.5 : 1,
}}
onMouseEnter={(e) => { if (!alreadyAttached) e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = ""; }}
>
{thumb}
<span className="flex-1 text-sm truncate" style={{ color: "var(--text-primary)" }}>{f.filename}</span>
<span className="text-xs flex-shrink-0" style={{ color: "var(--text-muted)" }}>{kb}</span>
{alreadyAttached
? <span className="text-xs flex-shrink-0" style={{ color: "var(--accent)" }}>Attached</span>
: <span style={{ fontSize: 11, padding: "2px 7px", borderRadius: 10, backgroundColor: c.bg, color: c.color, fontWeight: 500, flexShrink: 0, whiteSpace: "nowrap" }}>{cat}</span>
}
</div>
);
});
})()}
</div>
</div>
</div>
)}
{/* File preview modal */}
{previewFile && (() => {
const token = localStorage.getItem("access_token");
const fileUrl = `/api/crm/nextcloud/file?path=${encodeURIComponent(previewFile.path)}&token=${encodeURIComponent(token)}`;
const mime = previewFile.mime_type || "";
const isImage = mime.startsWith("image/");
const isPdf = mime === "application/pdf";
return (
<div
style={{ position: "fixed", inset: 0, zIndex: 1200, backgroundColor: "rgba(0,0,0,0.8)", display: "flex", alignItems: "center", justifyContent: "center", padding: 40 }}
onClick={() => setPreviewFile(null)}
>
<div
style={{ position: "relative", maxWidth: "90vw", maxHeight: "85vh", display: "flex", flexDirection: "column", borderRadius: 10, overflow: "hidden", backgroundColor: "var(--bg-card)", boxShadow: "0 20px 60px rgba(0,0,0,0.6)" }}
onClick={(e) => e.stopPropagation()}
>
{/* Preview header */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 16px", borderBottom: "1px solid var(--border-primary)", flexShrink: 0 }}>
<span className="text-sm font-medium truncate" style={{ color: "var(--text-heading)", maxWidth: 400 }}>{previewFile.filename}</span>
<div style={{ display: "flex", gap: 8, marginLeft: 16, flexShrink: 0 }}>
<a href={fileUrl} download={previewFile.filename} style={{ fontSize: 12, color: "var(--accent)", textDecoration: "none" }}>Download</a>
<button onClick={() => setPreviewFile(null)} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-muted)", fontSize: 20, lineHeight: 1 }}>×</button>
</div>
</div>
{/* Preview body */}
<div style={{ flex: 1, minHeight: 0, overflow: "auto", display: "flex", alignItems: "center", justifyContent: "center", padding: 16, backgroundColor: "var(--bg-primary)" }}>
{isImage && <img src={fileUrl} alt={previewFile.filename} style={{ maxWidth: "80vw", maxHeight: "70vh", objectFit: "contain", borderRadius: 6 }} />}
{isPdf && <iframe src={fileUrl} title={previewFile.filename} style={{ width: "75vw", height: "70vh", border: "none", borderRadius: 6 }} />}
{!isImage && !isPdf && (
<div style={{ textAlign: "center", color: "var(--text-muted)" }}>
<div style={{ fontSize: 48, marginBottom: 12 }}>📄</div>
<div className="text-sm">{previewFile.filename}</div>
<a href={fileUrl} download={previewFile.filename} className="text-sm" style={{ color: "var(--accent)", marginTop: 8, display: "inline-block" }}>Download to view</a>
</div>
)}
</div>
</div>
</div>
);
})()}
{/* Quill snow theme overrides — layout handled via JS, only cosmetics here */}
<style>{`
.quill-compose-wrapper .ql-toolbar.ql-snow::after { display: none !important; }
.quill-compose-wrapper .ql-toolbar.ql-snow {
background: var(--bg-card-hover) !important;
border-top: none !important;
border-left: none !important;
border-right: none !important;
border-bottom: 1px solid var(--border-secondary) !important;
position: relative !important;
z-index: 3 !important;
overflow: visible !important;
}
.quill-compose-wrapper .ql-toolbar .ql-formats {
gap: 1px !important;
margin: 0 10px 0 0 !important;
padding: 0 10px 0 0 !important;
border-right: 1px solid #4b5563 !important;
position: relative !important;
}
.quill-compose-wrapper .ql-toolbar .ql-formats:last-child {
border-right: none !important;
padding-right: 0 !important;
margin-right: 0 !important;
}
.quill-compose-wrapper .ql-toolbar .ql-picker,
.quill-compose-wrapper .ql-toolbar .ql-picker-label,
.quill-compose-wrapper .ql-toolbar .ql-picker-item {
color: var(--text-secondary) !important;
}
.quill-compose-wrapper .ql-toolbar .ql-picker-label {
position: relative !important;
}
.quill-compose-wrapper .ql-toolbar .ql-picker.ql-size .ql-picker-label::before {
color: var(--text-secondary) !important;
}
.quill-compose-wrapper .ql-toolbar button:hover,
.quill-compose-wrapper .ql-toolbar button.ql-active { background: var(--bg-primary) !important; }
.quill-compose-wrapper .ql-toolbar .ql-picker.ql-color .ql-picker-label .ql-stroke,
.quill-compose-wrapper .ql-toolbar .ql-picker.ql-color .ql-picker-label .ql-fill {
stroke: var(--ql-current-color, var(--text-secondary)) !important;
fill: var(--ql-current-color, var(--text-secondary)) !important;
}
.quill-compose-wrapper .ql-toolbar .ql-picker.ql-background .ql-picker-label .ql-fill {
fill: var(--text-secondary) !important;
}
.quill-compose-wrapper .ql-toolbar .ql-picker.ql-background .ql-picker-label .ql-stroke {
stroke: var(--border-secondary) !important;
}
/* Dropdowns: keep anchored to picker and above editor scroll area */
.quill-compose-wrapper .ql-toolbar .ql-picker {
position: relative !important;
}
.quill-compose-wrapper .ql-picker-options {
position: absolute !important;
z-index: 40 !important;
background: var(--bg-card) !important;
border: 1px solid var(--border-primary) !important;
box-shadow: 0 8px 24px rgba(0,0,0,0.45) !important;
border-radius: 6px !important;
max-height: 220px !important;
overflow-y: auto !important;
}
.quill-compose-wrapper .ql-stroke { stroke: var(--text-secondary) !important; }
.quill-compose-wrapper .ql-toolbar button:hover .ql-stroke,
.quill-compose-wrapper .ql-toolbar button.ql-active .ql-stroke { stroke: var(--accent) !important; }
.quill-compose-wrapper .ql-fill { fill: var(--text-secondary) !important; }
.quill-compose-wrapper .ql-toolbar button:hover .ql-fill,
.quill-compose-wrapper .ql-toolbar button.ql-active .ql-fill { fill: var(--accent) !important; }
.quill-compose-wrapper .ql-toolbar .ql-picker.ql-color .ql-picker-label:hover .ql-stroke,
.quill-compose-wrapper .ql-toolbar .ql-picker.ql-color.ql-expanded .ql-picker-label .ql-stroke,
.quill-compose-wrapper .ql-toolbar .ql-picker.ql-color .ql-picker-label:hover .ql-fill,
.quill-compose-wrapper .ql-toolbar .ql-picker.ql-color.ql-expanded .ql-picker-label .ql-fill {
stroke: var(--ql-current-color, var(--text-secondary)) !important;
fill: var(--ql-current-color, var(--text-secondary)) !important;
}
.quill-compose-wrapper .ql-toolbar .ql-picker.ql-background .ql-picker-label:hover .ql-fill,
.quill-compose-wrapper .ql-toolbar .ql-picker.ql-background.ql-expanded .ql-picker-label .ql-fill {
fill: var(--text-secondary) !important;
}
.quill-compose-wrapper .ql-container.ql-snow {
border-left: none !important;
border-right: none !important;
border-bottom: none !important;
font-size: 14px !important;
}
.quill-compose-wrapper .ql-editor {
color: ${editorPreviewDark ? "var(--text-primary)" : "#1a1a1a"} !important;
background: ${editorPreviewDark ? "var(--bg-input)" : "#ffffff"} !important;
}
.quill-compose-wrapper .ql-editor.ql-blank::before {
color: ${editorPreviewDark ? "var(--text-muted)" : "#6b7280"} !important;
font-style: normal !important;
}
.quill-compose-wrapper .ql-editor blockquote {
border-left: 3px solid ${editorPreviewDark ? "var(--border-primary)" : "#d1d5db"} !important;
color: ${editorPreviewDark ? "var(--text-secondary)" : "#6b7280"} !important;
padding-left: 12px !important;
}
.compose-email-input {
border: 1px solid var(--border-primary) !important;
box-shadow: none !important;
background-color: var(--bg-input) !important;
color: var(--text-primary) !important;
}
.compose-email-input:focus {
border-color: var(--border-primary) !important;
box-shadow: none !important;
outline: none !important;
}
.compose-email-input:-webkit-autofill,
.compose-email-input:-webkit-autofill:hover,
.compose-email-input:-webkit-autofill:focus {
-webkit-box-shadow: 0 0 0 1000px var(--bg-input) inset !important;
-webkit-text-fill-color: var(--text-primary) !important;
border: 1px solid var(--border-primary) !important;
transition: background-color 9999s ease-out 0s;
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,669 @@
/**
* MailViewModal
* Full email view with:
* - Dark-themed iframe body
* - ESC / click-outside to close
* - Save inline images (via JSON endpoint) or attachments (re-fetched from IMAP)
* to customer's Nextcloud media folder
*
* Props:
* open boolean
* onClose () => void
* entry CommInDB
* customer customer object | null
* onReply (defaultTo: string) => void
*/
import { useRef, useEffect, useState } from "react";
const SUBFOLDERS = ["received_media", "documents", "sent_media", "photos"];
// ── Add Customer mini modal ────────────────────────────────────────────────────
function AddCustomerModal({ email, onClose, onCreated }) {
const [name, setName] = useState("");
const [surname, setSurname] = useState("");
const [organization, setOrganization] = useState("");
const [saving, setSaving] = useState(false);
const [err, setErr] = useState("");
useEffect(() => {
const handler = (e) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
const handleSave = async () => {
if (!name.trim()) { setErr("Please enter a first name."); return; }
setSaving(true);
setErr("");
try {
const token = localStorage.getItem("access_token");
const body = {
name: name.trim(),
surname: surname.trim(),
organization: organization.trim(),
language: "en",
contacts: [{ type: "email", label: "Email", value: email, primary: true }],
};
const resp = await fetch("/api/crm/customers", {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) {
const e = await resp.json().catch(() => ({}));
throw new Error(e.detail || `Error ${resp.status}`);
}
const data = await resp.json();
onCreated(data);
} catch (e) {
setErr(e.message);
} finally {
setSaving(false);
}
};
const inputStyle = {
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-primary)",
color: "var(--text-primary)",
border: "1px solid",
borderRadius: 6,
padding: "6px 10px",
fontSize: 13,
width: "100%",
outline: "none",
};
return (
<div
style={{
position: "fixed", inset: 0, zIndex: 1100,
backgroundColor: "rgba(0,0,0,0.6)",
display: "flex", alignItems: "center", justifyContent: "center",
}}
onClick={onClose}
>
<div
style={{
backgroundColor: "var(--bg-card)",
border: "1px solid var(--border-primary)",
borderRadius: 10,
padding: 24,
width: 400,
boxShadow: "0 12px 40px rgba(0,0,0,0.5)",
}}
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-sm font-semibold mb-1" style={{ color: "var(--text-heading)" }}>
Add Customer
</h3>
<p className="text-xs mb-4" style={{ color: "var(--text-muted)" }}>
Adding <strong style={{ color: "var(--accent)" }}>{email}</strong> as a new customer.
</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 10 }}>
<div>
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>First Name *</label>
<input style={inputStyle} value={name} onChange={(e) => setName(e.target.value)} placeholder="First name" autoFocus />
</div>
<div>
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>Last Name</label>
<input style={inputStyle} value={surname} onChange={(e) => setSurname(e.target.value)} placeholder="Last name" />
</div>
</div>
<div className="mb-3">
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>Organization</label>
<input style={inputStyle} value={organization} onChange={(e) => setOrganization(e.target.value)} placeholder="Church, school, etc." />
</div>
<div className="mb-4">
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>Email</label>
<input style={{ ...inputStyle, opacity: 0.6, cursor: "not-allowed" }} value={email} readOnly />
</div>
{err && <p className="text-xs mb-3" style={{ color: "var(--danger-text)" }}>{err}</p>}
<div className="flex justify-end gap-2">
<button
onClick={onClose}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card)" }}
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-90"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", opacity: saving ? 0.7 : 1 }}
>
{saving ? "Saving..." : "Add Customer"}
</button>
</div>
</div>
</div>
);
}
// ── Save-to-DB mini modal ─────────────────────────────────────────────────────
function SaveModal({ item, commId, onClose }) {
// item: { type: "inline"|"attachment", filename, mime_type, dataUri?, attachmentIndex? }
const [filename, setFilename] = useState(item.filename || "file");
const [subfolder, setSubfolder] = useState("received_media");
const [saving, setSaving] = useState(false);
const [err, setErr] = useState("");
const [done, setDone] = useState(false);
// ESC to close
useEffect(() => {
const handler = (e) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
const handleSave = async () => {
if (!filename.trim()) { setErr("Please enter a filename."); return; }
setSaving(true);
setErr("");
try {
const token = localStorage.getItem("access_token");
if (item.type === "inline") {
// JSON body — avoids form multipart size limits for large data URIs
const resp = await fetch(`/api/crm/comms/email/${commId}/save-inline`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
data_uri: item.dataUri,
filename: filename.trim(),
subfolder,
mime_type: item.mime_type,
}),
});
if (!resp.ok) {
const e = await resp.json().catch(() => ({}));
throw new Error(e.detail || `Error ${resp.status}`);
}
} else {
// Attachment: re-fetched from IMAP server-side
const fd = new FormData();
fd.append("filename", filename.trim());
fd.append("subfolder", subfolder);
const resp = await fetch(
`/api/crm/comms/email/${commId}/save-attachment/${item.attachmentIndex}`,
{
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: fd,
}
);
if (!resp.ok) {
const e = await resp.json().catch(() => ({}));
throw new Error(e.detail || `Error ${resp.status}`);
}
}
setDone(true);
} catch (e) {
setErr(e.message);
} finally {
setSaving(false);
}
};
const inputStyle = {
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-primary)",
color: "var(--text-primary)",
border: "1px solid",
borderRadius: 6,
padding: "6px 10px",
fontSize: 13,
width: "100%",
outline: "none",
};
return (
<div
style={{
position: "fixed", inset: 0, zIndex: 1100,
backgroundColor: "rgba(0,0,0,0.6)",
display: "flex", alignItems: "center", justifyContent: "center",
}}
// Do NOT close on backdrop click user must use Cancel
>
<div
style={{
backgroundColor: "var(--bg-card)",
border: "1px solid var(--border-primary)",
borderRadius: 10,
padding: 24,
width: 380,
boxShadow: "0 12px 40px rgba(0,0,0,0.5)",
}}
>
{done ? (
<div className="text-center py-4">
<div style={{ fontSize: 32, marginBottom: 8 }}></div>
<p className="text-sm font-medium" style={{ color: "var(--text-heading)" }}>Saved successfully</p>
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
File stored in <strong>{subfolder}/</strong>
</p>
<button
onClick={onClose}
className="mt-4 px-4 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
Done
</button>
</div>
) : (
<>
<h3 className="text-sm font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Save to Customer Media
</h3>
<div className="mb-3">
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>
Filename
</label>
<input style={inputStyle} value={filename} onChange={(e) => setFilename(e.target.value)} />
</div>
<div className="mb-4">
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>
Folder
</label>
<select value={subfolder} onChange={(e) => setSubfolder(e.target.value)} style={inputStyle}>
{SUBFOLDERS.map((f) => (
<option key={f} value={f}>{f}</option>
))}
</select>
</div>
{err && (
<p className="text-xs mb-3" style={{ color: "var(--danger-text)" }}>{err}</p>
)}
<div className="flex justify-end gap-2">
<button
onClick={onClose}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card)" }}
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-90"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", opacity: saving ? 0.7 : 1 }}
>
{saving ? "Saving..." : "Save"}
</button>
</div>
</>
)}
</div>
</div>
);
}
// ── Main modal ────────────────────────────────────────────────────────────────
export default function MailViewModal({ open, onClose, entry, customer, onReply, onCustomerAdded }) {
const iframeRef = useRef(null);
const [saveItem, setSaveItem] = useState(null);
const [inlineImages, setInlineImages] = useState([]);
const [bodyDark, setBodyDark] = useState(true);
const [showAddCustomer, setShowAddCustomer] = useState(false);
const [addedCustomer, setAddedCustomer] = useState(null);
// ESC to close
useEffect(() => {
if (!open) return;
const handler = (e) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
// Extract inline images from HTML body
useEffect(() => {
if (!entry?.body_html) { setInlineImages([]); return; }
const parser = new DOMParser();
const doc = parser.parseFromString(entry.body_html, "text/html");
const imgs = Array.from(doc.querySelectorAll("img[src^='data:']"));
const found = imgs.map((img, i) => {
const src = img.getAttribute("src");
const mimeMatch = src.match(/^data:([^;]+);/);
const mime = mimeMatch ? mimeMatch[1] : "image/png";
const ext = mime.split("/")[1] || "png";
return {
type: "inline",
filename: `inline-image-${i + 1}.${ext}`,
mime_type: mime,
dataUri: src,
};
});
setInlineImages(found);
}, [entry]);
// Reset dark mode when new entry opens
useEffect(() => { setBodyDark(true); }, [entry]);
// Reset addedCustomer when a new entry opens
useEffect(() => { setAddedCustomer(null); }, [entry]);
if (!open || !entry) return null;
const isInbound = entry.direction === "inbound";
const fromLabel = isInbound
? (entry.from_addr || customer?.name || "Unknown Sender")
: "Me";
const toLabel = Array.isArray(entry.to_addrs)
? entry.to_addrs.join(", ")
: (entry.to_addrs || "");
const hasHtml = !!entry.body_html && entry.body_html.trim().length > 0;
const attachments = Array.isArray(entry.attachments) ? entry.attachments : [];
const canSave = !!entry.customer_id;
const handleReply = () => {
const replyTo = isInbound ? (entry.from_addr || "") : toLabel;
if (onReply) onReply(replyTo, entry.mail_account || "");
onClose();
};
const iframeDoc = hasHtml
? entry.body_html
: `<pre style="font-family:inherit;white-space:pre-wrap;margin:0">${(entry.body || "").replace(/</g, "&lt;")}</pre>`;
return (
<>
{/* Backdrop — click outside closes */}
<div
style={{
position: "fixed", inset: 0, zIndex: 1000,
backgroundColor: "rgba(0,0,0,0.55)",
display: "flex", alignItems: "center", justifyContent: "center",
padding: 60,
}}
onClick={onClose}
>
{/* Modal box */}
<div
style={{
backgroundColor: "var(--bg-card)",
border: "1px solid var(--border-primary)",
borderRadius: 12,
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
overflow: "hidden",
boxShadow: "0 20px 60px rgba(0,0,0,0.4)",
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div
className="flex items-center justify-between px-5 py-4"
style={{ borderBottom: "1px solid var(--border-primary)", flexShrink: 0 }}
>
<div style={{ flex: 1, minWidth: 0 }}>
<h2 className="text-base font-semibold truncate" style={{ color: "var(--text-heading)" }}>
{entry.subject || <span style={{ color: "var(--text-muted)", fontStyle: "italic" }}>(no subject)</span>}
</h2>
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
{entry.occurred_at ? new Date(entry.occurred_at).toLocaleString() : ""}
</p>
</div>
<button
type="button"
onClick={onClose}
className="cursor-pointer hover:opacity-70 ml-4 flex-shrink-0"
style={{ color: "var(--text-muted)", background: "none", border: "none", fontSize: 22, lineHeight: 1 }}
>
×
</button>
</div>
{/* Meta row */}
<div className="px-5 py-3" style={{ borderBottom: "1px solid var(--border-secondary)", backgroundColor: "var(--bg-primary)", flexShrink: 0 }}>
<div className="flex flex-wrap items-center gap-y-1 text-xs" style={{ gap: "0 0" }}>
<span style={{ paddingRight: 12 }}>
<span style={{ color: "var(--text-muted)" }}>From: </span>
<span style={{ color: "var(--text-primary)" }}>{fromLabel}</span>
{isInbound && entry.from_addr && customer && (
<span style={{ color: "var(--text-muted)", marginLeft: 4 }}>({entry.from_addr})</span>
)}
</span>
{toLabel && (
<>
<span style={{ color: "var(--border-primary)", paddingRight: 12 }}>|</span>
<span style={{ paddingRight: 12 }}>
<span style={{ color: "var(--text-muted)" }}>To: </span>
<span style={{ color: "var(--text-primary)" }}>{toLabel}</span>
</span>
</>
)}
{customer && (
<>
<span style={{ color: "var(--border-primary)", paddingRight: 12 }}>|</span>
<span style={{ paddingRight: 12 }}>
<span style={{ color: "var(--text-muted)" }}>Customer: </span>
<span style={{ color: "var(--accent)" }}>{customer.name}</span>
{customer.organization && (
<span style={{ color: "var(--text-muted)" }}> · {customer.organization}</span>
)}
</span>
</>
)}
<span style={{ color: "var(--border-primary)", paddingRight: 12 }}>|</span>
<span
className="px-2 py-0.5 rounded-full capitalize"
style={{
backgroundColor: isInbound ? "var(--danger-bg)" : "#dcfce7",
color: isInbound ? "var(--danger-text)" : "#166534",
}}
>
{entry.direction}
</span>
</div>
</div>
{/* Body — dark iframe with dark/light toggle */}
<div className="flex-1 overflow-hidden" style={{ position: "relative" }}>
{/* Dark / Light toggle */}
<div style={{ position: "absolute", top: 8, right: 20, zIndex: 10 }}>
<button
type="button"
onClick={() => setBodyDark((v) => !v)}
title={bodyDark ? "Switch to light mode" : "Switch to dark mode"}
style={{
padding: "4px 10px", fontSize: 11, borderRadius: 6, cursor: "pointer",
border: "1px solid rgba(128,128,128,0.4)",
backgroundColor: bodyDark ? "rgba(0,0,0,0.55)" : "rgba(255,255,255,0.7)",
color: bodyDark ? "#e0e0e0" : "#333",
backdropFilter: "blur(4px)",
transition: "all 0.15s",
}}
>
{bodyDark ? "☀ Light" : "🌙 Dark"}
</button>
</div>
<iframe
ref={iframeRef}
sandbox="allow-same-origin allow-popups"
referrerPolicy="no-referrer"
style={{ width: "100%", height: "100%", border: "none", display: "block" }}
srcDoc={`<!DOCTYPE html><html><head>
<meta charset="utf-8">
<style>
html, body { margin: 0; padding: 0; background: ${bodyDark ? "#1a1a1a" : "#ffffff"}; color: ${bodyDark ? "#e0e0e0" : "#1a1a1a"}; }
body { padding: 16px 20px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; line-height: 1.6; }
img { max-width: 100%; height: auto; display: inline-block; }
pre { white-space: pre-wrap; word-break: break-word; }
a { color: ${bodyDark ? "#60a5fa" : "#1d4ed8"}; }
blockquote { border-left: 3px solid ${bodyDark ? "#404040" : "#d1d5db"}; margin: 8px 0; padding-left: 12px; color: ${bodyDark ? "#9ca3af" : "#6b7280"}; }
table { color: ${bodyDark ? "#e0e0e0" : "#1a1a1a"}; }
* { box-sizing: border-box; }
</style>
</head><body>${iframeDoc}</body></html>`}
/>
</div>
{/* Inline images */}
{inlineImages.length > 0 && (
<div
className="px-5 py-3 flex flex-wrap gap-2"
style={{ borderTop: "1px solid var(--border-secondary)", backgroundColor: "var(--bg-primary)", flexShrink: 0 }}
>
<span className="text-xs font-medium w-full mb-1" style={{ color: "var(--text-secondary)" }}>
Inline Images ({inlineImages.length})
</span>
{inlineImages.map((img, i) => (
<div
key={i}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs"
style={{ backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)", color: "var(--text-primary)" }}
>
<span>🖼</span>
<span className="truncate" style={{ maxWidth: 160 }}>{img.filename}</span>
{canSave && (
<button
type="button"
onClick={() => setSaveItem(img)}
className="cursor-pointer hover:opacity-80 text-xs px-2 py-0.5 rounded"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", border: "none", marginLeft: 4 }}
>
Save
</button>
)}
</div>
))}
</div>
)}
{/* Attachments */}
{attachments.length > 0 && (
<div
className="px-5 py-3 flex flex-wrap gap-2"
style={{ borderTop: "1px solid var(--border-secondary)", backgroundColor: "var(--bg-primary)", flexShrink: 0 }}
>
<span className="text-xs font-medium w-full mb-1" style={{ color: "var(--text-secondary)" }}>
Attachments ({attachments.length})
</span>
{attachments.map((a, i) => {
const name = a.filename || a.name || "file";
const ct = a.content_type || "";
const kb = a.size ? ` · ${Math.ceil(a.size / 1024)} KB` : "";
const icon = ct.startsWith("image/") ? "🖼️" : ct === "application/pdf" ? "📑" : ct.startsWith("video/") ? "🎬" : "📎";
return (
<div
key={i}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs"
style={{ backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)", color: "var(--text-primary)" }}
>
<span>{icon}</span>
<span className="truncate" style={{ maxWidth: 200 }}>{name}{kb}</span>
{canSave && (
<button
type="button"
onClick={() => setSaveItem({ type: "attachment", filename: name, mime_type: ct, attachmentIndex: i })}
className="cursor-pointer hover:opacity-80 text-xs px-2 py-0.5 rounded"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", border: "none", marginLeft: 4 }}
>
Save
</button>
)}
</div>
);
})}
</div>
)}
{/* Unknown sender banner */}
{isInbound && !customer && !addedCustomer && entry.from_addr && (
<div
className="px-5 py-3 flex items-center gap-2 text-xs"
style={{ borderTop: "1px solid var(--border-secondary)", backgroundColor: "var(--bg-primary)", flexShrink: 0 }}
>
<span style={{ color: "var(--text-muted)" }}>
<strong style={{ color: "var(--text-secondary)" }}>{entry.from_addr}</strong> is not in your Customer&apos;s list.{" "}
<button
type="button"
onClick={() => setShowAddCustomer(true)}
className="cursor-pointer hover:underline"
style={{ background: "none", border: "none", padding: 0, color: "var(--accent)", fontWeight: 500, fontSize: "inherit" }}
>
Click here to add them.
</button>
</span>
</div>
)}
{isInbound && addedCustomer && (
<div
className="px-5 py-3 flex items-center gap-2 text-xs"
style={{ borderTop: "1px solid var(--border-secondary)", backgroundColor: "var(--bg-primary)", flexShrink: 0 }}
>
<span style={{ color: "var(--success-text, #16a34a)" }}>
<strong>{addedCustomer.name}</strong> has been added as a customer.
</span>
</div>
)}
{/* Footer */}
<div
className="flex items-center justify-end gap-3 px-5 py-4"
style={{ borderTop: "1px solid var(--border-primary)", flexShrink: 0 }}
>
<button
type="button"
onClick={onClose}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card)" }}
>
Close
</button>
{onReply && (
<button
type="button"
onClick={handleReply}
className="px-4 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card)" }}
>
Reply
</button>
)}
</div>
</div>
</div>
{/* Save sub-modal */}
{saveItem && (
<SaveModal
item={saveItem}
commId={entry.id}
onClose={() => setSaveItem(null)}
/>
)}
{/* Add Customer sub-modal */}
{showAddCustomer && entry?.from_addr && (
<AddCustomerModal
email={entry.from_addr}
onClose={() => setShowAddCustomer(false)}
onCreated={(newCustomer) => {
setAddedCustomer(newCustomer);
setShowAddCustomer(false);
if (onCustomerAdded) onCustomerAdded(newCustomer);
}}
/>
)}
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,579 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import api from "../../api/client";
import { useAuth } from "../../auth/AuthContext";
const CONTACT_TYPES = ["email", "phone", "whatsapp", "other"];
const LANGUAGES = [
{ value: "el", label: "Greek" },
{ value: "en", label: "English" },
{ value: "de", label: "German" },
{ value: "fr", label: "French" },
{ value: "it", label: "Italian" },
];
const TITLES = ["", "Fr.", "Rev.", "Archim.", "Bp.", "Abp.", "Met.", "Mr.", "Mrs.", "Ms.", "Dr.", "Prof."];
const PRESET_TAGS = ["church", "monastery", "municipality", "school", "repeat-customer", "vip", "pending", "inactive"];
const CONTACT_TYPE_ICONS = {
email: "📧",
phone: "📞",
whatsapp: "💬",
other: "🔗",
};
const inputClass = "w-full px-3 py-2 text-sm rounded-md border";
const inputStyle = {
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-primary)",
color: "var(--text-primary)",
};
const labelStyle = {
display: "block",
marginBottom: 4,
fontSize: 12,
color: "var(--text-secondary)",
fontWeight: 500,
};
function Field({ label, children, style }) {
return (
<div style={style}>
<label style={labelStyle}>{label}</label>
{children}
</div>
);
}
function SectionCard({ title, children }) {
return (
<div
className="rounded-lg border p-5 mb-4"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-sm font-semibold mb-4" style={{ color: "var(--text-heading)" }}>{title}</h2>
{children}
</div>
);
}
const emptyContact = () => ({ type: "email", label: "", value: "", primary: false });
export default function CustomerForm() {
const { id } = useParams();
const isEdit = Boolean(id);
const navigate = useNavigate();
const { user, hasPermission } = useAuth();
const canEdit = hasPermission("crm", "edit");
const [form, setForm] = useState({
title: "",
name: "",
surname: "",
organization: "",
language: "el",
tags: [],
folder_id: "",
location: { city: "", country: "", region: "" },
contacts: [],
notes: [],
});
const [loading, setLoading] = useState(isEdit);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [tagInput, setTagInput] = useState("");
const [newNoteText, setNewNoteText] = useState("");
const [editingNoteIdx, setEditingNoteIdx] = useState(null);
const [editingNoteText, setEditingNoteText] = useState("");
useEffect(() => {
if (!isEdit) return;
api.get(`/crm/customers/${id}`)
.then((data) => {
setForm({
title: data.title || "",
name: data.name || "",
surname: data.surname || "",
organization: data.organization || "",
language: data.language || "el",
tags: data.tags || [],
folder_id: data.folder_id || "",
location: {
city: data.location?.city || "",
country: data.location?.country || "",
region: data.location?.region || "",
},
contacts: data.contacts || [],
notes: data.notes || [],
});
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [id, isEdit]);
const set = (field, value) => setForm((f) => ({ ...f, [field]: value }));
const setLoc = (field, value) => setForm((f) => ({ ...f, location: { ...f.location, [field]: value } }));
// Tags
const addTag = (raw) => {
const tag = raw.trim();
if (tag && !form.tags.includes(tag)) {
set("tags", [...form.tags, tag]);
}
setTagInput("");
};
const removeTag = (tag) => set("tags", form.tags.filter((t) => t !== tag));
// Contacts
const addContact = () => set("contacts", [...form.contacts, emptyContact()]);
const removeContact = (i) => set("contacts", form.contacts.filter((_, idx) => idx !== i));
const setContact = (i, field, value) => {
const updated = form.contacts.map((c, idx) => idx === i ? { ...c, [field]: value } : c);
set("contacts", updated);
};
const setPrimaryContact = (i) => {
const type = form.contacts[i].type;
const updated = form.contacts.map((c, idx) => ({
...c,
primary: c.type === type ? idx === i : c.primary,
}));
set("contacts", updated);
};
// Notes
const addNote = () => {
if (!newNoteText.trim()) return;
const note = {
text: newNoteText.trim(),
by: user?.name || "unknown",
at: new Date().toISOString(),
};
set("notes", [...form.notes, note]);
setNewNoteText("");
};
const removeNote = (i) => {
set("notes", form.notes.filter((_, idx) => idx !== i));
if (editingNoteIdx === i) setEditingNoteIdx(null);
};
const startEditNote = (i) => {
setEditingNoteIdx(i);
setEditingNoteText(form.notes[i].text);
};
const saveEditNote = (i) => {
if (!editingNoteText.trim()) return;
const updated = form.notes.map((n, idx) =>
idx === i ? { ...n, text: editingNoteText.trim(), at: new Date().toISOString() } : n
);
set("notes", updated);
setEditingNoteIdx(null);
};
const buildPayload = () => ({
title: form.title.trim() || null,
name: form.name.trim(),
surname: form.surname.trim() || null,
organization: form.organization.trim() || null,
language: form.language,
tags: form.tags,
...(!isEdit && { folder_id: form.folder_id.trim().toLowerCase() }),
location: {
city: form.location.city.trim(),
country: form.location.country.trim(),
region: form.location.region.trim(),
},
contacts: form.contacts.filter((c) => c.value.trim()),
notes: form.notes,
});
const handleSave = async () => {
if (!form.name.trim()) { setError("Customer name is required."); return; }
if (!isEdit && !form.folder_id.trim()) { setError("Internal Folder ID is required."); return; }
if (!isEdit && !/^[a-z0-9][a-z0-9\-]*[a-z0-9]$/.test(form.folder_id.trim().toLowerCase())) {
setError("Internal Folder ID must contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen.");
return;
}
setSaving(true);
setError("");
try {
if (isEdit) {
await api.put(`/crm/customers/${id}`, buildPayload());
navigate(`/crm/customers/${id}`);
} else {
const res = await api.post("/crm/customers", buildPayload());
navigate(`/crm/customers/${res.id}`);
}
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
setSaving(true);
try {
await api.delete(`/crm/customers/${id}`);
navigate("/crm/customers");
} catch (err) {
setError(err.message);
setSaving(false);
}
};
if (loading) {
return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
}
return (
<div style={{ maxWidth: 800, margin: "0 auto" }}>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
{isEdit ? "Edit Customer" : "New Customer"}
</h1>
<div className="flex gap-2">
<button
onClick={() => navigate(isEdit ? `/crm/customers/${id}` : "/crm/customers")}
className="px-4 py-2 text-sm rounded-md border cursor-pointer"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
>
Cancel
</button>
{canEdit && (
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 text-sm rounded-md hover:opacity-90 transition-colors cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", opacity: saving ? 0.7 : 1 }}
>
{saving ? "Saving..." : "Save"}
</button>
)}
</div>
</div>
{error && (
<div
className="text-sm rounded-md p-3 mb-4 border"
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
>
{error}
</div>
)}
{/* Basic Info */}
<SectionCard title="Basic Info">
{/* Row 1: Title, Name, Surname */}
<div style={{ display: "grid", gridTemplateColumns: "140px 1fr 1fr", gap: 16, marginBottom: 16 }}>
<Field label="Title">
<select className={inputClass} style={inputStyle} value={form.title}
onChange={(e) => set("title", e.target.value)}>
{TITLES.map((t) => <option key={t} value={t}>{t || "—"}</option>)}
</select>
</Field>
<Field label="Name *">
<input className={inputClass} style={inputStyle} value={form.name}
onChange={(e) => set("name", e.target.value)} placeholder="First name" />
</Field>
<Field label="Surname">
<input className={inputClass} style={inputStyle} value={form.surname}
onChange={(e) => set("surname", e.target.value)} placeholder="Last name" />
</Field>
</div>
{/* Row 2: Organization, Language, Folder ID */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16, marginBottom: 16 }}>
<Field label="Organization">
<input className={inputClass} style={inputStyle} value={form.organization}
onChange={(e) => set("organization", e.target.value)} placeholder="Church, organization, etc." />
</Field>
<Field label="Language">
<select className={inputClass} style={inputStyle} value={form.language}
onChange={(e) => set("language", e.target.value)}>
{LANGUAGES.map((l) => <option key={l.value} value={l.value}>{l.label}</option>)}
</select>
</Field>
{!isEdit ? (
<Field label="Folder ID *">
<input
className={inputClass}
style={inputStyle}
value={form.folder_id}
onChange={(e) => set("folder_id", e.target.value.toLowerCase().replace(/[^a-z0-9\-]/g, ""))}
placeholder="e.g. saint-john-corfu"
/>
</Field>
) : (
<div>
<div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>Folder ID</div>
<div style={{ fontSize: 13, color: "var(--text-primary)", padding: "6px 0" }}>{form.folder_id || "—"}</div>
</div>
)}
</div>
{!isEdit && (
<p className="text-xs" style={{ color: "var(--text-muted)", marginTop: -8, marginBottom: 16 }}>
Lowercase letters, numbers and hyphens only. This becomes the Nextcloud folder name and cannot be changed later.
</p>
)}
{/* Row 3: Tags */}
<div>
<label style={labelStyle}>Tags</label>
<div className="flex flex-wrap gap-1.5 mb-2">
{form.tags.map((tag) => (
<span
key={tag}
className="flex items-center gap-1 px-2 py-0.5 text-xs rounded-full cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
onClick={() => removeTag(tag)}
title="Click to remove"
>
{tag} ×
</span>
))}
</div>
{/* Preset quick-add tags */}
<div className="flex flex-wrap gap-1.5 mb-2">
{PRESET_TAGS.filter((t) => !form.tags.includes(t)).map((t) => (
<button
key={t}
type="button"
onClick={() => addTag(t)}
className="px-2 py-0.5 text-xs rounded-full border cursor-pointer hover:opacity-80"
style={{ borderColor: "var(--border-primary)", color: "var(--text-muted)" }}
>
+ {t}
</button>
))}
</div>
<input
className={inputClass}
style={inputStyle}
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addTag(tagInput);
}
}}
onBlur={() => tagInput.trim() && addTag(tagInput)}
placeholder="Type a custom tag and press Enter or comma..."
/>
</div>
</SectionCard>
{/* Location */}
<SectionCard title="Location">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16 }}>
<Field label="City">
<input className={inputClass} style={inputStyle} value={form.location.city}
onChange={(e) => setLoc("city", e.target.value)} placeholder="City" />
</Field>
<Field label="Country">
<input className={inputClass} style={inputStyle} value={form.location.country}
onChange={(e) => setLoc("country", e.target.value)} placeholder="Country" />
</Field>
<Field label="Region">
<input className={inputClass} style={inputStyle} value={form.location.region}
onChange={(e) => setLoc("region", e.target.value)} placeholder="Region" />
</Field>
</div>
</SectionCard>
{/* Contacts */}
<SectionCard title="Contacts">
{form.contacts.map((c, i) => (
<div
key={i}
className="flex gap-2 mb-2 items-center"
>
<span className="text-base w-6 text-center flex-shrink-0">{CONTACT_TYPE_ICONS[c.type] || "🔗"}</span>
<select
className="px-2 py-2 text-sm rounded-md border w-32 flex-shrink-0"
style={inputStyle}
value={c.type}
onChange={(e) => setContact(i, "type", e.target.value)}
>
{CONTACT_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
<input
className="px-2 py-2 text-sm rounded-md border w-28 flex-shrink-0"
style={inputStyle}
value={c.label}
onChange={(e) => setContact(i, "label", e.target.value)}
placeholder="label (e.g. work)"
/>
<input
className={inputClass + " flex-1"}
style={inputStyle}
value={c.value}
onChange={(e) => setContact(i, "value", e.target.value)}
placeholder="value"
/>
<label className="flex items-center gap-1 text-xs flex-shrink-0 cursor-pointer" style={{ color: "var(--text-muted)" }}>
<input
type="radio"
name={`primary-${c.type}`}
checked={!!c.primary}
onChange={() => setPrimaryContact(i)}
className="cursor-pointer"
/>
Primary
</label>
<button
type="button"
onClick={() => removeContact(i)}
className="text-xs cursor-pointer hover:opacity-70 flex-shrink-0"
style={{ color: "var(--danger)" }}
>
×
</button>
</div>
))}
<button
type="button"
onClick={addContact}
className="mt-2 px-3 py-1.5 text-xs rounded-md border cursor-pointer hover:opacity-80"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
>
+ Add Contact
</button>
</SectionCard>
{/* Notes */}
<SectionCard title="Notes">
{form.notes.length > 0 && (
<div className="mb-4 space-y-2">
{form.notes.map((note, i) => (
<div
key={i}
className="px-3 py-2 rounded-md text-sm"
style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-primary)" }}
>
{editingNoteIdx === i ? (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<textarea
className={inputClass}
style={{ ...inputStyle, resize: "vertical", minHeight: 56 }}
value={editingNoteText}
onChange={(e) => setEditingNoteText(e.target.value)}
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) saveEditNote(i);
if (e.key === "Escape") setEditingNoteIdx(null);
}}
/>
<div style={{ display: "flex", gap: 6 }}>
<button
type="button"
onClick={() => saveEditNote(i)}
disabled={!editingNoteText.trim()}
className="px-2 py-1 text-xs rounded-md cursor-pointer hover:opacity-90"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", opacity: editingNoteText.trim() ? 1 : 0.5 }}
>Save</button>
<button
type="button"
onClick={() => setEditingNoteIdx(null)}
className="px-2 py-1 text-xs rounded-md cursor-pointer hover:opacity-80"
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)" }}
>Cancel</button>
</div>
</div>
) : (
<>
<p>{note.text}</p>
<div className="flex items-center justify-between mt-1">
<p className="text-xs" style={{ color: "var(--text-muted)" }}>
{note.by} · {note.at ? new Date(note.at).toLocaleDateString() : ""}
</p>
<div style={{ display: "flex", gap: 8 }}>
<button
type="button"
onClick={() => startEditNote(i)}
className="text-xs cursor-pointer hover:opacity-70"
style={{ color: "var(--accent)", background: "none", border: "none", padding: 0 }}
>Edit</button>
<button
type="button"
onClick={() => removeNote(i)}
className="text-xs cursor-pointer hover:opacity-70"
style={{ color: "var(--danger)", background: "none", border: "none", padding: 0 }}
>Remove</button>
</div>
</div>
</>
)}
</div>
))}
</div>
)}
<div className="flex gap-2">
<textarea
className={inputClass + " flex-1"}
style={{ ...inputStyle, resize: "vertical", minHeight: 64 }}
value={newNoteText}
onChange={(e) => setNewNoteText(e.target.value)}
placeholder="Add a note..."
/>
<button
type="button"
onClick={addNote}
disabled={!newNoteText.trim()}
className="px-3 py-2 text-sm rounded-md cursor-pointer hover:opacity-80 self-start"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", opacity: newNoteText.trim() ? 1 : 0.5 }}
>
Add
</button>
</div>
</SectionCard>
{/* Delete */}
{isEdit && canEdit && (
<div
className="rounded-lg border p-5"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-sm font-semibold mb-2" style={{ color: "var(--danger)" }}>Danger Zone</h2>
{!showDeleteConfirm ? (
<button
type="button"
onClick={() => setShowDeleteConfirm(true)}
className="px-4 py-2 text-sm rounded-md border cursor-pointer hover:opacity-80"
style={{ borderColor: "var(--danger)", color: "var(--danger)" }}
>
Delete Customer
</button>
) : (
<div>
<p className="text-sm mb-3" style={{ color: "var(--text-secondary)" }}>
Are you sure? This cannot be undone.
</p>
<div className="flex gap-2">
<button
type="button"
onClick={handleDelete}
disabled={saving}
className="px-4 py-2 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ backgroundColor: "var(--danger)", color: "#fff", opacity: saving ? 0.7 : 1 }}
>
{saving ? "Deleting..." : "Yes, Delete"}
</button>
<button
type="button"
onClick={() => setShowDeleteConfirm(false)}
className="px-4 py-2 text-sm rounded-md border cursor-pointer"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
>
Cancel
</button>
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,174 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import api from "../../api/client";
import { useAuth } from "../../auth/AuthContext";
const inputStyle = {
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-primary)",
color: "var(--text-primary)",
};
function primaryContact(customer, type) {
const contacts = customer.contacts || [];
const primary = contacts.find((c) => c.type === type && c.primary);
return primary?.value || contacts.find((c) => c.type === type)?.value || null;
}
export default function CustomerList() {
const [customers, setCustomers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [search, setSearch] = useState("");
const [tagFilter, setTagFilter] = useState("");
const [hoveredRow, setHoveredRow] = useState(null);
const navigate = useNavigate();
const { hasPermission } = useAuth();
const canEdit = hasPermission("crm", "edit");
const fetchCustomers = async () => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams();
if (search) params.set("search", search);
if (tagFilter) params.set("tag", tagFilter);
const qs = params.toString();
const data = await api.get(`/crm/customers${qs ? `?${qs}` : ""}`);
setCustomers(data.customers);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchCustomers();
}, [search, tagFilter]);
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Customers</h1>
{canEdit && (
<button
onClick={() => navigate("/crm/customers/new")}
className="px-4 py-2 text-sm rounded-md hover:opacity-90 transition-colors cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
New Customer
</button>
)}
</div>
<div className="flex gap-3 mb-4">
<input
type="text"
placeholder="Search by name, location, email, phone, tags..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 px-3 py-2 text-sm rounded-md border"
style={inputStyle}
/>
<input
type="text"
placeholder="Filter by tag..."
value={tagFilter}
onChange={(e) => setTagFilter(e.target.value)}
className="w-40 px-3 py-2 text-sm rounded-md border"
style={inputStyle}
/>
</div>
{error && (
<div
className="text-sm rounded-md p-3 mb-4 border"
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
>
{error}
</div>
)}
{loading ? (
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>
) : customers.length === 0 ? (
<div
className="rounded-lg p-8 text-center text-sm border"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}
>
No customers found.
</div>
) : (
<div
className="rounded-lg overflow-hidden border"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Name</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Organization</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Location</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Email</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Phone</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Tags</th>
</tr>
</thead>
<tbody>
{customers.map((c, index) => {
const loc = c.location || {};
const locationStr = [loc.city, loc.country].filter(Boolean).join(", ");
return (
<tr
key={c.id}
onClick={() => navigate(`/crm/customers/${c.id}`)}
className="cursor-pointer"
style={{
borderBottom: index < customers.length - 1 ? "1px solid var(--border-secondary)" : "none",
backgroundColor: hoveredRow === c.id ? "var(--bg-card-hover)" : "transparent",
}}
onMouseEnter={() => setHoveredRow(c.id)}
onMouseLeave={() => setHoveredRow(null)}
>
<td className="px-4 py-3 font-medium" style={{ color: "var(--text-heading)" }}>
{[c.title, c.name, c.surname].filter(Boolean).join(" ")}
</td>
<td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>{c.organization || "—"}</td>
<td className="px-4 py-3" style={{ color: "var(--text-muted)" }}>{locationStr || "—"}</td>
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
{primaryContact(c, "email") || "—"}
</td>
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
{primaryContact(c, "phone") || "—"}
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{(c.tags || []).slice(0, 3).map((tag) => (
<span
key={tag}
className="px-2 py-0.5 text-xs rounded-full"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
{tag}
</span>
))}
{(c.tags || []).length > 3 && (
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
+{c.tags.length - 3}
</span>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,3 @@
export { default as CustomerList } from "./CustomerList";
export { default as CustomerForm } from "./CustomerForm";
export { default as CustomerDetail } from "./CustomerDetail";

View File

@@ -0,0 +1,466 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { Link } from "react-router-dom";
import api from "../../api/client";
import MailViewModal from "../components/MailViewModal";
import ComposeEmailModal from "../components/ComposeEmailModal";
import { CommTypeIconBadge, CommDirectionIcon } from "../components/CommIcons";
// Display labels for transport types - always lowercase
const TYPE_LABELS = {
email: "e-mail",
whatsapp: "whatsapp",
call: "phonecall",
sms: "sms",
note: "note",
in_person: "in person",
};
const COMMS_TYPES = ["email", "whatsapp", "call", "sms", "note", "in_person"];
const DIRECTIONS = ["inbound", "outbound", "internal"];
const COMM_DATE_FMT = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", year: "numeric" });
const COMM_TIME_FMT = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
function formatCommDateTime(value) {
if (!value) return "";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return "";
return `${COMM_DATE_FMT.format(d)} · ${COMM_TIME_FMT.format(d).toLowerCase()}`;
}
const selectStyle = {
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-primary)",
color: "var(--text-primary)",
fontSize: 13,
padding: "6px 10px",
borderRadius: 6,
border: "1px solid",
cursor: "pointer",
};
// Customer search mini modal (replaces the giant dropdown)
function CustomerPickerModal({ open, onClose, customers, value, onChange }) {
const [q, setQ] = useState("");
const inputRef = useRef(null);
useEffect(() => {
if (open) { setQ(""); setTimeout(() => inputRef.current?.focus(), 60); }
}, [open]);
// ESC to close
useEffect(() => {
if (!open) return;
const handler = (e) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
if (!open) return null;
const lower = q.trim().toLowerCase();
const filtered = customers.filter((c) =>
!lower ||
(c.name || "").toLowerCase().includes(lower) ||
(c.surname || "").toLowerCase().includes(lower) ||
(c.organization || "").toLowerCase().includes(lower) ||
(c.contacts || []).some((ct) => (ct.value || "").toLowerCase().includes(lower))
);
return (
<div
style={{ position: "fixed", inset: 0, zIndex: 500, backgroundColor: "rgba(0,0,0,0.45)", display: "flex", alignItems: "center", justifyContent: "center" }}
onClick={onClose}
>
<div
style={{ backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)", borderRadius: 10, width: 380, maxHeight: 460, display: "flex", flexDirection: "column", boxShadow: "0 16px 48px rgba(0,0,0,0.35)", overflow: "hidden" }}
onClick={(e) => e.stopPropagation()}
>
<div style={{ padding: "12px 14px", borderBottom: "1px solid var(--border-secondary)" }}>
<input
ref={inputRef}
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search customer..."
style={{ width: "100%", padding: "7px 10px", fontSize: 13, borderRadius: 6, border: "1px solid var(--border-primary)", backgroundColor: "var(--bg-input)", color: "var(--text-primary)", outline: "none" }}
/>
</div>
<div style={{ overflowY: "auto", flex: 1 }}>
{/* All customers option */}
<div
onClick={() => { onChange(""); onClose(); }}
style={{ padding: "9px 14px", fontSize: 13, cursor: "pointer", color: value === "" ? "var(--accent)" : "var(--text-primary)", backgroundColor: value === "" ? "color-mix(in srgb, var(--accent) 8%, var(--bg-card))" : "transparent", fontWeight: value === "" ? 600 : 400 }}
onMouseEnter={(e) => { if (value !== "") e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"; }}
onMouseLeave={(e) => { if (value !== "") e.currentTarget.style.backgroundColor = "transparent"; }}
>
All customers
</div>
{filtered.map((c) => (
<div
key={c.id}
onClick={() => { onChange(c.id); onClose(); }}
style={{ padding: "9px 14px", fontSize: 13, cursor: "pointer", color: value === c.id ? "var(--accent)" : "var(--text-primary)", backgroundColor: value === c.id ? "color-mix(in srgb, var(--accent) 8%, var(--bg-card))" : "transparent" }}
onMouseEnter={(e) => { if (value !== c.id) e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"; }}
onMouseLeave={(e) => { if (value !== c.id) e.currentTarget.style.backgroundColor = value === c.id ? "color-mix(in srgb, var(--accent) 8%, var(--bg-card))" : "transparent"; }}
>
<div style={{ fontWeight: 500 }}>{c.name}{c.surname ? ` ${c.surname}` : ""}</div>
{c.organization && <div style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 1 }}>{c.organization}</div>}
</div>
))}
{filtered.length === 0 && q && (
<div style={{ padding: "16px 14px", textAlign: "center", fontSize: 13, color: "var(--text-muted)" }}>No customers found</div>
)}
</div>
</div>
</div>
);
}
export default function CommsPage() {
const [entries, setEntries] = useState([]);
const [customers, setCustomers] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [typeFilter, setTypeFilter] = useState("");
const [dirFilter, setDirFilter] = useState("");
const [custFilter, setCustFilter] = useState("");
const [expandedId, setExpandedId] = useState(null); // only 1 at a time
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState(null);
const [custPickerOpen, setCustPickerOpen] = useState(false);
// Modals
const [viewEntry, setViewEntry] = useState(null);
const [composeOpen, setComposeOpen] = useState(false);
const [composeTo, setComposeTo] = useState("");
const [composeFromAccount, setComposeFromAccount] = useState("");
const loadAll = useCallback(async () => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams({ limit: 200 });
if (typeFilter) params.set("type", typeFilter);
if (dirFilter) params.set("direction", dirFilter);
const [commsData, custsData] = await Promise.all([
api.get(`/crm/comms/all?${params}`),
api.get("/crm/customers"),
]);
setEntries(commsData.entries || []);
const map = {};
for (const c of custsData.customers || []) map[c.id] = c;
setCustomers(map);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [typeFilter, dirFilter]);
useEffect(() => { loadAll(); }, [loadAll]);
const syncEmails = async () => {
setSyncing(true);
setSyncResult(null);
try {
const data = await api.post("/crm/comms/email/sync", {});
setSyncResult(data);
await loadAll();
} catch (err) {
setSyncResult({ error: err.message });
} finally {
setSyncing(false);
}
};
// Toggle expand — only one at a time
const toggleExpand = (id) =>
setExpandedId((prev) => (prev === id ? null : id));
const openReply = (entry) => {
const toAddr = entry.direction === "inbound"
? (entry.from_addr || "")
: (Array.isArray(entry.to_addrs) ? entry.to_addrs[0] : "");
setViewEntry(null);
setComposeTo(toAddr);
setComposeOpen(true);
};
const filtered = custFilter
? entries.filter((e) => e.customer_id === custFilter)
: entries;
const sortedFiltered = [...filtered].sort((a, b) => {
const ta = Date.parse(a?.occurred_at || a?.created_at || "") || 0;
const tb = Date.parse(b?.occurred_at || b?.created_at || "") || 0;
if (tb !== ta) return tb - ta;
return String(b?.id || "").localeCompare(String(a?.id || ""));
});
const customerOptions = Object.values(customers).sort((a, b) =>
(a.name || "").localeCompare(b.name || "")
);
const selectedCustomerLabel = custFilter && customers[custFilter]
? customers[custFilter].name + (customers[custFilter].organization ? `${customers[custFilter].organization}` : "")
: "All customers";
return (
<div>
{/* Header */}
<div className="flex items-center justify-between mb-5">
<div>
<h1 className="text-xl font-bold" style={{ color: "var(--text-heading)" }}>Activity Log</h1>
<p className="text-sm mt-0.5" style={{ color: "var(--text-muted)" }}>
All customer communications across all channels
</p>
</div>
<div className="flex items-center gap-2">
{syncResult && (
<span className="text-xs" style={{ color: syncResult.error ? "var(--danger-text)" : "var(--success-text)" }}>
{syncResult.error
? syncResult.error
: `${syncResult.new_count} new email${syncResult.new_count !== 1 ? "s" : ""}`}
</span>
)}
<button
onClick={syncEmails}
disabled={syncing || loading}
title="Connect to mail server and download new emails into the log"
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ border: "1px solid", borderColor: "var(--border-primary)", color: "var(--text-secondary)", opacity: (syncing || loading) ? 0.6 : 1 }}
>
{syncing ? "Syncing..." : "Sync Emails"}
</button>
<button
onClick={loadAll}
disabled={loading}
title="Reload from local database"
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ border: "1px solid", borderColor: "var(--border-primary)", color: "var(--text-secondary)", opacity: loading ? 0.6 : 1 }}
>
Refresh
</button>
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3 mb-5">
<select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)} style={selectStyle}>
<option value="">All types</option>
{COMMS_TYPES.map((t) => <option key={t} value={t}>{TYPE_LABELS[t] || t}</option>)}
</select>
<select value={dirFilter} onChange={(e) => setDirFilter(e.target.value)} style={selectStyle}>
<option value="">All directions</option>
{DIRECTIONS.map((d) => <option key={d} value={d}>{d}</option>)}
</select>
{/* Customer picker button */}
<button
type="button"
onClick={() => setCustPickerOpen(true)}
style={{
...selectStyle,
minWidth: 180,
textAlign: "left",
color: custFilter ? "var(--accent)" : "var(--text-primary)",
fontWeight: custFilter ? 600 : 400,
}}
>
{selectedCustomerLabel}
</button>
{(typeFilter || dirFilter || custFilter) && (
<button
onClick={() => { setTypeFilter(""); setDirFilter(""); setCustFilter(""); }}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ color: "var(--danger-text)", backgroundColor: "var(--danger-bg)", borderRadius: 6 }}
>
Clear filters
</button>
)}
</div>
{error && (
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
)}
{loading ? (
<div className="text-center py-12" style={{ color: "var(--text-muted)" }}>Loading...</div>
) : sortedFiltered.length === 0 ? (
<div className="rounded-lg p-10 text-center text-sm border" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}>
No communications found.
</div>
) : (
<div>
<div className="text-xs mb-3" style={{ color: "var(--text-muted)" }}>
{sortedFiltered.length} entr{sortedFiltered.length !== 1 ? "ies" : "y"}
</div>
<div style={{ position: "relative" }}>
{/* Connector line */}
<div style={{
position: "absolute", left: 19, top: 12, bottom: 12,
width: 2, backgroundColor: "var(--border-secondary)", zIndex: 0,
}} />
<div className="space-y-2">
{sortedFiltered.map((entry) => {
const customer = customers[entry.customer_id];
const isExpanded = expandedId === entry.id;
const isEmail = entry.type === "email";
return (
<div key={entry.id} style={{ position: "relative", paddingLeft: 44 }}>
{/* Type icon marker */}
<div style={{ position: "absolute", left: 8, top: 11, zIndex: 1 }}>
<CommTypeIconBadge type={entry.type} />
</div>
<div
className="rounded-lg border"
style={{
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
cursor: entry.body ? "pointer" : "default",
}}
onClick={() => entry.body && toggleExpand(entry.id)}
>
{/* Entry header */}
<div className="flex items-center gap-2 px-4 py-3 flex-wrap">
<CommDirectionIcon direction={entry.direction} />
{customer ? (
<Link
to={`/crm/customers/${entry.customer_id}`}
className="text-xs font-medium hover:underline"
style={{ color: "var(--accent)" }}
onClick={(e) => e.stopPropagation()}
>
{customer.name}
{customer.organization ? ` · ${customer.organization}` : ""}
</Link>
) : (
<span className="text-xs font-mono" style={{ color: "var(--text-muted)" }}>
{entry.from_addr || entry.customer_id || "—"}
</span>
)}
{entry.subject && (
<span className="text-sm font-medium truncate" style={{ color: "var(--text-heading)", maxWidth: 280 }}>
{entry.subject}
</span>
)}
<div className="ml-auto flex items-center gap-2">
{/* Full View button (for email entries) */}
{isEmail && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); setViewEntry(entry); }}
className="text-xs px-2 py-0.5 rounded cursor-pointer hover:opacity-80 flex-shrink-0"
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-primary)" }}
>
Full View
</button>
)}
<span className="text-xs flex-shrink-0" style={{ color: "var(--text-muted)" }}>
{formatCommDateTime(entry.occurred_at)}
</span>
{entry.body && (
<span className="text-xs flex-shrink-0" style={{ color: "var(--text-muted)" }}>
{isExpanded ? "▲" : "▼"}
</span>
)}
</div>
</div>
{/* Body */}
{entry.body && (
<div className="pb-3" style={{ paddingLeft: 16, paddingRight: 16 }}>
<div style={{ borderTop: "1px solid var(--border-secondary)", marginLeft: 0, marginRight: 0 }} />
<p
className="text-sm mt-2"
style={{
color: "var(--text-primary)",
display: "-webkit-box",
WebkitLineClamp: isExpanded ? "unset" : 2,
WebkitBoxOrient: "vertical",
overflow: isExpanded ? "visible" : "hidden",
whiteSpace: "pre-wrap",
}}
>
{entry.body}
</p>
</div>
)}
{/* Footer: logged_by + attachments + Quick Reply */}
{(entry.logged_by || (entry.attachments?.length > 0) || (isExpanded && isEmail)) && (
<div className="px-4 pb-3 flex items-center gap-3 flex-wrap">
{entry.logged_by && (
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
by {entry.logged_by}
</span>
)}
{entry.attachments?.length > 0 && (
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
📎 {entry.attachments.length} attachment{entry.attachments.length !== 1 ? "s" : ""}
</span>
)}
{isExpanded && isEmail && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); openReply(entry); }}
className="ml-auto text-xs px-2 py-1 rounded-md cursor-pointer hover:opacity-90"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", border: "none" }}
>
Quick Reply
</button>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
)}
{/* Customer Picker Modal */}
<CustomerPickerModal
open={custPickerOpen}
onClose={() => setCustPickerOpen(false)}
customers={customerOptions}
value={custFilter}
onChange={setCustFilter}
/>
{/* Mail View Modal */}
<MailViewModal
open={!!viewEntry}
onClose={() => setViewEntry(null)}
entry={viewEntry}
customerName={viewEntry ? customers[viewEntry.customer_id]?.name : null}
onReply={(toAddr, sourceAccount) => {
setViewEntry(null);
setComposeTo(toAddr);
setComposeFromAccount(sourceAccount || "");
setComposeOpen(true);
}}
/>
{/* Compose Modal */}
<ComposeEmailModal
open={composeOpen}
onClose={() => { setComposeOpen(false); setComposeFromAccount(""); }}
defaultTo={composeTo}
defaultFromAccount={composeFromAccount}
requireFromAccount={true}
onSent={() => loadAll()}
/>
</div>
);
}

View File

@@ -0,0 +1,327 @@
import { useState, useEffect, useCallback } from "react";
import { Link } from "react-router-dom";
import api from "../../api/client";
const TYPE_COLORS = {
email: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" },
whatsapp: { bg: "#dcfce7", color: "#166534" },
call: { bg: "#fef9c3", color: "#854d0e" },
sms: { bg: "#fef3c7", color: "#92400e" },
note: { bg: "var(--bg-card-hover)", color: "var(--text-secondary)" },
in_person: { bg: "#ede9fe", color: "#5b21b6" },
};
const COMMS_TYPES = ["email", "whatsapp", "call", "sms", "note", "in_person"];
const DIRECTIONS = ["inbound", "outbound", "internal"];
function TypeBadge({ type }) {
const s = TYPE_COLORS[type] || TYPE_COLORS.note;
return (
<span
className="px-2 py-0.5 text-xs rounded-full capitalize"
style={{ backgroundColor: s.bg, color: s.color }}
>
{type}
</span>
);
}
function DirectionIcon({ direction }) {
if (direction === "inbound")
return <span title="Inbound" style={{ color: "var(--success-text)" }}></span>;
if (direction === "outbound")
return <span title="Outbound" style={{ color: "var(--accent)" }}></span>;
return <span title="Internal" style={{ color: "var(--text-muted)" }}></span>;
}
export default function InboxPage() {
const [entries, setEntries] = useState([]);
const [customers, setCustomers] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [typeFilter, setTypeFilter] = useState("");
const [dirFilter, setDirFilter] = useState("");
const [custFilter, setCustFilter] = useState("");
const [expanded, setExpanded] = useState({});
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState(null); // { new_count } | null
const loadAll = useCallback(async () => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams({ limit: 200 });
if (typeFilter) params.set("type", typeFilter);
if (dirFilter) params.set("direction", dirFilter);
const [commsData, custsData] = await Promise.all([
api.get(`/crm/comms/all?${params}`),
api.get("/crm/customers"),
]);
setEntries(commsData.entries || []);
// Build id→name map
const map = {};
for (const c of custsData.customers || []) {
map[c.id] = c;
}
setCustomers(map);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [typeFilter, dirFilter]);
useEffect(() => {
loadAll();
}, [loadAll]);
const syncEmails = async () => {
setSyncing(true);
setSyncResult(null);
try {
const data = await api.post("/crm/comms/email/sync", {});
setSyncResult(data);
await loadAll();
} catch (err) {
setSyncResult({ error: err.message });
} finally {
setSyncing(false);
}
};
const toggleExpand = (id) =>
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
// Client-side customer filter
const filtered = custFilter
? entries.filter((e) => e.customer_id === custFilter)
: entries;
const customerOptions = Object.values(customers).sort((a, b) =>
(a.name || "").localeCompare(b.name || "")
);
const selectStyle = {
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-primary)",
color: "var(--text-primary)",
fontSize: 13,
padding: "6px 10px",
borderRadius: 6,
border: "1px solid",
cursor: "pointer",
};
return (
<div>
{/* Header */}
<div className="flex items-center justify-between mb-5">
<div>
<h1 className="text-xl font-bold" style={{ color: "var(--text-heading)" }}>Inbox</h1>
<p className="text-sm mt-0.5" style={{ color: "var(--text-muted)" }}>
All communications across all customers
</p>
</div>
<div className="flex items-center gap-2">
{syncResult && (
<span className="text-xs" style={{ color: syncResult.error ? "var(--danger-text)" : "var(--success-text)" }}>
{syncResult.error ? syncResult.error : `${syncResult.new_count} new email${syncResult.new_count !== 1 ? "s" : ""}`}
</span>
)}
<button
onClick={syncEmails}
disabled={syncing || loading}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ border: "1px solid", borderColor: "var(--border-primary)", color: "var(--text-secondary)", opacity: (syncing || loading) ? 0.6 : 1 }}
>
{syncing ? "Syncing..." : "Sync Emails"}
</button>
<button
onClick={loadAll}
disabled={loading}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ borderColor: "var(--border-primary)", border: "1px solid", color: "var(--text-secondary)", opacity: loading ? 0.6 : 1 }}
>
Refresh
</button>
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3 mb-5">
<select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)} style={selectStyle}>
<option value="">All types</option>
{COMMS_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
<select value={dirFilter} onChange={(e) => setDirFilter(e.target.value)} style={selectStyle}>
<option value="">All directions</option>
{DIRECTIONS.map((d) => <option key={d} value={d}>{d}</option>)}
</select>
<select value={custFilter} onChange={(e) => setCustFilter(e.target.value)} style={selectStyle}>
<option value="">All customers</option>
{customerOptions.map((c) => (
<option key={c.id} value={c.id}>{c.name}{c.organization ? `${c.organization}` : ""}</option>
))}
</select>
{(typeFilter || dirFilter || custFilter) && (
<button
onClick={() => { setTypeFilter(""); setDirFilter(""); setCustFilter(""); }}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ color: "var(--danger-text)", backgroundColor: "var(--danger-bg)", borderRadius: 6 }}
>
Clear filters
</button>
)}
</div>
{error && (
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
)}
{loading ? (
<div className="text-center py-12" style={{ color: "var(--text-muted)" }}>Loading...</div>
) : filtered.length === 0 ? (
<div className="rounded-lg p-10 text-center text-sm border" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}>
No communications found.
</div>
) : (
<div>
<div className="text-xs mb-3" style={{ color: "var(--text-muted)" }}>
{filtered.length} entr{filtered.length !== 1 ? "ies" : "y"}
</div>
{/* Timeline */}
<div style={{ position: "relative" }}>
{/* Connector line */}
<div
style={{
position: "absolute",
left: 19,
top: 12,
bottom: 12,
width: 2,
backgroundColor: "var(--border-secondary)",
zIndex: 0,
}}
/>
<div className="space-y-2">
{filtered.map((entry) => {
const customer = customers[entry.customer_id];
const isExpanded = !!expanded[entry.id];
const typeStyle = TYPE_COLORS[entry.type] || TYPE_COLORS.note;
return (
<div
key={entry.id}
style={{ position: "relative", paddingLeft: 44 }}
>
{/* Dot */}
<div
style={{
position: "absolute",
left: 12,
top: 14,
width: 14,
height: 14,
borderRadius: "50%",
backgroundColor: typeStyle.bg,
border: `2px solid ${typeStyle.color}`,
zIndex: 1,
}}
/>
<div
className="rounded-lg border"
style={{
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
cursor: entry.body ? "pointer" : "default",
}}
onClick={() => entry.body && toggleExpand(entry.id)}
>
{/* Entry header */}
<div className="flex items-center gap-2 px-4 py-3 flex-wrap">
<TypeBadge type={entry.type} />
<DirectionIcon direction={entry.direction} />
<span className="text-xs" style={{ color: "var(--text-muted)" }}>{entry.direction}</span>
{customer ? (
<Link
to={`/crm/customers/${entry.customer_id}`}
className="text-xs font-medium hover:underline"
style={{ color: "var(--accent)" }}
onClick={(e) => e.stopPropagation()}
>
{customer.name}
{customer.organization ? ` · ${customer.organization}` : ""}
</Link>
) : (
<span className="text-xs font-mono" style={{ color: "var(--text-muted)" }}>{entry.customer_id}</span>
)}
<span className="ml-auto text-xs" style={{ color: "var(--text-muted)" }}>
{entry.occurred_at ? new Date(entry.occurred_at).toLocaleString() : ""}
</span>
{entry.body && (
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
{isExpanded ? "▲" : "▼"}
</span>
)}
</div>
{/* Subject / body preview */}
{(entry.subject || entry.body) && (
<div className="px-4 pb-3 border-t" style={{ borderColor: "var(--border-secondary)" }}>
{entry.subject && (
<p className="text-sm font-medium mt-2" style={{ color: "var(--text-heading)" }}>
{entry.subject}
</p>
)}
{entry.body && (
<p
className="text-sm mt-1"
style={{
color: "var(--text-primary)",
display: "-webkit-box",
WebkitLineClamp: isExpanded ? "unset" : 2,
WebkitBoxOrient: "vertical",
overflow: isExpanded ? "visible" : "hidden",
whiteSpace: "pre-wrap",
}}
>
{entry.body}
</p>
)}
</div>
)}
{/* Footer */}
{(entry.logged_by || (entry.attachments && entry.attachments.length > 0)) && (
<div className="px-4 pb-2 flex items-center gap-3">
{entry.logged_by && (
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
by {entry.logged_by}
</span>
)}
{entry.attachments && entry.attachments.length > 0 && (
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
{entry.attachments.length} attachment{entry.attachments.length !== 1 ? "s" : ""}
</span>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,838 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { Link } from "react-router-dom";
import api from "../../api/client";
import ComposeEmailModal from "../components/ComposeEmailModal";
import MailViewModal from "../components/MailViewModal";
const TABS = ["inbound", "outbound"];
const CLIENT_FILTER_TABS = ["all", "clients"];
const MAILBOX_TABS = ["sales", "support", "both"];
const READ_FILTER_TABS = ["all", "unread", "read", "important"];
const FILTER_COLORS = {
inbound: "var(--mail-filter-green)",
outbound: "var(--mail-filter-blue)",
all_messages: "var(--mail-filter-yellow)",
clients_only: "var(--mail-filter-green)",
sales: "var(--mail-filter-orange)",
support: "var(--mail-filter-red)",
mailbox_all: "var(--mail-filter-green)",
read_all: "var(--mail-filter-yellow)",
unread: "var(--mail-filter-green)",
read: "var(--mail-filter-blue)",
important: "var(--mail-filter-red)",
};
// Fixed pixel width of the identity (sender/recipient) column
const ID_COL_W = 210;
const DEFAULT_POLL_INTERVAL = 30; // seconds
function getPollInterval() {
const stored = parseInt(localStorage.getItem("mail_poll_interval"), 10);
if (!isNaN(stored) && stored >= 15 && stored <= 300) return stored;
return DEFAULT_POLL_INTERVAL;
}
// Relative time helper
function relativeTime(dateStr) {
if (!dateStr) return "";
const now = Date.now();
const then = new Date(dateStr).getTime();
const diff = now - then;
if (diff < 0) return "just now";
const secs = Math.floor(diff / 1000);
if (secs < 60) return "just now";
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 7) return `${days}d ago`;
const weeks = Math.floor(days / 7);
if (weeks < 5) return `${weeks}w ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months}mo ago`;
return `${Math.floor(months / 12)}y ago`;
}
function segmentedButtonStyle(active, color, borderRight = "none", inactiveTextColor = "var(--text-white)") {
return {
backgroundColor: active ? color : "var(--bg-card)",
color: active ? "var(--text-white)" : inactiveTextColor,
borderRight,
fontWeight: active ? 600 : 500,
};
}
function BookmarkButton({ important, onClick }) {
return (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onClick(); }}
title={important ? "Remove bookmark" : "Bookmark"}
style={{
background: "none",
border: "none",
padding: "0 2px",
cursor: "pointer",
lineHeight: 1,
color: important ? "#f59e0b" : "var(--border-primary)",
opacity: important ? 1 : 0.35,
flexShrink: 0,
transition: "opacity 0.15s, color 0.15s",
display: "flex",
alignItems: "center",
}}
className="row-star"
>
<svg width="13" height="16" viewBox="0 0 13 16" fill={important ? "#f59e0b" : "currentColor"} xmlns="http://www.w3.org/2000/svg">
<path d="M1.5 1C1.22386 1 1 1.22386 1 1.5V14.5C1 14.7652 1.14583 14.9627 1.35217 15.0432C1.55851 15.1237 1.78462 15.0693 1.93301 14.9045L6.5 9.81L11.067 14.9045C11.2154 15.0693 11.4415 15.1237 11.6478 15.0432C11.8542 14.9627 12 14.7652 12 14.5V1.5C12 1.22386 11.7761 1 11.5 1H1.5Z"/>
</svg>
</button>
);
}
// Settings / Signature Modal
function SettingsModal({ open, onClose }) {
const [signature, setSignature] = useState(() => localStorage.getItem("mail_signature") || "");
const [saved, setSaved] = useState(false);
const [pollInterval, setPollInterval] = useState(() => getPollInterval());
const editorRef = useRef(null);
const quillRef = useRef(null);
const [quillLoaded, setQuillLoaded] = useState(!!window.Quill);
// ESC to close
useEffect(() => {
if (!open) return;
const handler = (e) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
// Load Quill (reuse global loader pattern)
useEffect(() => {
if (!open) return;
if (window.Quill) { setQuillLoaded(true); return; }
if (!document.getElementById("__quill_js__")) {
const link = document.createElement("link");
link.id = "__quill_css__";
link.rel = "stylesheet";
link.href = "https://cdn.quilljs.com/1.3.7/quill.snow.css";
document.head.appendChild(link);
const script = document.createElement("script");
script.id = "__quill_js__";
script.src = "https://cdn.quilljs.com/1.3.7/quill.min.js";
script.onload = () => setQuillLoaded(true);
document.head.appendChild(script);
}
}, [open]);
useEffect(() => {
if (!open || !quillLoaded || !editorRef.current || quillRef.current) return;
const q = new window.Quill(editorRef.current, {
theme: "snow",
placeholder: "Your signature...",
modules: {
toolbar: [
["bold", "italic", "underline"],
[{ color: [] }],
["link"],
["clean"],
],
},
});
// Load existing signature HTML
const saved = localStorage.getItem("mail_signature") || "";
if (saved) q.clipboard.dangerouslyPasteHTML(saved);
quillRef.current = q;
return () => { quillRef.current = null; };
}, [open, quillLoaded]);
const handleSave = () => {
const html = quillRef.current ? quillRef.current.root.innerHTML : signature;
localStorage.setItem("mail_signature", html);
const interval = Math.min(300, Math.max(15, parseInt(pollInterval, 10) || DEFAULT_POLL_INTERVAL));
localStorage.setItem("mail_poll_interval", String(interval));
setSaved(true);
setTimeout(() => { setSaved(false); onClose(); }, 800);
};
if (!open) return null;
return (
<div
style={{ position: "fixed", inset: 0, zIndex: 1100, backgroundColor: "rgba(0,0,0,0.55)", display: "flex", alignItems: "center", justifyContent: "center", padding: 60 }}
onClick={onClose}
>
<div
style={{ backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)", borderRadius: 12, width: "min(700px, 94vw)", display: "flex", flexDirection: "column", overflow: "hidden", boxShadow: "0 20px 60px rgba(0,0,0,0.4)", maxHeight: "85vh" }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-4" style={{ borderBottom: "1px solid var(--border-primary)", flexShrink: 0 }}>
<h2 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>Mail Settings</h2>
<button type="button" onClick={onClose} style={{ color: "var(--text-muted)", background: "none", border: "none", fontSize: 22, cursor: "pointer", lineHeight: 1 }}>×</button>
</div>
{/* Polling Rate */}
<div className="px-5 pt-4 pb-3 flex-shrink-0" style={{ borderBottom: "1px solid var(--border-secondary)" }}>
<p className="text-xs font-semibold uppercase tracking-wide mb-2" style={{ color: "var(--text-secondary)" }}>Auto-Check Interval</p>
<div className="flex items-center gap-3">
<input
type="number"
min={15}
max={300}
value={pollInterval}
onChange={(e) => setPollInterval(e.target.value)}
className="px-3 py-1.5 text-sm rounded-md border"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-primary)", color: "var(--text-primary)", width: 90 }}
/>
<span className="text-sm" style={{ color: "var(--text-muted)" }}>seconds <span style={{ fontSize: 11 }}>(min 15, max 300)</span></span>
</div>
<p className="text-xs mt-1.5" style={{ color: "var(--text-muted)" }}>
How often to check if new emails are available on the server. A banner will appear if new mail is found.
</p>
</div>
<div className="px-5 pt-4 pb-2 flex-shrink-0">
<p className="text-xs font-semibold uppercase tracking-wide mb-3" style={{ color: "var(--text-secondary)" }}>Email Signature</p>
</div>
<div className="flex-1 overflow-auto quill-sig-wrapper" style={{ minHeight: 200, paddingBottom: 0 }}>
{quillLoaded ? (
<div ref={editorRef} style={{ minHeight: 160, backgroundColor: "var(--bg-input)", color: "var(--text-primary)" }} />
) : (
<div className="flex items-center justify-center py-12" style={{ color: "var(--text-muted)" }}>Loading editor...</div>
)}
</div>
<div className="flex items-center justify-end gap-3 px-5 py-4" style={{ borderTop: "1px solid var(--border-primary)", flexShrink: 0 }}>
{saved && <span className="text-xs" style={{ color: "var(--success-text)" }}>Saved!</span>}
<button type="button" onClick={onClose} className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80" style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card)" }}>
Cancel
</button>
<button type="button" onClick={handleSave} className="px-4 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-90" style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}>
Save Settings
</button>
</div>
</div>
<style>{`
.quill-sig-wrapper .ql-toolbar { background: var(--bg-card-hover); border-color: var(--border-primary) !important; border-top: none !important; }
.quill-sig-wrapper .ql-toolbar button, .quill-sig-wrapper .ql-toolbar .ql-picker-label { color: var(--text-secondary) !important; }
.quill-sig-wrapper .ql-toolbar .ql-stroke { stroke: var(--text-secondary) !important; }
.quill-sig-wrapper .ql-toolbar button:hover .ql-stroke, .quill-sig-wrapper .ql-toolbar button.ql-active .ql-stroke { stroke: var(--accent) !important; }
.quill-sig-wrapper .ql-container { border-color: var(--border-secondary) !important; }
.quill-sig-wrapper .ql-editor { color: var(--text-primary) !important; background: var(--bg-input) !important; min-height: 160px; }
.quill-sig-wrapper .ql-editor.ql-blank::before { color: var(--text-muted) !important; font-style: normal !important; }
`}</style>
</div>
);
}
export default function MailPage() {
const [entries, setEntries] = useState([]);
const [customers, setCustomers] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [activeTab, setActiveTab] = useState("inbound");
const [clientFilter, setClientFilter] = useState("all");
const [readFilter, setReadFilter] = useState("all");
const [search, setSearch] = useState("");
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState(null);
// New-mail banner
const [newMailCount, setNewMailCount] = useState(0);
const [bannerDismissed, setBannerDismissed] = useState(false);
// Polling
const pollIntervalRef = useRef(null);
// Multi-select
const [selected, setSelected] = useState(new Set());
const [hoveredId, setHoveredId] = useState(null);
const [deleting, setDeleting] = useState(false);
// Modals
const [viewEntry, setViewEntry] = useState(null);
const [composeOpen, setComposeOpen] = useState(false);
const [composeTo, setComposeTo] = useState("");
const [composeFromAccount, setComposeFromAccount] = useState("");
const [mailboxFilter, setMailboxFilter] = useState("both");
const [settingsOpen, setSettingsOpen] = useState(false);
const loadAll = useCallback(async () => {
setLoading(true);
setError("");
try {
const [mailData, custsData] = await Promise.all([
api.get(`/crm/comms/email/all?limit=500&mailbox=${encodeURIComponent(mailboxFilter)}`),
api.get("/crm/customers"),
]);
setEntries(mailData.entries || []);
const map = {};
for (const c of custsData.customers || []) map[c.id] = c;
setCustomers(map);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [mailboxFilter]);
useEffect(() => { loadAll(); }, [loadAll]);
// Clear selection when tab changes
useEffect(() => { setSelected(new Set()); setReadFilter("all"); }, [activeTab]);
// Auto-poll: check for new emails on server
const startPolling = useCallback(() => {
if (pollIntervalRef.current) clearInterval(pollIntervalRef.current);
const intervalMs = getPollInterval() * 1000;
pollIntervalRef.current = setInterval(async () => {
try {
const data = await api.get("/crm/comms/email/check");
if (data.new_count > 0) {
setNewMailCount(data.new_count);
setBannerDismissed(false);
}
} catch {
// silently ignore poll errors
}
}, intervalMs);
}, []);
useEffect(() => {
startPolling();
return () => { if (pollIntervalRef.current) clearInterval(pollIntervalRef.current); };
}, [startPolling]);
const syncEmails = async () => {
setSyncing(true);
setSyncResult(null);
setNewMailCount(0);
setBannerDismissed(false);
try {
const data = await api.post("/crm/comms/email/sync", {});
setSyncResult(data);
await loadAll();
} catch (err) {
setSyncResult({ error: err.message });
} finally {
setSyncing(false);
}
};
const openReply = (toAddr, sourceAccount = "") => {
setViewEntry(null);
setComposeTo(toAddr || "");
setComposeFromAccount(sourceAccount || "");
setComposeOpen(true);
};
const toggleSelect = (id) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const deleteSelected = async () => {
if (selected.size === 0) return;
setDeleting(true);
try {
await api.post("/crm/comms/bulk-delete", { ids: [...selected] });
setSelected(new Set());
await loadAll();
} catch (err) {
setError(err.message);
} finally {
setDeleting(false);
}
};
const toggleImportant = async (entry) => {
const newVal = !entry.is_important;
setEntries((prev) =>
prev.map((e) => e.id === entry.id ? { ...e, is_important: newVal } : e)
);
try {
await api.patch(`/crm/comms/${entry.id}/important`, { important: newVal });
} catch {
setEntries((prev) =>
prev.map((e) => e.id === entry.id ? { ...e, is_important: !newVal } : e)
);
}
};
const openEntry = async (entry) => {
setViewEntry(entry);
// Mark inbound as read if not already
if (entry.direction === "inbound" && !entry.is_read) {
setEntries((prev) =>
prev.map((e) => e.id === entry.id ? { ...e, is_read: true } : e)
);
try {
await api.patch(`/crm/comms/${entry.id}/read`, { read: true });
} catch {
// non-critical — don't revert
}
}
};
const q = search.trim().toLowerCase();
const customerEmailMap = useMemo(() => {
const map = {};
Object.values(customers).forEach((c) => {
(c.contacts || []).forEach((ct) => {
if (ct?.type === "email" && ct?.value) map[String(ct.value).toLowerCase()] = c.id;
});
});
return map;
}, [customers]);
const resolveCustomerId = useCallback((entry) => {
if (entry.customer_id && customers[entry.customer_id]) return entry.customer_id;
const candidates = [];
if (entry.from_addr) candidates.push(String(entry.from_addr).toLowerCase());
const toList = Array.isArray(entry.to_addrs) ? entry.to_addrs : (entry.to_addrs ? [entry.to_addrs] : []);
toList.forEach((addr) => candidates.push(String(addr).toLowerCase()));
for (const addr of candidates) {
if (customerEmailMap[addr]) return customerEmailMap[addr];
}
return null;
}, [customerEmailMap, customers]);
const tabEntries = entries.filter((e) => e.direction === activeTab);
const clientFiltered = clientFilter === "clients"
? tabEntries.filter((e) => !!resolveCustomerId(e))
: tabEntries;
const readFiltered = readFilter === "unread"
? clientFiltered.filter((e) => !e.is_read)
: readFilter === "read"
? clientFiltered.filter((e) => !!e.is_read)
: readFilter === "important"
? clientFiltered.filter((e) => !!e.is_important)
: clientFiltered;
const filtered = readFiltered.filter((e) => {
if (!q) return true;
const custId = resolveCustomerId(e);
const cust = custId ? customers[custId] : null;
return (
(e.subject || "").toLowerCase().includes(q) ||
(e.body || "").toLowerCase().includes(q) ||
(e.from_addr || "").toLowerCase().includes(q) ||
(cust?.name || "").toLowerCase().includes(q) ||
(cust?.organization || "").toLowerCase().includes(q)
);
});
const unreadInboundCount = entries.filter((e) => e.direction === "inbound" && !e.is_read).length;
const anySelected = selected.size > 0;
const showBanner = newMailCount > 0 && !bannerDismissed;
return (
<div>
{/* Header */}
<div className="flex items-center justify-between mb-5">
<div>
<h1 className="text-xl font-bold" style={{ color: "var(--text-heading)" }}>Mail</h1>
<p className="text-sm mt-0.5" style={{ color: "var(--text-muted)" }}>All synced emails</p>
</div>
<div className="flex items-center gap-2">
{syncResult && (
<span className="text-xs" style={{ color: syncResult.error ? "var(--danger-text)" : "var(--success-text)" }}>
{syncResult.error ? syncResult.error : `${syncResult.new_count} new email${syncResult.new_count !== 1 ? "s" : ""}`}
</span>
)}
<button
onClick={() => { setComposeTo(""); setComposeFromAccount(""); setComposeOpen(true); }}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-90"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
Compose
</button>
<button
onClick={syncEmails}
disabled={syncing || loading}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ border: "1px solid", borderColor: "var(--border-primary)", color: "var(--text-secondary)", opacity: (syncing || loading) ? 0.6 : 1 }}
>
{syncing ? "Syncing..." : "Sync Mail"}
</button>
<button
onClick={loadAll}
disabled={loading}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ border: "1px solid", borderColor: "var(--border-primary)", color: "var(--text-secondary)", opacity: loading ? 0.6 : 1 }}
>
Refresh
</button>
<button
onClick={() => setSettingsOpen(true)}
title="Mail Settings"
className="px-2 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ border: "1px solid", borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
>
</button>
</div>
</div>
{/* New-mail banner */}
{showBanner && (
<div
className="flex items-center justify-between px-4 py-2.5 rounded-lg mb-4 text-sm"
style={{ backgroundColor: "color-mix(in srgb, var(--accent) 12%, var(--bg-card))", border: "1px solid var(--accent)", color: "var(--text-heading)" }}
>
<span>
<span style={{ fontWeight: 600 }}>{newMailCount} new email{newMailCount !== 1 ? "s" : ""}</span> available on server
</span>
<div className="flex items-center gap-2">
<button
onClick={syncEmails}
disabled={syncing}
className="px-3 py-1 text-xs rounded-md cursor-pointer hover:opacity-90"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", opacity: syncing ? 0.6 : 1 }}
>
{syncing ? "Syncing..." : "Sync Now"}
</button>
<button
onClick={() => setBannerDismissed(true)}
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-muted)", fontSize: 18, lineHeight: 1, padding: "0 2px" }}
>
×
</button>
</div>
</div>
)}
{/* Tabs + Filters + Search + Bulk actions */}
<div className="flex items-center gap-3 mb-4 flex-wrap">
{/* Direction Tabs */}
<div className="flex rounded-lg overflow-hidden" style={{ border: "1px solid var(--border-primary)" }}>
{TABS.map((tab) => {
const count = entries.filter((e) => e.direction === tab).length;
const active = tab === activeTab;
const unread = tab === "inbound" ? unreadInboundCount : 0;
const color = tab === "inbound" ? FILTER_COLORS.inbound : FILTER_COLORS.outbound;
return (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className="px-4 py-1.5 text-sm cursor-pointer capitalize"
style={segmentedButtonStyle(
active,
color,
tab === "inbound" ? "1px solid var(--border-primary)" : "none",
color
)}
>
{tab}{" "}
<span style={{ opacity: 0.7, fontSize: 11 }}>({count})</span>
{unread > 0 && (
<span
style={{
display: "inline-flex", alignItems: "center", justifyContent: "center",
backgroundColor: active ? "rgba(255,255,255,0.25)" : "var(--accent)",
color: active ? "var(--text-white)" : "#fff",
borderRadius: 10, fontSize: 10, fontWeight: 700,
minWidth: 16, height: 16, padding: "0 4px", marginLeft: 5,
}}
>
{unread}
</span>
)}
</button>
);
})}
</div>
{/* Client filter */}
<div className="flex rounded-lg overflow-hidden" style={{ border: "1px solid var(--border-primary)" }}>
{[["all", "All Messages"], ["clients", "Clients Only"]].map(([val, label]) => {
const active = clientFilter === val;
const color = val === "all" ? FILTER_COLORS.all_messages : FILTER_COLORS.clients_only;
return (
<button
key={val}
onClick={() => setClientFilter(val)}
className="px-4 py-1.5 text-sm cursor-pointer"
style={segmentedButtonStyle(active, color, val === "all" ? "1px solid var(--border-primary)" : "none")}
>
{label}
</button>
);
})}
</div>
<div className="flex rounded-lg overflow-hidden" style={{ border: "1px solid var(--border-primary)" }}>
{MAILBOX_TABS.map((tab) => {
const active = mailboxFilter === tab;
const label = tab === "both" ? "ALL" : tab[0].toUpperCase() + tab.slice(1);
const color = tab === "sales" ? FILTER_COLORS.sales : tab === "support" ? FILTER_COLORS.support : FILTER_COLORS.mailbox_all;
return (
<button
key={tab}
onClick={() => setMailboxFilter(tab)}
className="px-4 py-1.5 text-sm cursor-pointer"
style={segmentedButtonStyle(active, color, tab !== "both" ? "1px solid var(--border-primary)" : "none")}
>
{label}
</button>
);
})}
</div>
{/* Read status filter */}
<div className="flex rounded-lg overflow-hidden" style={{ border: "1px solid var(--border-primary)" }}>
{READ_FILTER_TABS.map((val) => {
const active = readFilter === val;
const label = val === "all" ? "All" : val === "unread" ? "Unread" : val === "read" ? "Read" : "Importants";
const color = val === "all"
? FILTER_COLORS.read_all
: val === "unread"
? FILTER_COLORS.unread
: val === "read"
? FILTER_COLORS.read
: FILTER_COLORS.important;
return (
<button
key={val}
onClick={() => setReadFilter(val)}
className="px-4 py-1.5 text-sm cursor-pointer"
style={segmentedButtonStyle(active, color, val !== "important" ? "1px solid var(--border-primary)" : "none")}
>
{label}
</button>
);
})}
</div>
{/* Search */}
<div style={{ flex: "1 1 320px", minWidth: 220, maxWidth: 800, display: "flex", gap: 8 }}>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search subject, sender, customer..."
className="px-3 text-sm rounded-md border"
style={{
height: 34,
width: "100%",
minWidth: 220,
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-primary)",
color: "var(--text-primary)",
}}
/>
{search && (
<button
onClick={() => setSearch("")}
className="px-3 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ height: 34, color: "var(--danger-text)", backgroundColor: "var(--danger-bg)", borderRadius: 6 }}
>
Clear
</button>
)}
</div>
{/* Bulk delete */}
{anySelected && (
<button
onClick={deleteSelected}
disabled={deleting}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80 ml-auto"
style={{ backgroundColor: "var(--danger)", color: "#fff", opacity: deleting ? 0.6 : 1 }}
>
{deleting ? "Deleting..." : `Delete ${selected.size} selected`}
</button>
)}
</div>
{error && (
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
)}
{loading ? (
<div className="text-center py-12" style={{ color: "var(--text-muted)" }}>Loading...</div>
) : filtered.length === 0 ? (
<div className="rounded-lg p-10 text-center text-sm border" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}>
{tabEntries.length === 0 ? `No ${activeTab} emails yet.` : "No emails match your search."}
</div>
) : (
<div>
<div className="text-xs mb-3" style={{ color: "var(--text-muted)" }}>
{filtered.length} email{filtered.length !== 1 ? "s" : ""}
{anySelected && <span style={{ color: "var(--accent)", marginLeft: 8 }}>{selected.size} selected</span>}
</div>
<div className="rounded-lg border overflow-hidden" style={{ borderColor: "var(--border-primary)" }}>
{filtered.map((entry, idx) => {
const resolvedCustomerId = resolveCustomerId(entry);
const customer = resolvedCustomerId ? customers[resolvedCustomerId] : null;
const addrLine = activeTab === "inbound"
? (entry.from_addr || "")
: (Array.isArray(entry.to_addrs) ? entry.to_addrs[0] : (entry.to_addrs || ""));
const isSelected = selected.has(entry.id);
const isKnownCustomer = !!customer;
const isUnread = entry.direction === "inbound" && !entry.is_read;
return (
<div
key={entry.id}
className={idx > 0 ? "border-t" : ""}
style={{ borderColor: "var(--border-secondary)" }}
onMouseEnter={() => setHoveredId(entry.id)}
onMouseLeave={() => setHoveredId(null)}
>
<div
className="flex items-center gap-3 px-3 py-3 cursor-pointer"
style={{
backgroundColor: isSelected
? "color-mix(in srgb, var(--btn-primary) 10%, var(--bg-card))"
: isUnread
? "color-mix(in srgb, var(--accent) 5%, var(--bg-card))"
: "var(--bg-card)",
transition: "background-color 0.1s",
}}
onClick={() => {
if (anySelected) toggleSelect(entry.id);
else openEntry(entry);
}}
>
{/* Unread dot */}
<div style={{ width: 8, flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
{isUnread && (
<span style={{ width: 7, height: 7, borderRadius: "50%", backgroundColor: "var(--accent)", display: "block", flexShrink: 0 }} />
)}
</div>
{/* Bookmark */}
<BookmarkButton important={entry.is_important} onClick={() => toggleImportant(entry)} />
{/* Identity column */}
<div style={{ width: ID_COL_W, flexShrink: 0, minWidth: 0, overflow: "hidden" }}>
{isKnownCustomer ? (
<>
<Link
to={`/crm/customers/${resolvedCustomerId}`}
className="hover:underline block text-xs leading-tight"
style={{ color: "var(--accent)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: isUnread ? 700 : 500 }}
onClick={(e) => e.stopPropagation()}
>
{customer.name}
</Link>
{customer.organization && (
<span
className="block text-xs leading-tight"
style={{ color: "var(--accent)", opacity: 0.75, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{customer.organization}
</span>
)}
</>
) : (
<span
className="block text-xs leading-tight"
style={{ color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: isUnread ? 700 : 500 }}
>
{addrLine}
</span>
)}
</div>
{/* Subject + preview */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span
className="text-sm"
style={{ color: "var(--text-heading)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", display: "block", fontWeight: isUnread ? 700 : 400 }}
>
{entry.subject || <span style={{ color: "var(--text-muted)", fontStyle: "italic", fontWeight: 400 }}>(no subject)</span>}
</span>
{Array.isArray(entry.attachments) && entry.attachments.length > 0 && (
<span className="text-xs flex-shrink-0" style={{ color: "var(--text-muted)" }}>
📎 {entry.attachments.length}
</span>
)}
</div>
{entry.body && (
<p
className="text-xs mt-0.5"
style={{ color: "var(--text-muted)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{entry.body}
</p>
)}
</div>
{/* Date + checkbox + chevron */}
<div className="flex-shrink-0 text-right flex items-center gap-2" style={{ minWidth: 80 }}>
<span
className="text-xs"
title={entry.occurred_at ? new Date(entry.occurred_at).toLocaleString() : ""}
style={{ color: "var(--text-muted)", cursor: "default", fontWeight: isUnread ? 600 : 400 }}
>
{relativeTime(entry.occurred_at)}
</span>
<div style={{ width: 20, flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
{(anySelected || hoveredId === entry.id) ? (
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSelect(entry.id)}
onClick={(e) => e.stopPropagation()}
style={{ cursor: "pointer", accentColor: "var(--btn-primary)", width: 14, height: 14 }}
/>
) : (
<span className="text-xs" style={{ color: "var(--text-muted)" }}></span>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Hover CSS for star visibility */}
<style>{`
.row-star { opacity: 0.35; transition: opacity 0.15s, color 0.15s; }
div:hover > div > .row-star { opacity: 0.6; }
.row-star[title="Mark as normal"] { opacity: 1 !important; color: #f59e0b !important; }
`}</style>
<MailViewModal
open={!!viewEntry}
onClose={() => setViewEntry(null)}
entry={viewEntry}
customer={viewEntry ? customers[resolveCustomerId(viewEntry)] : null}
onReply={openReply}
/>
<ComposeEmailModal
open={composeOpen}
onClose={() => setComposeOpen(false)}
defaultTo={composeTo}
defaultFromAccount={composeFromAccount}
requireFromAccount={true}
onSent={() => loadAll()}
/>
<SettingsModal
open={settingsOpen}
onClose={() => {
setSettingsOpen(false);
startPolling(); // restart poll with potentially new interval
}}
/>
</div>
);
}

View File

@@ -0,0 +1,293 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import api from "../../api/client";
import { useAuth } from "../../auth/AuthContext";
const STATUS_COLORS = {
draft: { bg: "var(--bg-card-hover)", color: "var(--text-secondary)" },
confirmed: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" },
in_production: { bg: "#fff7ed", color: "#9a3412" },
shipped: { bg: "#f5f3ff", color: "#6d28d9" },
delivered: { bg: "var(--success-bg)", color: "var(--success-text)" },
cancelled: { bg: "var(--danger-bg)", color: "var(--danger-text)" },
};
const PAYMENT_COLORS = {
pending: { bg: "#fef9c3", color: "#854d0e" },
partial: { bg: "#fff7ed", color: "#9a3412" },
paid: { bg: "var(--success-bg)", color: "var(--success-text)" },
};
const labelStyle = {
display: "block",
marginBottom: 4,
fontSize: 12,
color: "var(--text-secondary)",
fontWeight: 500,
};
function ReadField({ label, value }) {
return (
<div>
<div style={labelStyle}>{label}</div>
<div className="text-sm" style={{ color: "var(--text-primary)" }}>
{value || <span style={{ color: "var(--text-muted)" }}></span>}
</div>
</div>
);
}
function SectionCard({ title, children }) {
return (
<div className="ui-section-card mb-4">
<h2 className="ui-section-card__header-title mb-4">{title}</h2>
{children}
</div>
);
}
export default function OrderDetail() {
const { id } = useParams();
const navigate = useNavigate();
const { hasPermission } = useAuth();
const canEdit = hasPermission("crm", "edit");
const [order, setOrder] = useState(null);
const [customer, setCustomer] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
api.get(`/crm/orders/${id}`)
.then((data) => {
setOrder(data);
if (data.customer_id) {
api.get(`/crm/customers/${data.customer_id}`)
.then(setCustomer)
.catch(() => {});
}
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [id]);
if (loading) {
return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
}
if (error) {
return (
<div className="text-sm rounded-md p-3 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
);
}
if (!order) return null;
const statusStyle = STATUS_COLORS[order.status] || STATUS_COLORS.draft;
const payStyle = PAYMENT_COLORS[order.payment_status] || PAYMENT_COLORS.pending;
const shipping = order.shipping || {};
const subtotal = (order.items || []).reduce((sum, item) => {
return sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
}, 0);
const discount = order.discount || {};
const discountAmount =
discount.type === "percentage"
? subtotal * ((Number(discount.value) || 0) / 100)
: Number(discount.value) || 0;
const total = Number(order.total_price || 0);
return (
<div style={{ maxWidth: 900 }}>
{/* Header */}
<div className="flex items-start justify-between mb-6">
<div>
<div className="flex items-center gap-3 mb-1">
<h1 className="text-2xl font-bold font-mono" style={{ color: "var(--text-heading)" }}>
{order.order_number}
</h1>
<span
className="px-2 py-0.5 text-xs rounded-full capitalize"
style={{ backgroundColor: statusStyle.bg, color: statusStyle.color }}
>
{(order.status || "draft").replace("_", " ")}
</span>
</div>
{customer ? (
<button
onClick={() => navigate(`/crm/customers/${customer.id}`)}
className="text-sm hover:underline"
style={{ color: "var(--accent)", background: "none", border: "none", cursor: "pointer", padding: 0 }}
>
{customer.organization ? `${customer.name} / ${customer.organization}` : customer.name}
</button>
) : (
<span className="text-sm" style={{ color: "var(--text-muted)" }}>{order.customer_id}</span>
)}
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
Created {order.created_at ? new Date(order.created_at).toLocaleDateString() : "—"}
{order.updated_at && order.updated_at !== order.created_at && (
<span> · Updated {new Date(order.updated_at).toLocaleDateString()}</span>
)}
</p>
</div>
<div className="flex gap-2">
{customer && (
<button
onClick={() => navigate(`/crm/customers/${customer.id}`)}
className="px-3 py-1.5 text-sm rounded-md border cursor-pointer hover:opacity-80"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
>
Back to Customer
</button>
)}
{canEdit && (
<button
onClick={() => navigate(`/crm/orders/${id}/edit`)}
className="px-4 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-90"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
Edit
</button>
)}
</div>
</div>
{/* Items */}
<SectionCard title="Items">
{(order.items || []).length === 0 ? (
<p className="text-sm" style={{ color: "var(--text-muted)" }}>No items.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr style={{ borderBottom: "1px solid var(--border-primary)" }}>
<th className="pb-2 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Item</th>
<th className="pb-2 text-right font-medium" style={{ color: "var(--text-secondary)" }}>Qty</th>
<th className="pb-2 text-right font-medium" style={{ color: "var(--text-secondary)" }}>Unit Price</th>
<th className="pb-2 text-right font-medium" style={{ color: "var(--text-secondary)" }}>Line Total</th>
</tr>
</thead>
<tbody>
{order.items.map((item, idx) => {
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
const label =
item.type === "product"
? item.product_name || item.product_id || "Product"
: item.type === "console_device"
? `${item.device_id || ""}${item.label ? ` (${item.label})` : ""}`
: item.description || "—";
return (
<tr
key={idx}
style={{ borderBottom: idx < order.items.length - 1 ? "1px solid var(--border-secondary)" : "none" }}
>
<td className="py-2 pr-4">
<span style={{ color: "var(--text-primary)" }}>{label}</span>
<span className="ml-2 text-xs px-1.5 py-0.5 rounded-full" style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-muted)" }}>
{item.type.replace("_", " ")}
</span>
{Array.isArray(item.serial_numbers) && item.serial_numbers.length > 0 && (
<div className="text-xs mt-0.5 font-mono" style={{ color: "var(--text-muted)" }}>
SN: {item.serial_numbers.join(", ")}
</div>
)}
</td>
<td className="py-2 text-right" style={{ color: "var(--text-primary)" }}>{item.quantity}</td>
<td className="py-2 text-right" style={{ color: "var(--text-primary)" }}>
{order.currency} {Number(item.unit_price || 0).toFixed(2)}
</td>
<td className="py-2 text-right font-medium" style={{ color: "var(--text-heading)" }}>
{order.currency} {lineTotal.toFixed(2)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</SectionCard>
{/* Pricing Summary */}
<SectionCard title="Pricing">
<div style={{ display: "flex", flexDirection: "column", gap: 8, maxWidth: 300, marginLeft: "auto" }}>
<div className="flex justify-between text-sm">
<span style={{ color: "var(--text-secondary)" }}>Subtotal</span>
<span style={{ color: "var(--text-primary)" }}>{order.currency} {subtotal.toFixed(2)}</span>
</div>
{discountAmount > 0 && (
<div className="flex justify-between text-sm">
<span style={{ color: "var(--text-secondary)" }}>
Discount
{discount.type === "percentage" && ` (${discount.value}%)`}
{discount.reason && `${discount.reason}`}
</span>
<span style={{ color: "var(--danger-text)" }}>{order.currency} {discountAmount.toFixed(2)}</span>
</div>
)}
<div
className="flex justify-between text-sm font-semibold pt-2"
style={{ borderTop: "1px solid var(--border-primary)" }}
>
<span style={{ color: "var(--text-heading)" }}>Total</span>
<span style={{ color: "var(--text-heading)" }}>{order.currency} {total.toFixed(2)}</span>
</div>
</div>
</SectionCard>
{/* Payment */}
<SectionCard title="Payment">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
<div>
<div style={labelStyle}>Payment Status</div>
<span
className="px-2 py-0.5 text-xs rounded-full capitalize"
style={{ backgroundColor: payStyle.bg, color: payStyle.color }}
>
{order.payment_status || "pending"}
</span>
</div>
<div>
<div style={labelStyle}>Invoice</div>
{order.invoice_path ? (
<span className="text-sm font-mono" style={{ color: "var(--text-primary)" }}>
{order.invoice_path}
</span>
) : (
<span style={{ color: "var(--text-muted)", fontSize: 14 }}></span>
)}
</div>
</div>
</SectionCard>
{/* Shipping */}
<SectionCard title="Shipping">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16 }}>
<ReadField label="Method" value={shipping.method} />
<ReadField label="Carrier" value={shipping.carrier} />
<ReadField label="Tracking Number" value={shipping.tracking_number} />
<ReadField label="Destination" value={shipping.destination} />
<ReadField
label="Shipped At"
value={shipping.shipped_at ? new Date(shipping.shipped_at).toLocaleDateString() : null}
/>
<ReadField
label="Delivered At"
value={shipping.delivered_at ? new Date(shipping.delivered_at).toLocaleDateString() : null}
/>
</div>
</SectionCard>
{/* Notes */}
{order.notes && (
<SectionCard title="Notes">
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--text-primary)" }}>{order.notes}</p>
</SectionCard>
)}
</div>
);
}

View File

@@ -0,0 +1,662 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import api from "../../api/client";
import { useAuth } from "../../auth/AuthContext";
const ORDER_STATUSES = ["draft", "confirmed", "in_production", "shipped", "delivered", "cancelled"];
const PAYMENT_STATUSES = ["pending", "partial", "paid"];
const CURRENCIES = ["EUR", "USD", "GBP"];
const ITEM_TYPES = ["product", "console_device", "freetext"];
const inputClass = "w-full px-3 py-2 text-sm rounded-md border";
const inputStyle = {
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-primary)",
color: "var(--text-primary)",
};
const labelStyle = {
display: "block",
marginBottom: 4,
fontSize: 12,
color: "var(--text-secondary)",
fontWeight: 500,
};
function Field({ label, children, style }) {
return (
<div style={style}>
<label style={labelStyle}>{label}</label>
{children}
</div>
);
}
function SectionCard({ title, children }) {
return (
<div className="ui-section-card mb-4">
<h2 className="ui-section-card__header-title mb-4">{title}</h2>
{children}
</div>
);
}
const emptyItem = () => ({
type: "product",
product_id: "",
product_name: "",
description: "",
device_id: "",
label: "",
quantity: 1,
unit_price: 0,
serial_numbers: "",
});
export default function OrderForm() {
const { id } = useParams();
const isEdit = Boolean(id);
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { hasPermission } = useAuth();
const canEdit = hasPermission("crm", "edit");
const [form, setForm] = useState({
customer_id: searchParams.get("customer_id") || "",
order_number: "",
status: "draft",
currency: "EUR",
items: [],
discount: { type: "percentage", value: 0, reason: "" },
payment_status: "pending",
invoice_path: "",
shipping: {
method: "",
carrier: "",
tracking_number: "",
destination: "",
shipped_at: "",
delivered_at: "",
},
notes: "",
});
const [customers, setCustomers] = useState([]);
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(isEdit);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [customerSearch, setCustomerSearch] = useState("");
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
// Load customers and products
useEffect(() => {
api.get("/crm/customers").then((d) => setCustomers(d.customers || [])).catch(() => {});
api.get("/crm/products").then((d) => setProducts(d.products || [])).catch(() => {});
}, []);
// Load order for edit
useEffect(() => {
if (!isEdit) return;
api.get(`/crm/orders/${id}`)
.then((data) => {
const shipping = data.shipping || {};
setForm({
customer_id: data.customer_id || "",
order_number: data.order_number || "",
status: data.status || "draft",
currency: data.currency || "EUR",
items: (data.items || []).map((item) => ({
...emptyItem(),
...item,
serial_numbers: Array.isArray(item.serial_numbers)
? item.serial_numbers.join(", ")
: item.serial_numbers || "",
})),
discount: data.discount || { type: "percentage", value: 0, reason: "" },
payment_status: data.payment_status || "pending",
invoice_path: data.invoice_path || "",
shipping: {
method: shipping.method || "",
carrier: shipping.carrier || "",
tracking_number: shipping.tracking_number || "",
destination: shipping.destination || "",
shipped_at: shipping.shipped_at ? shipping.shipped_at.slice(0, 10) : "",
delivered_at: shipping.delivered_at ? shipping.delivered_at.slice(0, 10) : "",
},
notes: data.notes || "",
});
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [id, isEdit]);
// Set customer search label when customer_id loads
useEffect(() => {
if (form.customer_id && customers.length > 0) {
const c = customers.find((x) => x.id === form.customer_id);
if (c) setCustomerSearch(c.organization ? `${c.name} / ${c.organization}` : c.name);
}
}, [form.customer_id, customers]);
const setField = (key, value) => setForm((f) => ({ ...f, [key]: value }));
const setShipping = (key, value) => setForm((f) => ({ ...f, shipping: { ...f.shipping, [key]: value } }));
const setDiscount = (key, value) => setForm((f) => ({ ...f, discount: { ...f.discount, [key]: value } }));
const addItem = () => setForm((f) => ({ ...f, items: [...f.items, emptyItem()] }));
const removeItem = (idx) => setForm((f) => ({ ...f, items: f.items.filter((_, i) => i !== idx) }));
const setItem = (idx, key, value) =>
setForm((f) => ({
...f,
items: f.items.map((item, i) => (i === idx ? { ...item, [key]: value } : item)),
}));
const onProductSelect = (idx, productId) => {
const product = products.find((p) => p.id === productId);
setForm((f) => ({
...f,
items: f.items.map((item, i) =>
i === idx
? { ...item, product_id: productId, product_name: product?.name || "", unit_price: product?.price || 0 }
: item
),
}));
};
// Computed pricing
const subtotal = form.items.reduce((sum, item) => {
return sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
}, 0);
const discountAmount =
form.discount.type === "percentage"
? subtotal * ((Number(form.discount.value) || 0) / 100)
: Number(form.discount.value) || 0;
const total = Math.max(0, subtotal - discountAmount);
const filteredCustomers = customerSearch
? customers.filter((c) => {
const q = customerSearch.toLowerCase();
return c.name.toLowerCase().includes(q) || (c.organization || "").toLowerCase().includes(q);
})
: customers.slice(0, 20);
const handleSave = async () => {
if (!form.customer_id) { setError("Please select a customer."); return; }
setSaving(true);
setError("");
try {
const payload = {
customer_id: form.customer_id,
order_number: form.order_number || undefined,
status: form.status,
currency: form.currency,
items: form.items.map((item) => ({
type: item.type,
product_id: item.product_id || null,
product_name: item.product_name || null,
description: item.description || null,
device_id: item.device_id || null,
label: item.label || null,
quantity: Number(item.quantity) || 1,
unit_price: Number(item.unit_price) || 0,
serial_numbers: item.serial_numbers
? item.serial_numbers.split(",").map((s) => s.trim()).filter(Boolean)
: [],
})),
subtotal,
discount: {
type: form.discount.type,
value: Number(form.discount.value) || 0,
reason: form.discount.reason || "",
},
total_price: total,
payment_status: form.payment_status,
invoice_path: form.invoice_path || "",
shipping: {
method: form.shipping.method || "",
carrier: form.shipping.carrier || "",
tracking_number: form.shipping.tracking_number || "",
destination: form.shipping.destination || "",
shipped_at: form.shipping.shipped_at || null,
delivered_at: form.shipping.delivered_at || null,
},
notes: form.notes || "",
};
if (isEdit) {
await api.put(`/crm/orders/${id}`, payload);
navigate(`/crm/orders/${id}`);
} else {
const result = await api.post("/crm/orders", payload);
navigate(`/crm/orders/${result.id}`);
}
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!window.confirm("Delete this order? This cannot be undone.")) return;
try {
await api.delete(`/crm/orders/${id}`);
navigate("/crm/orders");
} catch (err) {
setError(err.message);
}
};
if (loading) {
return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
}
if (!canEdit) {
return <div className="text-sm p-3" style={{ color: "var(--danger-text)" }}>No permission to edit orders.</div>;
}
return (
<div style={{ maxWidth: 900 }}>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
{isEdit ? "Edit Order" : "New Order"}
</h1>
<button
onClick={() => navigate(isEdit ? `/crm/orders/${id}` : "/crm/orders")}
className="px-3 py-1.5 text-sm rounded-md border cursor-pointer hover:opacity-80"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
>
Cancel
</button>
</div>
{error && (
<div
className="text-sm rounded-md p-3 mb-4 border"
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
>
{error}
</div>
)}
{/* 1. Customer */}
<SectionCard title="Customer">
<div style={{ position: "relative" }}>
<label style={labelStyle}>Customer *</label>
<input
className={inputClass}
style={inputStyle}
placeholder="Search by name or organization..."
value={customerSearch}
onChange={(e) => {
setCustomerSearch(e.target.value);
setShowCustomerDropdown(true);
if (!e.target.value) setField("customer_id", "");
}}
onFocus={() => setShowCustomerDropdown(true)}
onBlur={() => setTimeout(() => setShowCustomerDropdown(false), 150)}
/>
{showCustomerDropdown && filteredCustomers.length > 0 && (
<div
className="absolute z-10 w-full mt-1 rounded-md border shadow-lg"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", maxHeight: 200, overflowY: "auto" }}
>
{filteredCustomers.map((c) => (
<div
key={c.id}
className="px-3 py-2 text-sm cursor-pointer hover:opacity-80"
style={{ color: "var(--text-primary)", borderBottom: "1px solid var(--border-secondary)" }}
onMouseDown={() => {
setField("customer_id", c.id);
setCustomerSearch(c.organization ? `${c.name} / ${c.organization}` : c.name);
setShowCustomerDropdown(false);
}}
>
<span style={{ color: "var(--text-heading)" }}>{c.name}</span>
{c.organization && (
<span className="ml-2 text-xs" style={{ color: "var(--text-muted)" }}>{c.organization}</span>
)}
</div>
))}
</div>
)}
</div>
</SectionCard>
{/* 2. Order Info */}
<SectionCard title="Order Info">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16 }}>
<Field label="Order Number">
<input
className={inputClass}
style={inputStyle}
placeholder="Auto-generated if empty"
value={form.order_number}
onChange={(e) => setField("order_number", e.target.value)}
/>
</Field>
<Field label="Status">
<select className={inputClass} style={inputStyle} value={form.status}
onChange={(e) => setField("status", e.target.value)}>
{ORDER_STATUSES.map((s) => (
<option key={s} value={s}>{s.replace("_", " ")}</option>
))}
</select>
</Field>
<Field label="Currency">
<select className={inputClass} style={inputStyle} value={form.currency}
onChange={(e) => setField("currency", e.target.value)}>
{CURRENCIES.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</Field>
</div>
</SectionCard>
{/* 3. Items */}
<SectionCard title="Items">
{form.items.length === 0 ? (
<p className="text-sm mb-4" style={{ color: "var(--text-muted)" }}>No items yet.</p>
) : (
<div className="space-y-3 mb-4">
{form.items.map((item, idx) => (
<div
key={idx}
className="rounded-md border p-4"
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-secondary)" }}
>
<div style={{ display: "grid", gridTemplateColumns: "140px 1fr 100px 120px auto", gap: 12, alignItems: "end" }}>
<Field label="Type">
<select
className={inputClass}
style={inputStyle}
value={item.type}
onChange={(e) => setItem(idx, "type", e.target.value)}
>
{ITEM_TYPES.map((t) => <option key={t} value={t}>{t.replace("_", " ")}</option>)}
</select>
</Field>
{item.type === "product" && (
<Field label="Product">
<select
className={inputClass}
style={inputStyle}
value={item.product_id}
onChange={(e) => onProductSelect(idx, e.target.value)}
>
<option value="">Select product...</option>
{products.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</Field>
)}
{item.type === "console_device" && (
<Field label="Device ID + Label">
<div style={{ display: "flex", gap: 8 }}>
<input
className={inputClass}
style={inputStyle}
placeholder="Device UID"
value={item.device_id}
onChange={(e) => setItem(idx, "device_id", e.target.value)}
/>
<input
className={inputClass}
style={inputStyle}
placeholder="Label"
value={item.label}
onChange={(e) => setItem(idx, "label", e.target.value)}
/>
</div>
</Field>
)}
{item.type === "freetext" && (
<Field label="Description">
<input
className={inputClass}
style={inputStyle}
placeholder="Description..."
value={item.description}
onChange={(e) => setItem(idx, "description", e.target.value)}
/>
</Field>
)}
<Field label="Qty">
<input
type="number"
min="1"
className={inputClass}
style={inputStyle}
value={item.quantity}
onChange={(e) => setItem(idx, "quantity", e.target.value)}
/>
</Field>
<Field label="Unit Price">
<input
type="number"
min="0"
step="0.01"
className={inputClass}
style={inputStyle}
value={item.unit_price}
onChange={(e) => setItem(idx, "unit_price", e.target.value)}
/>
</Field>
<div style={{ paddingBottom: 2 }}>
<button
type="button"
onClick={() => removeItem(idx)}
className="px-3 py-2 text-sm rounded-md border cursor-pointer hover:opacity-80"
style={{ borderColor: "var(--danger)", color: "var(--danger-text)", whiteSpace: "nowrap" }}
>
Remove
</button>
</div>
</div>
<div className="mt-3">
<Field label="Serial Numbers (comma-separated)">
<input
className={inputClass}
style={inputStyle}
placeholder="SN001, SN002..."
value={item.serial_numbers}
onChange={(e) => setItem(idx, "serial_numbers", e.target.value)}
/>
</Field>
</div>
</div>
))}
</div>
)}
<button
type="button"
onClick={addItem}
className="px-3 py-1.5 text-sm rounded-md border cursor-pointer hover:opacity-80"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
>
+ Add Item
</button>
</SectionCard>
{/* 4. Pricing */}
<SectionCard title="Pricing">
<div className="flex gap-8 mb-4">
<div>
<div style={labelStyle}>Subtotal</div>
<div className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>
{form.currency} {subtotal.toFixed(2)}
</div>
</div>
<div>
<div style={labelStyle}>Total</div>
<div className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>
{form.currency} {total.toFixed(2)}
</div>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "140px 160px 1fr", gap: 16 }}>
<Field label="Discount Type">
<select
className={inputClass}
style={inputStyle}
value={form.discount.type}
onChange={(e) => setDiscount("type", e.target.value)}
>
<option value="percentage">Percentage (%)</option>
<option value="fixed">Fixed Amount</option>
</select>
</Field>
<Field label={form.discount.type === "percentage" ? "Discount %" : "Discount Amount"}>
<input
type="number"
min="0"
step="0.01"
className={inputClass}
style={inputStyle}
value={form.discount.value}
onChange={(e) => setDiscount("value", e.target.value)}
/>
</Field>
<Field label="Discount Reason">
<input
className={inputClass}
style={inputStyle}
placeholder="Optional reason..."
value={form.discount.reason}
onChange={(e) => setDiscount("reason", e.target.value)}
/>
</Field>
</div>
{Number(form.discount.value) > 0 && (
<p className="text-xs mt-2" style={{ color: "var(--text-muted)" }}>
Discount: {form.currency} {discountAmount.toFixed(2)}
</p>
)}
</SectionCard>
{/* 5. Payment */}
<SectionCard title="Payment">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
<Field label="Payment Status">
<select
className={inputClass}
style={inputStyle}
value={form.payment_status}
onChange={(e) => setField("payment_status", e.target.value)}
>
{PAYMENT_STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
</Field>
<Field label="Invoice Path (Nextcloud)">
<input
className={inputClass}
style={inputStyle}
placeholder="05_Customers/FOLDER/invoice.pdf"
value={form.invoice_path}
onChange={(e) => setField("invoice_path", e.target.value)}
/>
</Field>
</div>
</SectionCard>
{/* 6. Shipping */}
<SectionCard title="Shipping">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16, marginBottom: 16 }}>
<Field label="Method">
<input
className={inputClass}
style={inputStyle}
placeholder="e.g. courier, pickup"
value={form.shipping.method}
onChange={(e) => setShipping("method", e.target.value)}
/>
</Field>
<Field label="Carrier">
<input
className={inputClass}
style={inputStyle}
placeholder="e.g. ACS, DHL"
value={form.shipping.carrier}
onChange={(e) => setShipping("carrier", e.target.value)}
/>
</Field>
<Field label="Tracking Number">
<input
className={inputClass}
style={inputStyle}
value={form.shipping.tracking_number}
onChange={(e) => setShipping("tracking_number", e.target.value)}
/>
</Field>
<Field label="Destination" style={{ gridColumn: "1 / -1" }}>
<input
className={inputClass}
style={inputStyle}
placeholder="City, Country"
value={form.shipping.destination}
onChange={(e) => setShipping("destination", e.target.value)}
/>
</Field>
<Field label="Shipped At">
<input
type="date"
className={inputClass}
style={inputStyle}
value={form.shipping.shipped_at}
onChange={(e) => setShipping("shipped_at", e.target.value)}
/>
</Field>
<Field label="Delivered At">
<input
type="date"
className={inputClass}
style={inputStyle}
value={form.shipping.delivered_at}
onChange={(e) => setShipping("delivered_at", e.target.value)}
/>
</Field>
</div>
</SectionCard>
{/* 7. Notes */}
<SectionCard title="Notes">
<textarea
className={inputClass}
style={{ ...inputStyle, resize: "vertical", minHeight: 100 }}
placeholder="Internal notes..."
value={form.notes}
onChange={(e) => setField("notes", e.target.value)}
/>
</SectionCard>
{/* Actions */}
<div className="flex items-center gap-3">
<button
onClick={handleSave}
disabled={saving}
className="px-5 py-2 text-sm rounded-md cursor-pointer hover:opacity-90"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", opacity: saving ? 0.7 : 1 }}
>
{saving ? "Saving..." : isEdit ? "Save Changes" : "Create Order"}
</button>
{isEdit && (
<button
onClick={handleDelete}
className="px-4 py-2 text-sm rounded-md border cursor-pointer hover:opacity-80"
style={{ borderColor: "var(--danger)", color: "var(--danger-text)" }}
>
Delete Order
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,207 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import api from "../../api/client";
import { useAuth } from "../../auth/AuthContext";
const STATUS_COLORS = {
draft: { bg: "var(--bg-card-hover)", color: "var(--text-secondary)" },
confirmed: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" },
in_production: { bg: "#fff7ed", color: "#9a3412" },
shipped: { bg: "#f5f3ff", color: "#6d28d9" },
delivered: { bg: "var(--success-bg)", color: "var(--success-text)" },
cancelled: { bg: "var(--danger-bg)", color: "var(--danger-text)" },
};
const PAYMENT_COLORS = {
pending: { bg: "#fef9c3", color: "#854d0e" },
partial: { bg: "#fff7ed", color: "#9a3412" },
paid: { bg: "var(--success-bg)", color: "var(--success-text)" },
};
const inputStyle = {
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-primary)",
color: "var(--text-primary)",
};
const ORDER_STATUSES = ["draft", "confirmed", "in_production", "shipped", "delivered", "cancelled"];
const PAYMENT_STATUSES = ["pending", "partial", "paid"];
export default function OrderList() {
const [orders, setOrders] = useState([]);
const [customerMap, setCustomerMap] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [paymentFilter, setPaymentFilter] = useState("");
const [hoveredRow, setHoveredRow] = useState(null);
const navigate = useNavigate();
const { hasPermission } = useAuth();
const canEdit = hasPermission("crm", "edit");
useEffect(() => {
api.get("/crm/customers")
.then((data) => {
const map = {};
(data.customers || []).forEach((c) => { map[c.id] = c; });
setCustomerMap(map);
})
.catch(() => {});
}, []);
const fetchOrders = async () => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams();
if (statusFilter) params.set("status", statusFilter);
if (paymentFilter) params.set("payment_status", paymentFilter);
const qs = params.toString();
const data = await api.get(`/crm/orders${qs ? `?${qs}` : ""}`);
setOrders(data.orders || []);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchOrders();
}, [statusFilter, paymentFilter]);
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Orders</h1>
{canEdit && (
<button
onClick={() => navigate("/crm/orders/new")}
className="px-4 py-2 text-sm rounded-md hover:opacity-90 transition-colors cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
New Order
</button>
)}
</div>
<div className="flex gap-3 mb-4">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 text-sm rounded-md border"
style={inputStyle}
>
<option value="">All Statuses</option>
{ORDER_STATUSES.map((s) => (
<option key={s} value={s}>{s.replace("_", " ")}</option>
))}
</select>
<select
value={paymentFilter}
onChange={(e) => setPaymentFilter(e.target.value)}
className="px-3 py-2 text-sm rounded-md border"
style={inputStyle}
>
<option value="">All Payment Statuses</option>
{PAYMENT_STATUSES.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
{error && (
<div
className="text-sm rounded-md p-3 mb-4 border"
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
>
{error}
</div>
)}
{loading ? (
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>
) : orders.length === 0 ? (
<div
className="rounded-lg p-8 text-center text-sm border"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}
>
No orders found.
</div>
) : (
<div
className="rounded-lg overflow-hidden border"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Order #</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Customer</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Status</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Total</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Payment</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Date</th>
</tr>
</thead>
<tbody>
{orders.map((o, index) => {
const statusStyle = STATUS_COLORS[o.status] || STATUS_COLORS.draft;
const payStyle = PAYMENT_COLORS[o.payment_status] || PAYMENT_COLORS.pending;
const customer = customerMap[o.customer_id];
const customerName = customer
? customer.organization
? `${customer.name} / ${customer.organization}`
: customer.name
: o.customer_id || "—";
return (
<tr
key={o.id}
onClick={() => navigate(`/crm/orders/${o.id}`)}
className="cursor-pointer"
style={{
borderBottom: index < orders.length - 1 ? "1px solid var(--border-secondary)" : "none",
backgroundColor: hoveredRow === o.id ? "var(--bg-card-hover)" : "transparent",
}}
onMouseEnter={() => setHoveredRow(o.id)}
onMouseLeave={() => setHoveredRow(null)}
>
<td className="px-4 py-3 font-mono text-xs font-medium" style={{ color: "var(--text-heading)" }}>
{o.order_number}
</td>
<td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>{customerName}</td>
<td className="px-4 py-3">
<span
className="px-2 py-0.5 text-xs rounded-full capitalize"
style={{ backgroundColor: statusStyle.bg, color: statusStyle.color }}
>
{(o.status || "draft").replace("_", " ")}
</span>
</td>
<td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>
{o.currency || "EUR"} {Number(o.total_price || 0).toFixed(2)}
</td>
<td className="px-4 py-3">
<span
className="px-2 py-0.5 text-xs rounded-full capitalize"
style={{ backgroundColor: payStyle.bg, color: payStyle.color }}
>
{o.payment_status || "pending"}
</span>
</td>
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
{o.created_at ? new Date(o.created_at).toLocaleDateString() : "—"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,3 @@
export { default as OrderList } from "./OrderList";
export { default as OrderForm } from "./OrderForm";
export { default as OrderDetail } from "./OrderDetail";

View File

@@ -0,0 +1,635 @@
import { useState, useEffect, useRef } from "react";
import { useNavigate, useParams } from "react-router-dom";
import api from "../../api/client";
import { useAuth } from "../../auth/AuthContext";
const CATEGORY_LABELS = {
controller: "Controller",
striker: "Striker",
clock: "Clock",
part: "Part",
repair_service: "Repair / Service",
};
const CATEGORIES = Object.keys(CATEGORY_LABELS);
const STATUS_OPTIONS = [
{ value: "active", label: "Active", activeColor: "#31ee76", activeBg: "#14532d", inactiveBg: "var(--bg-input)", inactiveColor: "var(--text-secondary)" },
{ value: "discontinued", label: "Discontinued", activeColor: "#ef4444", activeBg: "#450a0a", inactiveBg: "var(--bg-input)", inactiveColor: "var(--text-secondary)" },
{ value: "planned", label: "Planned", activeColor: "#f59e0b", activeBg: "#451a03", inactiveBg: "var(--bg-input)", inactiveColor: "var(--text-secondary)" },
];
const defaultForm = {
name: "",
sku: "",
category: "controller",
description: "",
price: "",
currency: "EUR",
status: "active",
costs: {
labor_hours: "",
labor_rate: "",
items: [],
},
stock: {
on_hand: "",
reserved: "",
},
};
function numOr(v, fallback = 0) {
const n = parseFloat(v);
return isNaN(n) ? fallback : n;
}
function computeCostsTotal(costs, priceField = "price_last") {
const labor = numOr(costs.labor_hours) * numOr(costs.labor_rate);
const itemsTotal = (costs.items || []).reduce(
(sum, it) => sum + numOr(it.quantity, 1) * numOr(it[priceField] || it.price_last || it.price),
0
);
return labor + itemsTotal;
}
const inputClass = "w-full px-3 py-2 text-sm rounded-md border";
const inputStyle = {
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-primary)",
color: "var(--text-primary)",
};
function Field({ label, children }) {
return (
<div>
<label className="ui-form-label">{label}</label>
{children}
</div>
);
}
function SectionCard({ title, children, style }) {
return (
<div className="ui-section-card" style={style}>
{title && (
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__header-title">{title}</h2>
</div>
)}
{children}
</div>
);
}
export default function ProductForm() {
const { id } = useParams();
const isEdit = Boolean(id);
const navigate = useNavigate();
const { hasPermission } = useAuth();
const canEdit = hasPermission("crm", "edit");
const fileInputRef = useRef(null);
const [form, setForm] = useState(defaultForm);
const [loading, setLoading] = useState(isEdit);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [photoPreview, setPhotoPreview] = useState(null); // local blob URL for preview
const [photoFile, setPhotoFile] = useState(null); // File object pending upload
const [uploadingPhoto, setUploadingPhoto] = useState(false);
const [existingPhotoUrl, setExistingPhotoUrl] = useState(null);
useEffect(() => {
if (!isEdit) return;
api.get(`/crm/products/${id}`)
.then((data) => {
setForm({
name: data.name || "",
sku: data.sku || "",
category: data.category || "controller",
description: data.description || "",
price: data.price != null ? String(data.price) : "",
currency: data.currency || "EUR",
status: data.status || (data.active !== false ? "active" : "discontinued"),
costs: {
labor_hours: data.costs?.labor_hours != null ? String(data.costs.labor_hours) : "",
labor_rate: data.costs?.labor_rate != null ? String(data.costs.labor_rate) : "",
items: (data.costs?.items || []).map((it) => ({
name: it.name || "",
quantity: String(it.quantity ?? 1),
price_last: String(it.price_last ?? it.price ?? ""),
price_min: String(it.price_min ?? ""),
price_max: String(it.price_max ?? ""),
})),
},
stock: {
on_hand: data.stock?.on_hand != null ? String(data.stock.on_hand) : "",
reserved: data.stock?.reserved != null ? String(data.stock.reserved) : "",
},
});
if (data.photo_url) {
setExistingPhotoUrl(`/api${data.photo_url}`);
}
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [id, isEdit]);
const set = (field, value) => setForm((f) => ({ ...f, [field]: value }));
const setCost = (field, value) => setForm((f) => ({ ...f, costs: { ...f.costs, [field]: value } }));
const setStock = (field, value) => setForm((f) => ({ ...f, stock: { ...f.stock, [field]: value } }));
const addCostItem = () =>
setForm((f) => ({
...f,
costs: { ...f.costs, items: [...f.costs.items, { name: "", quantity: "1", price_last: "", price_min: "", price_max: "" }] },
}));
const removeCostItem = (i) =>
setForm((f) => ({
...f,
costs: { ...f.costs, items: f.costs.items.filter((_, idx) => idx !== i) },
}));
const setCostItem = (i, field, value) =>
setForm((f) => ({
...f,
costs: {
...f.costs,
items: f.costs.items.map((it, idx) => idx === i ? { ...it, [field]: value } : it),
},
}));
function handlePhotoChange(e) {
const file = e.target.files?.[0];
if (!file) return;
setPhotoFile(file);
setPhotoPreview(URL.createObjectURL(file));
}
const buildPayload = () => ({
name: form.name.trim(),
sku: form.sku.trim() || null,
category: form.category,
description: form.description.trim() || null,
price: form.price !== "" ? parseFloat(form.price) : null,
currency: form.currency,
status: form.status,
active: form.status === "active",
costs: {
labor_hours: numOr(form.costs.labor_hours),
labor_rate: numOr(form.costs.labor_rate),
items: form.costs.items
.filter((it) => it.name.trim())
.map((it) => ({
name: it.name.trim(),
quantity: numOr(it.quantity, 1),
price: numOr(it.price_last || it.price),
price_last: numOr(it.price_last || it.price),
price_min: numOr(it.price_min),
price_max: numOr(it.price_max),
})),
},
stock: {
on_hand: parseInt(form.stock.on_hand, 10) || 0,
reserved: parseInt(form.stock.reserved, 10) || 0,
},
});
const handleSave = async () => {
if (!form.name.trim()) {
setError("Product name is required.");
return;
}
setSaving(true);
setError("");
try {
let savedProduct;
if (isEdit) {
savedProduct = await api.put(`/crm/products/${id}`, buildPayload());
} else {
savedProduct = await api.post("/crm/products", buildPayload());
}
// Upload photo if a new one was selected
if (photoFile && savedProduct?.id) {
setUploadingPhoto(true);
const formData = new FormData();
formData.append("file", photoFile);
await fetch(`/api/crm/products/${savedProduct.id}/photo`, {
method: "POST",
headers: {
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
},
body: formData,
});
}
navigate("/crm/products");
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
setUploadingPhoto(false);
}
};
const handleDelete = async () => {
setSaving(true);
try {
await api.delete(`/crm/products/${id}`);
navigate("/crm/products");
} catch (err) {
setError(err.message);
setSaving(false);
}
};
const costsTotalEst = computeCostsTotal(form.costs, "price_last");
const costsTotalMin = computeCostsTotal(form.costs, "price_min");
const costsTotalMax = computeCostsTotal(form.costs, "price_max");
const price = parseFloat(form.price) || 0;
const marginEst = price - costsTotalEst;
const marginMin = price - costsTotalMax; // highest cost = lowest margin
const marginMax = price - costsTotalMin; // lowest cost = highest margin
const stockAvailable = (parseInt(form.stock.on_hand, 10) || 0) - (parseInt(form.stock.reserved, 10) || 0);
if (loading) {
return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
}
const currentPhoto = photoPreview || existingPhotoUrl;
return (
<div style={{ maxWidth: 1300, margin: "0 auto" }}>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
{isEdit ? "Edit Product" : "New Product"}
</h1>
<div className="flex items-center gap-2">
{isEdit && canEdit && !showDeleteConfirm && (
<button
type="button"
onClick={() => setShowDeleteConfirm(true)}
className="px-4 py-2 text-sm rounded-md border cursor-pointer hover:opacity-80"
style={{ borderColor: "var(--danger)", color: "var(--danger)", backgroundColor: "transparent" }}
>
Delete
</button>
)}
{isEdit && canEdit && showDeleteConfirm && (
<>
<span className="text-sm" style={{ color: "var(--text-secondary)" }}>Are you sure?</span>
<button
type="button"
onClick={handleDelete}
disabled={saving}
className="px-4 py-2 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ backgroundColor: "var(--danger)", color: "#fff", opacity: saving ? 0.7 : 1 }}
>
{saving ? "Deleting..." : "Yes, Delete"}
</button>
<button
type="button"
onClick={() => setShowDeleteConfirm(false)}
className="px-4 py-2 text-sm rounded-md border cursor-pointer"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
>
Cancel
</button>
</>
)}
{!showDeleteConfirm && (
<>
<button
onClick={() => navigate("/crm/products")}
className="px-4 py-2 text-sm rounded-md border cursor-pointer"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
>
Cancel
</button>
{canEdit && (
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 text-sm rounded-md hover:opacity-90 transition-colors cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", opacity: saving ? 0.7 : 1 }}
>
{saving ? (uploadingPhoto ? "Uploading photo..." : "Saving...") : "Save"}
</button>
)}
</>
)}
</div>
</div>
{error && (
<div
className="text-sm rounded-md p-3 mb-4 border"
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
>
{error}
</div>
)}
{/* 2-column layout */}
<div style={{ display: "flex", gap: 20, alignItems: "flex-start" }}>
{/* LEFT column */}
<div style={{ flex: "0 0 460px", display: "flex", flexDirection: "column", gap: 16 }}>
{/* Product Details */}
<SectionCard title="Product Details">
{/* Photo upload */}
<div style={{ display: "flex", gap: 16, alignItems: "flex-start", marginBottom: 16 }}>
<div
onClick={() => canEdit && fileInputRef.current?.click()}
style={{
width: 120, height: 120, borderRadius: 10, border: "2px dashed var(--border-primary)",
backgroundColor: "var(--bg-primary)", display: "flex", alignItems: "center",
justifyContent: "center", overflow: "hidden", flexShrink: 0,
cursor: canEdit ? "pointer" : "default", position: "relative",
}}
>
{currentPhoto ? (
<img src={currentPhoto} alt="" style={{ width: "100%", height: "100%", objectFit: "contain" }} />
) : (
<div style={{ textAlign: "center" }}>
<div style={{ fontSize: 24, opacity: 0.3 }}>📷</div>
<div style={{ fontSize: 10, color: "var(--text-muted)", marginTop: 4 }}>Photo</div>
</div>
)}
</div>
<div style={{ flex: 1 }}>
{canEdit && (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="px-3 py-1.5 text-xs rounded-md border cursor-pointer hover:opacity-80"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
>
{currentPhoto ? "Change Photo" : "Upload Photo"}
</button>
)}
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
style={{ display: "none" }}
onChange={handlePhotoChange}
/>
{photoFile && (
<div style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 6 }}>
{photoFile.name} will upload on save
</div>
)}
<div style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 6 }}>
JPG, PNG, or WebP. Stored on server.
</div>
</div>
</div>
{/* Name + SKU */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, marginBottom: 14 }}>
<Field label="Name *">
<input
className={inputClass}
style={inputStyle}
value={form.name}
onChange={(e) => set("name", e.target.value)}
placeholder="e.g. Vesper Plus"
/>
</Field>
<Field label="SKU">
<input
className={inputClass}
style={inputStyle}
value={form.sku}
onChange={(e) => set("sku", e.target.value)}
placeholder="e.g. VSP-001"
/>
</Field>
</div>
{/* Category */}
<div style={{ marginBottom: 14 }}>
<Field label="Category">
<select
className={inputClass}
style={inputStyle}
value={form.category}
onChange={(e) => set("category", e.target.value)}
>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{CATEGORY_LABELS[c]}</option>
))}
</select>
</Field>
</div>
{/* Description */}
<div style={{ marginBottom: 14 }}>
<Field label="Description">
<textarea
className={inputClass}
style={{ ...inputStyle, resize: "vertical", minHeight: 72 }}
value={form.description}
onChange={(e) => set("description", e.target.value)}
placeholder="Optional product description..."
/>
</Field>
</div>
{/* Status toggle — color coded */}
<div>
<div className="ui-form-label">Status</div>
<div style={{ display: "flex", borderRadius: 6, overflow: "hidden", border: "1px solid var(--border-primary)" }}>
{STATUS_OPTIONS.map((opt, idx) => {
const isActive = form.status === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => canEdit && set("status", opt.value)}
style={{
flex: 1, padding: "7px 0", fontSize: 12, fontWeight: 600,
border: "none", cursor: canEdit ? "pointer" : "default",
backgroundColor: isActive ? opt.activeBg : opt.inactiveBg,
color: isActive ? opt.activeColor : opt.inactiveColor,
borderRight: idx < STATUS_OPTIONS.length - 1 ? "1px solid var(--border-primary)" : "none",
transition: "background-color 0.15s",
}}
>
{opt.label}
</button>
);
})}
</div>
</div>
</SectionCard>
{/* Stock */}
<SectionCard title="Stock">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 14 }}>
<Field label="On Hand">
<input
type="number" min="0" step="1"
className={inputClass} style={inputStyle}
value={form.stock.on_hand}
onChange={(e) => setStock("on_hand", e.target.value)}
placeholder="0"
/>
</Field>
<Field label="Reserved">
<input
type="number" min="0" step="1"
className={inputClass} style={inputStyle}
value={form.stock.reserved}
onChange={(e) => setStock("reserved", e.target.value)}
placeholder="0"
/>
</Field>
<Field label="Available">
<div
className="px-3 py-2 text-sm rounded-md border"
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-primary)", color: "var(--text-heading)", fontWeight: 600 }}
>
{stockAvailable}
</div>
</Field>
</div>
</SectionCard>
</div>
{/* RIGHT column */}
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 16 }}>
{/* Pricing & Mfg. Costs */}
<SectionCard title="Pricing & Mfg. Costs">
{/* Price */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, marginBottom: 14 }}>
<Field label="Price (EUR)">
<input
type="number" min="0" step="0.01"
className={inputClass} style={inputStyle}
value={form.price}
onChange={(e) => set("price", e.target.value)}
onBlur={(e) => { const v = parseFloat(e.target.value); if (!isNaN(v)) set("price", v.toFixed(2)); }}
placeholder="0.00"
/>
</Field>
<div />
</div>
{/* Labor */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, marginBottom: 14 }}>
<Field label="Labor Hours">
<input type="number" min="0" step="0.5" className={inputClass} style={inputStyle}
value={form.costs.labor_hours} onChange={(e) => setCost("labor_hours", e.target.value)} placeholder="0" />
</Field>
<Field label="Labor Rate (€/hr)">
<input type="number" min="0" step="0.01" className={inputClass} style={inputStyle}
value={form.costs.labor_rate} onChange={(e) => setCost("labor_rate", e.target.value)} placeholder="0.00" />
</Field>
</div>
{/* Cost line items */}
{form.costs.items.length > 0 && (
<div style={{ marginBottom: 8 }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr 72px 85px 85px 85px 28px", gap: 6, marginBottom: 4 }}>
<div className="ui-form-label" style={{ marginBottom: 0 }}>Item Name</div>
<div className="ui-form-label" style={{ marginBottom: 0, textAlign: "center" }}>QTY</div>
<div className="ui-form-label" style={{ marginBottom: 0, textAlign: "center" }}>Min ()</div>
<div className="ui-form-label" style={{ marginBottom: 0, textAlign: "center" }}>Max ()</div>
<div className="ui-form-label" style={{ marginBottom: 0, textAlign: "center" }}>Est. ()</div>
<div />
</div>
{form.costs.items.map((it, i) => (
<div key={i} style={{ display: "grid", gridTemplateColumns: "1fr 72px 85px 85px 85px 28px", gap: 6, marginBottom: 6 }}>
<input className={inputClass} style={inputStyle} value={it.name}
onChange={(e) => setCostItem(i, "name", e.target.value)} placeholder="e.g. PCB" />
<input type="number" min="0" step="1" className={inputClass}
style={{ ...inputStyle, textAlign: "center" }}
value={it.quantity}
onChange={(e) => setCostItem(i, "quantity", e.target.value)}
onBlur={(e) => { const v = parseFloat(e.target.value); if (!isNaN(v)) setCostItem(i, "quantity", v.toFixed(2)); }}
placeholder="1" />
<input type="number" min="0" step="0.01" className={inputClass}
style={{ ...inputStyle, textAlign: "center" }}
value={it.price_min}
onChange={(e) => setCostItem(i, "price_min", e.target.value)}
onBlur={(e) => { const v = parseFloat(e.target.value); if (!isNaN(v)) setCostItem(i, "price_min", v.toFixed(2)); }}
placeholder="0.00" title="Minimum expected price" />
<input type="number" min="0" step="0.01" className={inputClass}
style={{ ...inputStyle, textAlign: "center" }}
value={it.price_max}
onChange={(e) => setCostItem(i, "price_max", e.target.value)}
onBlur={(e) => { const v = parseFloat(e.target.value); if (!isNaN(v)) setCostItem(i, "price_max", v.toFixed(2)); }}
placeholder="0.00" title="Maximum expected price" />
<input type="number" min="0" step="0.01" className={inputClass}
style={{ ...inputStyle, textAlign: "center" }}
value={it.price_last}
onChange={(e) => setCostItem(i, "price_last", e.target.value)}
onBlur={(e) => { const v = parseFloat(e.target.value); if (!isNaN(v)) setCostItem(i, "price_last", v.toFixed(2)); }}
placeholder="0.00" title="Estimated / last paid price" />
<button type="button" onClick={() => removeCostItem(i)}
className="flex items-center justify-center text-base cursor-pointer hover:opacity-70"
style={{ color: "var(--danger)", background: "none", border: "none", padding: 0 }}>
×
</button>
</div>
))}
</div>
)}
<button type="button" onClick={addCostItem}
className="px-3 py-1.5 text-xs rounded-md border cursor-pointer hover:opacity-80 mb-4"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}>
+ Add Cost Item
</button>
{/* Summary rows */}
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{/* Mfg. Cost Total — three values */}
<div className="px-3 py-2 rounded-md text-sm"
style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-secondary)" }}>
<div className="flex items-center justify-between">
<span>Mfg. Cost Total</span>
<div className="flex items-center gap-2 font-mono text-xs">
<span style={{ color: "var(--text-more-muted)" }} title="MinMax cost range">
{costsTotalMin.toFixed(2)} {costsTotalMax.toFixed(2)}
</span>
<span style={{ color: "var(--border-primary)" }}>|</span>
<span className="font-semibold" style={{ color: "var(--text-heading)" }} title="Estimated cost">
est. {costsTotalEst.toFixed(2)}
</span>
</div>
</div>
</div>
{/* Margin — three values */}
<div className="px-3 py-2 rounded-md text-sm"
style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-secondary)" }}>
<div className="flex items-center justify-between">
<span>Margin (Price Cost)</span>
{form.price ? (
<div className="flex items-center gap-2 font-mono text-xs">
<span style={{ color: (marginMin >= 0 && marginMax >= 0) ? "var(--text-more-muted)" : "var(--danger-text)" }} title="Margin range (minmax cost)">
{marginMax.toFixed(2)} {marginMin.toFixed(2)}
</span>
<span style={{ color: "var(--border-primary)" }}>|</span>
<span className="font-semibold" style={{ color: marginEst >= 0 ? "var(--success-text)" : "var(--danger-text)" }} title="Estimated margin">
est. {marginEst.toFixed(2)}
</span>
</div>
) : (
<span className="font-mono font-semibold" style={{ color: "var(--text-muted)" }}></span>
)}
</div>
</div>
</div>
</SectionCard>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,215 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import api from "../../api/client";
import { useAuth } from "../../auth/AuthContext";
const CATEGORY_LABELS = {
controller: "Controller",
striker: "Striker",
clock: "Clock",
part: "Part",
repair_service: "Repair / Service",
};
const CATEGORIES = Object.keys(CATEGORY_LABELS);
export default function ProductList() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [search, setSearch] = useState("");
const [categoryFilter, setCategoryFilter] = useState("");
const [hoveredRow, setHoveredRow] = useState(null);
const navigate = useNavigate();
const { hasPermission } = useAuth();
const canEdit = hasPermission("crm", "edit");
const fetchProducts = async () => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams();
if (categoryFilter) params.set("category", categoryFilter);
const qs = params.toString();
const data = await api.get(`/crm/products${qs ? `?${qs}` : ""}`);
setProducts(data.products);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchProducts();
}, [categoryFilter]);
const filtered = search
? products.filter(
(p) =>
(p.name || "").toLowerCase().includes(search.toLowerCase()) ||
(p.sku || "").toLowerCase().includes(search.toLowerCase())
)
: products;
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
Products
</h1>
{canEdit && (
<button
onClick={() => navigate("/crm/products/new")}
className="px-4 py-2 text-sm rounded-md hover:opacity-90 transition-colors cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
New Product
</button>
)}
</div>
{/* Filters */}
<div className="flex gap-3 mb-4">
<input
type="text"
placeholder="Search by name or SKU..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 px-3 py-2 text-sm rounded-md border"
style={{
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-primary)",
color: "var(--text-primary)",
}}
/>
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="px-3 py-2 text-sm rounded-md border"
style={{
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-primary)",
color: "var(--text-primary)",
}}
>
<option value="">All Categories</option>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{CATEGORY_LABELS[c]}</option>
))}
</select>
</div>
{error && (
<div
className="text-sm rounded-md p-3 mb-4 border"
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
>
{error}
</div>
)}
{loading ? (
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>
) : filtered.length === 0 ? (
<div
className="rounded-lg p-8 text-center text-sm border"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}
>
No products found.
</div>
) : (
<div
className="rounded-lg overflow-hidden border"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
<th className="px-3 py-3" style={{ width: 48 }} />
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Name</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>SKU</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Category</th>
<th className="px-4 py-3 text-right font-medium" style={{ color: "var(--text-secondary)" }}>Price</th>
<th className="px-4 py-3 text-right font-medium" style={{ color: "var(--text-secondary)" }}>Mfg. Cost</th>
<th className="px-4 py-3 text-right font-medium" style={{ color: "var(--text-secondary)" }}>Margin</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Stock</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Status</th>
</tr>
</thead>
<tbody>
{filtered.map((p, index) => {
const price = p.price != null ? Number(p.price) : null;
const mfgCost = p.costs?.total != null ? Number(p.costs.total) : null;
const margin = price != null && mfgCost != null ? price - mfgCost : null;
return (
<tr
key={p.id}
onClick={() => navigate(`/crm/products/${p.id}`)}
className="cursor-pointer"
style={{
borderBottom: index < filtered.length - 1 ? "1px solid var(--border-primary)" : "none",
backgroundColor: hoveredRow === p.id ? "var(--bg-card-hover)" : "transparent",
}}
onMouseEnter={() => setHoveredRow(p.id)}
onMouseLeave={() => setHoveredRow(null)}
>
<td className="px-3 py-2" style={{ width: 48 }}>
{p.photo_url ? (
<img
src={`/api${p.photo_url}`}
alt=""
style={{ width: 40, height: 40, borderRadius: 6, objectFit: "contain", border: "1px solid var(--border-primary)" }}
/>
) : (
<div style={{ width: 40, height: 40, borderRadius: 6, backgroundColor: "var(--bg-card-hover)", border: "1px solid var(--border-primary)", display: "flex", alignItems: "center", justifyContent: "center" }}>
<span style={{ fontSize: 18, opacity: 0.4 }}>📦</span>
</div>
)}
</td>
<td className="px-4 py-3 font-medium" style={{ color: "var(--text-heading)" }}>{p.name}</td>
<td className="px-4 py-3 font-mono text-xs" style={{ color: "var(--text-muted)" }}>{p.sku || "—"}</td>
<td className="px-4 py-3">
<span
className="px-2 py-0.5 text-xs rounded-full"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
{CATEGORY_LABELS[p.category] || p.category}
</span>
</td>
<td className="px-4 py-3 text-right" style={{ color: "var(--text-primary)" }}>
{price != null ? `${price.toFixed(2)}` : "—"}
</td>
<td className="px-4 py-3 text-right" style={{ color: "var(--text-muted)" }}>
{mfgCost != null ? `${mfgCost.toFixed(2)}` : "—"}
</td>
<td className="px-4 py-3 text-right" style={{ color: margin != null ? (margin >= 0 ? "var(--success-text)" : "var(--danger-text)") : "var(--text-muted)" }}>
{margin != null ? `${margin.toFixed(2)}` : "—"}
</td>
<td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>
{p.stock ? p.stock.available : "—"}
</td>
<td className="px-4 py-3">
<span
className="px-2 py-0.5 text-xs rounded-full"
style={
p.active
? { backgroundColor: "var(--success-bg)", color: "var(--success-text)" }
: { backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }
}
>
{p.active ? "Active" : "Inactive"}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { default as ProductList } from "./ProductList";
export { default as ProductForm } from "./ProductForm";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,438 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useNavigate } from "react-router-dom";
import api from "../../api/client";
const STATUS_STYLES = {
draft: { bg: "var(--bg-card-hover)", color: "var(--text-secondary)" },
sent: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" },
accepted: { bg: "var(--success-bg)", color: "var(--success-text)" },
rejected: { bg: "var(--danger-bg)", color: "var(--danger-text)" },
};
function fmt(n) {
const f = parseFloat(n) || 0;
return f.toLocaleString("el-GR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " €";
}
function fmtDate(iso) {
if (!iso) return "—";
return iso.slice(0, 10);
}
// ── PDF thumbnail via PDF.js ──────────────────────────────────────────────────
function loadPdfJs() {
return new Promise((res, rej) => {
if (window.pdfjsLib) { res(); return; }
if (document.getElementById("__pdfjs2__")) {
// Script already injected — wait for it
const check = setInterval(() => {
if (window.pdfjsLib) { clearInterval(check); res(); }
}, 50);
return;
}
const s = document.createElement("script");
s.id = "__pdfjs2__";
s.src = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js";
s.onload = () => {
window.pdfjsLib.GlobalWorkerOptions.workerSrc =
"https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
res();
};
s.onerror = rej;
document.head.appendChild(s);
});
}
function PdfThumbnail({ quotationId, onClick }) {
const canvasRef = useRef(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
try {
await loadPdfJs();
const token = localStorage.getItem("access_token");
const url = `/api/crm/quotations/${quotationId}/pdf`;
const loadingTask = window.pdfjsLib.getDocument({
url,
httpHeaders: token ? { Authorization: `Bearer ${token}` } : {},
});
const pdf = await loadingTask.promise;
if (cancelled) return;
const page = await pdf.getPage(1);
if (cancelled) return;
const canvas = canvasRef.current;
if (!canvas) return;
const viewport = page.getViewport({ scale: 1 });
const scale = Math.min(72 / viewport.width, 96 / viewport.height);
const scaled = page.getViewport({ scale });
canvas.width = scaled.width;
canvas.height = scaled.height;
await page.render({ canvasContext: canvas.getContext("2d"), viewport: scaled }).promise;
} catch {
if (!cancelled) setFailed(true);
}
})();
return () => { cancelled = true; };
}, [quotationId]);
const style = {
width: 72,
height: 96,
borderRadius: 4,
overflow: "hidden",
flexShrink: 0,
cursor: "pointer",
border: "1px solid var(--border-primary)",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "var(--bg-primary)",
};
if (failed) {
return (
<div style={style} onClick={onClick} title="Open PDF">
<span style={{ fontSize: 28 }}>📑</span>
</div>
);
}
return (
<div style={style} onClick={onClick} title="Open PDF">
<canvas ref={canvasRef} style={{ width: "100%", height: "100%", objectFit: "contain", display: "block" }} />
</div>
);
}
function DraftThumbnail() {
return (
<div style={{
width: 72, height: 96, borderRadius: 4, flexShrink: 0,
border: "1px dashed var(--border-primary)",
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
backgroundColor: "var(--bg-primary)", gap: 4,
}}>
<span style={{ fontSize: 18 }}>📄</span>
<span style={{ fontSize: 9, fontWeight: 700, color: "var(--text-muted)", letterSpacing: "0.06em", textTransform: "uppercase" }}>
DRAFT
</span>
</div>
);
}
function PdfViewModal({ quotationId, quotationNumber, onClose }) {
const [blobUrl, setBlobUrl] = useState(null);
const [loadingPdf, setLoadingPdf] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
let objectUrl = null;
const token = localStorage.getItem("access_token");
fetch(`/api/crm/quotations/${quotationId}/pdf`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then(r => {
if (!r.ok) throw new Error("Failed to load PDF");
return r.blob();
})
.then(blob => {
objectUrl = URL.createObjectURL(blob);
setBlobUrl(objectUrl);
})
.catch(() => setError(true))
.finally(() => setLoadingPdf(false));
return () => { if (objectUrl) URL.revokeObjectURL(objectUrl); };
}, [quotationId]);
return (
<div
onClick={onClose}
style={{
position: "fixed", inset: 0, zIndex: 1000,
backgroundColor: "rgba(0,0,0,0.88)",
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
}}
>
<div
onClick={e => e.stopPropagation()}
style={{
backgroundColor: "var(--bg-card)", borderRadius: 10, overflow: "hidden",
width: "80vw", height: "88vh", display: "flex", flexDirection: "column",
boxShadow: "0 24px 64px rgba(0,0,0,0.5)",
}}
>
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "10px 16px", borderBottom: "1px solid var(--border-primary)", flexShrink: 0,
}}>
<span style={{ fontSize: 13, fontWeight: 600, color: "var(--text-heading)" }}>{quotationNumber}</span>
<div style={{ display: "flex", gap: 8 }}>
{blobUrl && (
<a
href={blobUrl}
download={`${quotationNumber}.pdf`}
style={{ padding: "4px 12px", fontSize: 12, borderRadius: 6, textDecoration: "none", backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
Download
</a>
)}
<button
onClick={onClose}
style={{ background: "none", border: "none", color: "var(--text-muted)", fontSize: 22, cursor: "pointer", lineHeight: 1, padding: "0 4px" }}
>×</button>
</div>
</div>
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>
{loadingPdf && <span style={{ color: "var(--text-muted)", fontSize: 13 }}>Loading PDF...</span>}
{error && <span style={{ color: "var(--danger-text)", fontSize: 13 }}>Failed to load PDF.</span>}
{blobUrl && (
<iframe
src={blobUrl}
style={{ width: "100%", height: "100%", border: "none" }}
title={quotationNumber}
/>
)}
</div>
</div>
</div>
);
}
export default function QuotationList({ customerId, onSend }) {
const navigate = useNavigate();
const [quotations, setQuotations] = useState([]);
const [loading, setLoading] = useState(true);
const [deleting, setDeleting] = useState(null);
const [regenerating, setRegenerating] = useState(null);
const [pdfPreview, setPdfPreview] = useState(null); // { id, number }
const load = useCallback(async () => {
if (!customerId) return;
setLoading(true);
try {
const res = await api.get(`/crm/quotations/customer/${customerId}`);
setQuotations(res.quotations || []);
} catch {
setQuotations([]);
} finally {
setLoading(false);
}
}, [customerId]);
useEffect(() => { load(); }, [load]);
async function handleDelete(q) {
if (!window.confirm(`Delete quotation ${q.quotation_number}? This cannot be undone.`)) return;
setDeleting(q.id);
try {
await api.delete(`/crm/quotations/${q.id}`);
setQuotations(prev => prev.filter(x => x.id !== q.id));
} catch {
alert("Failed to delete quotation");
} finally {
setDeleting(null);
}
}
async function handleRegenerate(q) {
setRegenerating(q.id);
try {
const updated = await api.post(`/crm/quotations/${q.id}/regenerate-pdf`);
setQuotations(prev => prev.map(x => x.id === updated.id ? {
...x,
nextcloud_pdf_url: updated.nextcloud_pdf_url,
} : x));
} catch {
alert("PDF regeneration failed");
} finally {
setRegenerating(null);
}
}
function openPdfModal(q) {
setPdfPreview({ id: q.id, number: q.quotation_number });
}
// Grid columns: thumbnail | number | title | date | status | total | actions
const GRID = "90px 120px minmax(0,1fr) 130px 130px 130px 120px";
return (
<div>
{/* PDF Preview Modal */}
{pdfPreview && (
<PdfViewModal
quotationId={pdfPreview.id}
quotationNumber={pdfPreview.number}
onClose={() => setPdfPreview(null)}
/>
)}
{/* Header */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
<h2 style={{ fontSize: 15, fontWeight: 600, color: "var(--text-heading)" }}>Quotations</h2>
<button
onClick={() => navigate(`/crm/quotations/new?customer_id=${customerId}`)}
style={{
padding: "7px 16px", fontSize: 13, fontWeight: 600, borderRadius: 6,
border: "none", cursor: "pointer", backgroundColor: "var(--accent)", color: "#fff",
}}
>
+ New Quotation
</button>
</div>
{loading && (
<div style={{ textAlign: "center", padding: 40, color: "var(--text-muted)", fontSize: 13 }}>Loading...</div>
)}
{!loading && quotations.length === 0 && (
<div style={{ textAlign: "center", padding: "40px 20px", backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)", borderRadius: 8 }}>
<div style={{ fontSize: 32, marginBottom: 10 }}>📄</div>
<div style={{ fontSize: 14, fontWeight: 500, color: "var(--text-heading)", marginBottom: 6 }}>No quotations yet</div>
<div style={{ fontSize: 13, color: "var(--text-muted)", marginBottom: 16 }}>
Create a quotation to generate a professional PDF offer for this customer.
</div>
<button
onClick={() => navigate(`/crm/quotations/new?customer_id=${customerId}`)}
style={{ padding: "8px 20px", fontSize: 13, fontWeight: 600, borderRadius: 6, border: "none", cursor: "pointer", backgroundColor: "var(--accent)", color: "#fff" }}
>
+ New Quotation
</button>
</div>
)}
{!loading && quotations.length > 0 && (
<div style={{ borderRadius: 8, border: "1px solid var(--border-primary)", overflow: "hidden" }}>
{/* Table header */}
<div style={{
display: "grid",
gridTemplateColumns: GRID,
backgroundColor: "var(--bg-card)",
borderBottom: "1px solid var(--border-primary)",
padding: "8px 16px",
alignItems: "center",
gap: 12,
}}>
<div />
{[
{ label: "Number", align: "left" },
{ label: "Title", align: "left" },
{ label: "Date", align: "center" },
{ label: "Status", align: "center" },
{ label: "Total", align: "right", paddingRight: 16 },
{ label: "Actions", align: "center" },
].map(({ label, align, paddingRight }) => (
<div key={label} style={{ fontSize: 11, fontWeight: 600, color: "var(--text-muted)", textTransform: "uppercase", letterSpacing: "0.04em", textAlign: align, paddingRight }}>
{label}
</div>
))}
</div>
{/* Rows */}
{quotations.map(q => (
<div
key={q.id}
style={{
display: "grid",
gridTemplateColumns: GRID,
gap: 12,
padding: "12px 16px",
borderBottom: "1px solid var(--border-secondary)",
alignItems: "center",
minHeight: 110,
backgroundColor: "var(--bg-card)",
}}
onMouseEnter={e => e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"}
onMouseLeave={e => e.currentTarget.style.backgroundColor = "var(--bg-card)"}
>
{/* Thumbnail — click opens modal if PDF exists */}
<div>
{q.nextcloud_pdf_url ? (
<PdfThumbnail quotationId={q.id} onClick={() => openPdfModal(q)} />
) : (
<DraftThumbnail />
)}
</div>
{/* Number */}
<div style={{ fontFamily: "monospace", fontSize: 13, fontWeight: 600, color: "var(--text-primary)" }}>
{q.quotation_number}
</div>
{/* Title + subtitle */}
<div style={{ overflow: "hidden", paddingRight: 8 }}>
<div style={{ fontSize: 16, fontWeight: 600, color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{q.title || <span style={{ color: "var(--text-muted)", fontStyle: "italic", fontWeight: 400 }}>Untitled</span>}
</div>
{q.subtitle && (
<div style={{ fontSize: 12, color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", marginTop: 2 }}>
{q.subtitle}
</div>
)}
</div>
{/* Date */}
<div style={{ fontSize: 12, color: "var(--text-secondary)", textAlign: "center" }}>
{fmtDate(q.created_at)}
</div>
{/* Status badge */}
<div style={{ textAlign: "center" }}>
<span style={{
display: "inline-block", padding: "2px 10px", borderRadius: 20,
fontSize: 11, fontWeight: 600,
backgroundColor: STATUS_STYLES[q.status]?.bg || "var(--bg-card-hover)",
color: STATUS_STYLES[q.status]?.color || "var(--text-secondary)",
}}>
{q.status}
</span>
</div>
{/* Total */}
<div style={{ fontSize: 16, fontWeight: 600, color: "var(--success-text)", textAlign: "right", paddingRight: 16 }}>
{fmt(q.final_total)}
</div>
{/* Actions — Edit + Delete same width; Gen PDF if no PDF yet */}
<div style={{ display: "flex", flexDirection: "column", gap: 5, alignItems: "stretch", paddingLeft: 25, paddingRight: 25 }}>
{!q.nextcloud_pdf_url && (
<button
onClick={() => handleRegenerate(q)}
disabled={regenerating === q.id}
style={{ padding: "4px 10px", fontSize: 11, fontWeight: 500, borderRadius: 5, border: "1px solid var(--border-primary)", cursor: "pointer", backgroundColor: "transparent", color: "var(--text-muted)", whiteSpace: "nowrap", textAlign: "center" }}
>
{regenerating === q.id ? "..." : "Gen PDF"}
</button>
)}
<button
onClick={() => navigate(`/crm/quotations/${q.id}`)}
style={{ padding: "4px 10px", fontSize: 11, fontWeight: 500, borderRadius: 5, border: "1px solid var(--border-primary)", cursor: "pointer", backgroundColor: "transparent", color: "var(--text-secondary)", whiteSpace: "nowrap", textAlign: "center" }}
>
Edit
</button>
<button
onClick={() => onSend && onSend(q)}
style={{ padding: "4px 10px", fontSize: 11, fontWeight: 500, borderRadius: 5, border: "1px solid var(--border-primary)", cursor: "pointer", backgroundColor: "transparent", color: "var(--accent)", whiteSpace: "nowrap", textAlign: "center" }}
>
Send
</button>
<button
onClick={() => handleDelete(q)}
disabled={deleting === q.id}
style={{ padding: "4px 10px", fontSize: 11, fontWeight: 500, borderRadius: 5, border: "1px solid var(--border-primary)", cursor: "pointer", backgroundColor: "transparent", color: "var(--danger-text)", whiteSpace: "nowrap", textAlign: "center" }}
>
{deleting === q.id ? "..." : "Delete"}
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { default as QuotationForm } from "./QuotationForm";
export { default as QuotationList } from "./QuotationList";

File diff suppressed because it is too large Load Diff

View File

@@ -1599,6 +1599,7 @@ export default function DeviceDetail() {
const [liveStrikeCounters, setLiveStrikeCounters] = useState(null);
const [requestingStrikeCounters, setRequestingStrikeCounters] = useState(false);
const lastStrikeRequestAtRef = useRef(0);
const [hwProduct, setHwProduct] = useState(null);
// --- Section edit modal open/close state ---
const [editingLocation, setEditingLocation] = useState(false);
@@ -1641,6 +1642,25 @@ export default function DeviceDetail() {
setDeviceUsers([]);
}).finally(() => setUsersLoading(false));
// Fetch manufacturing record + product catalog to resolve hw image
if (d.device_id) {
Promise.all([
api.get(`/manufacturing/devices/${d.device_id}`).catch(() => null),
api.get("/crm/products").catch(() => null),
]).then(([mfgItem, productsRes]) => {
const hwType = mfgItem?.hw_type || "";
if (!hwType) return;
const products = productsRes?.products || [];
const norm = (s) => (s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
const normHw = norm(hwType);
const match = products.find(
(p) => norm(p.name) === normHw || norm(p.sku) === normHw ||
norm(p.name).includes(normHw) || normHw.includes(norm(p.name))
);
if (match) setHwProduct(match);
}).catch(() => {});
}
api.get(`/equipment/notes?device_id=${id}`).then((data) => {
const issues = (data.notes || []).filter(
(n) => (n.category === "issue" || n.category === "action_item") && n.status !== "completed"
@@ -1809,9 +1829,8 @@ export default function DeviceDetail() {
: null;
const randomPlaybacks = playbackPlaceholderForId(id || device.device_id || device.id);
const hwImageMap = { VesperPlus: "/devices/VesperPlus.png" };
const hwVariant = "VesperPlus";
const hwImage = hwImageMap[hwVariant] || hwImageMap.VesperPlus;
const hwVariant = hwProduct?.name || "VesperPlus";
const hwImage = hwProduct?.photo_url ? `/api${hwProduct.photo_url}` : "/devices/VesperPlus.png";
const locationCard = (
<section className="device-section-card">

View File

@@ -250,16 +250,13 @@ export default function DeviceForm() {
{/* ===== Left Column ===== */}
<div className="space-y-6">
{/* --- Basic Info --- */}
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Basic Information
</h2>
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__header-title">Basic Information</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Device Name *
</label>
<input
@@ -271,7 +268,7 @@ export default function DeviceForm() {
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Location
</label>
<input
@@ -283,7 +280,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Location Coordinates
</label>
<input
@@ -295,7 +292,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Device Photo URL
</label>
<input
@@ -320,13 +317,10 @@ export default function DeviceForm() {
</section>
{/* --- Device Attributes --- */}
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Device Attributes
</h2>
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__header-title">Device Attributes</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-wrap gap-4 md:col-span-2">
<label className="flex items-center gap-2">
@@ -385,7 +379,7 @@ export default function DeviceForm() {
</label>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Total Bells
</label>
<input
@@ -397,7 +391,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Device Locale
</label>
<select
@@ -413,7 +407,7 @@ export default function DeviceForm() {
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Bell Outputs (comma-separated)
</label>
<input
@@ -425,7 +419,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Hammer Timings (comma-separated)
</label>
<input
@@ -437,7 +431,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Serial Log Level
</label>
<input
@@ -449,7 +443,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
SD Log Level
</label>
<input
@@ -464,16 +458,13 @@ export default function DeviceForm() {
</section>
{/* --- Network Settings --- */}
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Network Settings
</h2>
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__header-title">Network Settings</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Hostname
</label>
<input
@@ -496,7 +487,7 @@ export default function DeviceForm() {
</label>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
WebSocket URL
</label>
<input
@@ -507,7 +498,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Church Assistant URL
</label>
<input
@@ -524,16 +515,13 @@ export default function DeviceForm() {
{/* ===== Right Column ===== */}
<div className="space-y-6">
{/* --- Subscription --- */}
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Subscription
</h2>
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__header-title">Subscription</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Tier
</label>
<select
@@ -549,7 +537,7 @@ export default function DeviceForm() {
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Start Date
</label>
<input
@@ -560,7 +548,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Duration (months)
</label>
<input
@@ -572,7 +560,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Max Users
</label>
<input
@@ -584,7 +572,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Max Outputs
</label>
<input
@@ -599,16 +587,13 @@ export default function DeviceForm() {
</section>
{/* --- Clock Settings --- */}
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Clock Settings
</h2>
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__header-title">Clock Settings</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Ring Alerts
</label>
<select
@@ -624,7 +609,7 @@ export default function DeviceForm() {
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Ring Intervals
</label>
<input
@@ -645,7 +630,7 @@ export default function DeviceForm() {
<span className="text-sm" style={{ color: "var(--text-primary)" }}>Ring Alerts Master On</span>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Clock Outputs (comma-separated)
</label>
<input
@@ -656,7 +641,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Clock Timings (comma-separated)
</label>
<input
@@ -667,7 +652,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Hour Alerts Bell
</label>
<input
@@ -679,7 +664,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Half-hour Alerts Bell
</label>
<input
@@ -691,7 +676,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Quarter Alerts Bell
</label>
<input
@@ -703,7 +688,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Backlight Output
</label>
<input
@@ -788,16 +773,13 @@ export default function DeviceForm() {
</section>
{/* --- Statistics --- */}
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Statistics & Warranty
</h2>
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__header-title">Statistics & Warranty</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Total Playbacks
</label>
<input
@@ -809,7 +791,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Total Hammer Strikes
</label>
<input
@@ -821,7 +803,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Total Warnings Given
</label>
<input
@@ -833,7 +815,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Per-Bell Strikes (comma-separated)
</label>
<input
@@ -853,7 +835,7 @@ export default function DeviceForm() {
<span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>Warranty Active</span>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Warranty Start
</label>
<input
@@ -864,7 +846,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Warranty Period (months)
</label>
<input
@@ -876,7 +858,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Last Maintained On
</label>
<input
@@ -887,7 +869,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Maintenance Period (months)
</label>
<input

View File

@@ -20,12 +20,7 @@ const categoryStyle = (cat) => {
function Field({ label, children }) {
return (
<div>
<dt
className="text-xs font-medium uppercase tracking-wide"
style={{ color: "var(--text-muted)" }}
>
{label}
</dt>
<dt className="ui-field-label">{label}</dt>
<dd className="mt-1 text-sm" style={{ color: "var(--text-primary)" }}>
{children || "-"}
</dd>
@@ -158,16 +153,10 @@ export default function NoteDetail() {
{/* Left Column */}
<div className="space-y-6">
{/* Note Content */}
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2
className="text-lg font-semibold mb-4"
style={{ color: "var(--text-heading)" }}
>
Content
</h2>
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__header-title">Content</h2>
</div>
<div
className="text-sm whitespace-pre-wrap"
style={{ color: "var(--text-primary)" }}
@@ -177,16 +166,10 @@ export default function NoteDetail() {
</section>
{/* Timestamps */}
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2
className="text-lg font-semibold mb-4"
style={{ color: "var(--text-heading)" }}
>
Details
</h2>
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__header-title">Details</h2>
</div>
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
<Field label="Category">
<span
@@ -211,16 +194,10 @@ export default function NoteDetail() {
{/* Right Column */}
<div className="space-y-6">
{/* Linked Device */}
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2
className="text-lg font-semibold mb-4"
style={{ color: "var(--text-heading)" }}
>
Linked Device
</h2>
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__header-title">Linked Device</h2>
</div>
{note.device_id ? (
<div className="flex items-center justify-between">
<div>
@@ -247,16 +224,10 @@ export default function NoteDetail() {
</section>
{/* Linked User */}
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2
className="text-lg font-semibold mb-4"
style={{ color: "var(--text-heading)" }}
>
Linked User
</h2>
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__header-title">Linked User</h2>
</div>
{note.user_id ? (
<div className="flex items-center justify-between">
<div>

View File

@@ -132,16 +132,13 @@ export default function NoteForm() {
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Left Column — Note Content */}
<div className="space-y-6">
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Note Details
</h2>
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__header-title">Note Details</h2>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Title *
</label>
<input
@@ -159,7 +156,7 @@ export default function NoteForm() {
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Content *
</label>
<textarea
@@ -178,7 +175,7 @@ export default function NoteForm() {
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Category
</label>
<select
@@ -204,16 +201,13 @@ export default function NoteForm() {
{/* Right Column — Associations */}
<div className="space-y-6">
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Link To
</h2>
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__header-title">Link To</h2>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Device (optional)
</label>
<select
@@ -235,7 +229,7 @@ export default function NoteForm() {
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
User (optional)
</label>
<select

View File

@@ -3,9 +3,13 @@ import { useAuth } from "../auth/AuthContext";
import api from "../api/client";
const BOARD_TYPES = [
{ value: "vs", label: "Vesper (VS)" },
{ value: "vp", label: "Vesper+ (VP)" },
{ value: "vx", label: "VesperPro (VX)" },
{ value: "vesper", label: "Vesper" },
{ value: "vesper_plus", label: "Vesper+" },
{ value: "vesper_pro", label: "Vesper Pro" },
{ value: "chronos", label: "Chronos" },
{ value: "chronos_pro", label: "Chronos Pro" },
{ value: "agnus", label: "Agnus" },
{ value: "agnus_mini", label: "Agnus Mini" },
];
const CHANNELS = ["stable", "beta", "alpha", "testing"];
@@ -29,6 +33,24 @@ function ChannelBadge({ channel }) {
);
}
const UPDATE_TYPE_STYLES = {
mandatory: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)", label: "Mandatory" },
emergency: { bg: "var(--danger-bg)", color: "var(--danger-text)", label: "Emergency" },
optional: { bg: "var(--bg-card-hover)", color: "var(--text-muted)", label: "Optional" },
};
function UpdateTypeBadge({ type }) {
const style = UPDATE_TYPE_STYLES[type] || UPDATE_TYPE_STYLES.optional;
return (
<span
className="px-2 py-0.5 text-xs rounded-full font-medium"
style={{ backgroundColor: style.bg, color: style.color }}
>
{style.label}
</span>
);
}
function formatBytes(bytes) {
if (!bytes) return "—";
if (bytes < 1024) return `${bytes} B`;
@@ -61,9 +83,11 @@ export default function FirmwareManager() {
const [channelFilter, setChannelFilter] = useState("");
const [showUpload, setShowUpload] = useState(false);
const [uploadHwType, setUploadHwType] = useState("vs");
const [uploadHwType, setUploadHwType] = useState("vesper");
const [uploadChannel, setUploadChannel] = useState("stable");
const [uploadVersion, setUploadVersion] = useState("");
const [uploadUpdateType, setUploadUpdateType] = useState("mandatory");
const [uploadMinFw, setUploadMinFw] = useState("");
const [uploadNotes, setUploadNotes] = useState("");
const [uploadFile, setUploadFile] = useState(null);
const [uploading, setUploading] = useState(false);
@@ -104,6 +128,8 @@ export default function FirmwareManager() {
formData.append("hw_type", uploadHwType);
formData.append("channel", uploadChannel);
formData.append("version", uploadVersion);
formData.append("update_type", uploadUpdateType);
if (uploadMinFw) formData.append("min_fw_version", uploadMinFw);
if (uploadNotes) formData.append("notes", uploadNotes);
formData.append("file", uploadFile);
@@ -120,6 +146,8 @@ export default function FirmwareManager() {
setShowUpload(false);
setUploadVersion("");
setUploadUpdateType("mandatory");
setUploadMinFw("");
setUploadNotes("");
setUploadFile(null);
if (fileInputRef.current) fileInputRef.current.value = "";
@@ -145,7 +173,7 @@ export default function FirmwareManager() {
}
};
const BOARD_TYPE_LABELS = { vs: "Vesper", vp: "Vesper+", vx: "VesperPro" };
const BOARD_TYPE_LABELS = { vesper: "Vesper", vesper_plus: "Vesper+", vesper_pro: "Vesper Pro", chronos: "Chronos", chronos_pro: "Chronos Pro", agnus: "Agnus", agnus_mini: "Agnus Mini" };
return (
<div>
@@ -173,131 +201,247 @@ export default function FirmwareManager() {
{/* Upload form */}
{showUpload && (
<div
className="rounded-lg border p-5 mb-5"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
className="ui-section-card mb-5"
>
<h2 className="text-base font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Upload New Firmware
</h2>
{/* Section title row */}
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">Upload New Firmware</h2>
</div>
{uploadError && (
<div
className="text-sm rounded-md p-3 mb-3 border"
style={{
backgroundColor: "var(--danger-bg)",
borderColor: "var(--danger)",
color: "var(--danger-text)",
}}
className="text-sm rounded-md p-3 mb-4 border"
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
>
{uploadError}
</div>
)}
<form onSubmit={handleUpload} className="space-y-3">
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>
Board Type
</label>
<select
value={uploadHwType}
onChange={(e) => setUploadHwType(e.target.value)}
className="w-full px-3 py-2 rounded-md text-sm border"
style={{
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-input)",
color: "var(--text-primary)",
}}
>
{BOARD_TYPES.map((bt) => (
<option key={bt.value} value={bt.value}>{bt.label}</option>
))}
</select>
<form onSubmit={handleUpload}>
{/* 3-column panel layout — height driven by left panel */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "1.5rem", alignItems: "stretch" }}>
{/* ── LEFT: Config ── */}
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
{/* Board Type */}
<div>
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>Board Type</label>
<select
value={uploadHwType}
onChange={(e) => setUploadHwType(e.target.value)}
className="w-full px-3 py-2 rounded-md text-sm border"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
>
{BOARD_TYPES.map((bt) => (
<option key={bt.value} value={bt.value}>{bt.label}</option>
))}
</select>
</div>
{/* Channel | Version | Min FW */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.625rem" }}>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>Channel</label>
<select
value={uploadChannel}
onChange={(e) => setUploadChannel(e.target.value)}
className="w-full px-3 py-2 rounded-md text-sm border"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
>
{CHANNELS.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>Version</label>
<input
type="text"
value={uploadVersion}
onChange={(e) => setUploadVersion(e.target.value)}
placeholder="1.5"
required
className="w-full px-3 py-2 rounded-md text-sm border"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
/>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>Min FW</label>
<input
type="text"
value={uploadMinFw}
onChange={(e) => setUploadMinFw(e.target.value)}
placeholder="e.g. 1.2"
className="w-full px-3 py-2 rounded-md text-sm border"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
/>
</div>
</div>
{/* Update Type — 3 toggle buttons */}
<div>
<label className="block text-xs font-medium mb-1.5" style={{ color: "var(--text-muted)" }}>Update Type</label>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.5rem" }}>
{[
{ value: "mandatory", label: "Mandatory", desc: "Auto on reboot", active: { bg: "var(--badge-blue-bg)", border: "var(--badge-blue-text)", color: "var(--badge-blue-text)" } },
{ value: "emergency", label: "Emergency", desc: "Immediate push", active: { bg: "var(--danger-bg)", border: "var(--danger)", color: "var(--danger-text)" } },
{ value: "optional", label: "Optional", desc: "User-initiated", active: { bg: "var(--success-bg)", border: "var(--success-text)", color: "var(--success-text)" } },
].map((opt) => {
const isActive = uploadUpdateType === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => setUploadUpdateType(opt.value)}
style={{
padding: "0.5rem 0.25rem",
borderRadius: "0.5rem",
border: `1px solid ${isActive ? opt.active.border : "var(--border-input)"}`,
backgroundColor: isActive ? opt.active.bg : "var(--bg-input)",
color: isActive ? opt.active.color : "var(--text-muted)",
cursor: "pointer",
textAlign: "center",
transition: "all 0.15s ease",
}}
>
<div style={{ fontSize: "0.75rem", fontWeight: 600 }}>{opt.label}</div>
<div style={{ fontSize: "0.65rem", marginTop: "0.15rem", opacity: 0.75 }}>{opt.desc}</div>
</button>
);
})}
</div>
</div>
{/* Action buttons sit at the bottom of the left panel */}
<div style={{ display: "flex", gap: "0.625rem", marginTop: "auto", paddingTop: "0.5rem" }}>
<button
type="submit"
disabled={uploading}
className="px-4 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{uploading ? "Uploading…" : "Upload"}
</button>
<button
type="button"
onClick={() => { setShowUpload(false); setUploadError(""); }}
className="px-4 py-2 text-sm rounded-md hover:opacity-80 cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Cancel
</button>
</div>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>
Channel
</label>
<select
value={uploadChannel}
onChange={(e) => setUploadChannel(e.target.value)}
className="w-full px-3 py-2 rounded-md text-sm border"
style={{
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-input)",
color: "var(--text-primary)",
}}
>
{CHANNELS.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>
Version
</label>
<input
type="text"
value={uploadVersion}
onChange={(e) => setUploadVersion(e.target.value)}
placeholder="1.4.2"
required
{/* ── MIDDLE: Release Notes ── */}
<div style={{ display: "flex", flexDirection: "column" }}>
<label className="block text-xs font-medium" style={{ color: "var(--text-muted)", marginBottom: "0.25rem" }}>Release Notes</label>
<textarea
value={uploadNotes}
onChange={(e) => setUploadNotes(e.target.value)}
placeholder="What changed in this version?"
className="w-full px-3 py-2 rounded-md text-sm border"
style={{
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-input)",
color: "var(--text-primary)",
flex: 1,
resize: "none",
minHeight: 0,
}}
/>
</div>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>
firmware.bin
</label>
<input
ref={fileInputRef}
type="file"
accept=".bin"
required
onChange={(e) => setUploadFile(e.target.files[0] || null)}
className="w-full text-sm"
style={{ color: "var(--text-primary)" }}
/>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>
Release Notes (optional)
</label>
<textarea
value={uploadNotes}
onChange={(e) => setUploadNotes(e.target.value)}
rows={2}
placeholder="What changed in this version?"
className="w-full px-3 py-2 rounded-md text-sm border resize-none"
style={{
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-input)",
color: "var(--text-primary)",
}}
/>
</div>
<div className="flex gap-3 pt-1">
<button
type="submit"
disabled={uploading}
className="px-4 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{uploading ? "Uploading…" : "Upload"}
</button>
<button
type="button"
onClick={() => { setShowUpload(false); setUploadError(""); }}
className="px-4 py-2 text-sm rounded-md hover:opacity-80 cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Cancel
</button>
{/* ── RIGHT: File drop + info ── */}
<div style={{ display: "flex", flexDirection: "column" }}>
<label className="block text-xs font-medium" style={{ color: "var(--text-muted)", marginBottom: "0.25rem" }}>File Upload</label>
{/* Drop zone */}
<div
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const f = e.dataTransfer.files[0];
if (f && f.name.endsWith(".bin")) setUploadFile(f);
}}
style={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "0.5rem",
border: `2px dashed ${uploadFile ? "var(--btn-primary)" : "var(--border-input)"}`,
borderRadius: "0.625rem",
backgroundColor: uploadFile ? "var(--badge-blue-bg)" : "var(--bg-input)",
cursor: "pointer",
transition: "all 0.15s ease",
padding: "1rem",
minHeight: 0,
}}
>
<input
ref={fileInputRef}
type="file"
accept=".bin"
required
onChange={(e) => setUploadFile(e.target.files[0] || null)}
style={{ display: "none" }}
/>
{uploadFile ? (
<>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--btn-primary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: "var(--badge-blue-text)", textAlign: "center", wordBreak: "break-all" }}>
{uploadFile.name}
</span>
</>
) : (
<>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
<span style={{ fontSize: "0.75rem", color: "var(--text-muted)", textAlign: "center" }}>
Click or drop <strong>.bin</strong> file here
</span>
</>
)}
</div>
{/* File info */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "0.375rem",
padding: "0.75rem",
marginTop: "0.75rem",
borderRadius: "0.5rem",
border: "1px solid var(--border-secondary)",
backgroundColor: "var(--bg-secondary)",
}}
>
<div style={{ fontSize: "0.72rem", color: "var(--text-muted)" }}>
<span style={{ fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em" }}>Size</span>
{" "}
<span style={{ color: "var(--text-primary)", fontFamily: "monospace" }}>
{uploadFile ? formatBytes(uploadFile.size) : "—"}
</span>
</div>
{uploadFile?.lastModified && (
<div style={{ fontSize: "0.72rem", color: "var(--text-muted)" }}>
<span style={{ fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em" }}>Modified</span>
{" "}
<span style={{ color: "var(--text-primary)", fontFamily: "monospace" }}>
{formatDate(new Date(uploadFile.lastModified).toISOString())}
</span>
</div>
)}
</div>
</div>
</div>
</form>
</div>
@@ -362,6 +506,8 @@ export default function FirmwareManager() {
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Type</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Channel</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Version</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Update Type</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Min FW</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Size</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>SHA-256</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Uploaded</th>
@@ -374,13 +520,13 @@ export default function FirmwareManager() {
<tbody>
{loading ? (
<tr>
<td colSpan={canDelete ? 8 : 7} className="px-4 py-8 text-center text-sm" style={{ color: "var(--text-muted)" }}>
<td colSpan={canDelete ? 10 : 9} className="px-4 py-8 text-center text-sm" style={{ color: "var(--text-muted)" }}>
Loading
</td>
</tr>
) : firmware.length === 0 ? (
<tr>
<td colSpan={canDelete ? 8 : 7} className="px-4 py-8 text-center text-sm" style={{ color: "var(--text-muted)" }}>
<td colSpan={canDelete ? 10 : 9} className="px-4 py-8 text-center text-sm" style={{ color: "var(--text-muted)" }}>
No firmware versions found.{" "}
{canAdd && (
<button
@@ -408,6 +554,12 @@ export default function FirmwareManager() {
<td className="px-4 py-3 font-mono text-xs" style={{ color: "var(--text-primary)" }}>
{fw.version}
</td>
<td className="px-4 py-3">
<UpdateTypeBadge type={fw.update_type} />
</td>
<td className="px-4 py-3 font-mono text-xs" style={{ color: "var(--text-muted)" }}>
{fw.min_fw_version || "—"}
</td>
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
{formatBytes(fw.size_bytes)}
</td>

View File

@@ -17,6 +17,7 @@
--text-primary: #e3e5ea;
--text-secondary: #9ca3af;
--text-muted: #9ca3af;
--text-more-muted: #818da1bb;
--text-heading: #e3e5ea;
--text-white: #ffffff;
--text-link: #589cfa;
@@ -28,6 +29,11 @@
--btn-primary-hover: #82c91e;
--btn-neutral: #778ca8;
--btn-neutral-hover: #8a9dba;
--mail-filter-green: #2f9e44;
--mail-filter-blue: #4dabf7;
--mail-filter-yellow: #f08c00;
--mail-filter-orange: #f76707;
--mail-filter-red: #e03131;
--danger: #f34b4b;
--danger-hover: #e53e3e;
@@ -42,6 +48,48 @@
--badge-blue-bg: #1e3a5f;
--badge-blue-text: #63b3ed;
/* ── Spacing tokens ── */
--section-padding: 2.25rem 2.5rem 2.25rem;
--section-padding-compact: 1.25rem 1.5rem;
--section-radius: 0.75rem;
--section-gap: 1.5rem;
/* ── Typography tokens ── */
--section-title-size: 0.78rem;
--section-title-weight: 700;
--section-title-tracking: 0.07em;
--font-size-label: 0.72rem;
--font-size-value: 0.92rem;
/* ── Section header title (larger, page-header style) ── */
--section-header-title-size: 1.0rem;
--section-header-title-weight: 600;
--section-header-title-tracking: 0.01em;
--section-header-title-color: var(--text-heading);
/* ── Field / item labels (secondary titles within sections) ── */
/* Display variant: small, uppercase, muted — used in <dt> / read-only views */
--field-label-size: 0.72rem;
--field-label-weight: 600;
--field-label-tracking: 0.02em;
--field-label-color: var(--text-more-muted);
/* Form variant: slightly larger, no uppercase — used in <label> / form inputs */
--form-label-size: 0.8rem;
--form-label-weight: 500;
--form-label-tracking: 0.01em;
--form-label-color: var(--text-secondary);
}
/* Remove number input spinners (arrows) in all browsers */
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
appearance: textfield;
}
/* Ensure all interactive elements show pointer cursor */
@@ -624,12 +672,77 @@ input[type="range"]::-moz-range-thumb {
text-align: left;
}
/* ── Section cards (used in all tabs) ── */
/* ── Universal section card — single source of truth for all pages ── */
.ui-section-card {
border: 1px solid var(--border-primary);
border-radius: var(--section-radius);
background-color: var(--bg-card);
padding: var(--section-padding);
}
.ui-section-card--compact {
border: 1px solid var(--border-primary);
border-radius: var(--section-radius);
background-color: var(--bg-card);
padding: var(--section-padding-compact);
}
.ui-section-card__title-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-primary);
}
.ui-section-card__title {
font-size: var(--section-title-size);
font-weight: var(--section-title-weight);
letter-spacing: var(--section-title-tracking);
text-transform: uppercase;
color: var(--text-muted);
margin: 0;
}
/* Larger, white-ish section title for form/settings pages */
.ui-section-card__header-title {
font-size: var(--section-header-title-size);
font-weight: var(--section-header-title-weight);
letter-spacing: var(--section-header-title-tracking);
color: var(--section-header-title-color);
margin: 0;
}
/* ── Field labels — secondary titles within section cards ── */
/* Display/read-only label (dt, small caps, muted) */
.ui-field-label {
display: block;
font-size: var(--field-label-size);
font-weight: var(--field-label-weight);
letter-spacing: var(--field-label-tracking);
text-transform: uppercase;
color: var(--field-label-color);
margin: 0;
}
/* Form label (label element, slightly larger, no uppercase) */
.ui-form-label {
display: block;
font-size: var(--form-label-size);
font-weight: var(--form-label-weight);
letter-spacing: var(--form-label-tracking);
color: var(--form-label-color);
margin-bottom: 0.35rem;
}
/* ── Section cards (used in all tabs) — alias to ui-section-card ── */
.device-section-card {
border: 1px solid var(--border-primary);
border-radius: 0.75rem;
border-radius: var(--section-radius);
background-color: var(--bg-card);
padding: 2.25rem 2.5rem 2.5rem;
padding: var(--section-padding);
}
.device-section-card__title-row {
@@ -642,9 +755,9 @@ input[type="range"]::-moz-range-thumb {
}
.device-section-card__title {
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.07em;
font-size: var(--section-title-size);
font-weight: var(--section-title-weight);
letter-spacing: var(--section-title-tracking);
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 0;
@@ -729,7 +842,7 @@ input[type="range"]::-moz-range-thumb {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
padding: 1rem 2.5rem;
border-radius: 0.6rem;
border: 1px solid var(--border-primary);
background-color: var(--bg-card);
@@ -1105,6 +1218,17 @@ input[type="range"]::-moz-range-thumb {
}
/* ── Upload modal animations ── */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* File input */
input[type="file"]::file-selector-button {
background-color: var(--bg-card) !important;

View File

@@ -1,4 +1,202 @@
import { useEffect, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import api from "../api/client";
// ── Breadcrumb label cache (in-memory, survives re-renders) ────────────────
const labelCache = {};
// ── Segment label rules ────────────────────────────────────────────────────
const STATIC_LABELS = {
"": "Home",
dashboard: "Dashboard",
devices: "Devices",
users: "App Users",
mqtt: "MQTT",
commands: "Commands",
logs: "Logs",
equipment: "Equipment",
notes: "Issues & Notes",
mail: "Mail",
crm: "CRM",
comms: "Activity Log",
customers: "Customers",
orders: "Orders",
products: "Products",
quotations: "Quotations",
new: "New",
edit: "Edit",
melodies: "Melodies",
archetypes: "Archetypes",
settings: "Settings",
composer: "Composer",
manufacturing: "Manufacturing",
batch: "Batch",
provision: "Provision Device",
firmware: "Firmware",
developer: "Developer",
api: "API Reference",
staff: "Staff",
};
// Maps a parent path segment to an async entity resolver
const ENTITY_RESOLVERS = {
customers: { apiPath: (id) => `/crm/customers/${id}`, label: (d) => [d.name, d.surname].filter(Boolean).join(" ") || d.organization || null },
products: { apiPath: (id) => `/crm/products/${id}`, label: (d) => d.name || null },
quotations: { apiPath: (id) => `/crm/quotations/${id}`, label: (d) => d.quotation_number || null },
devices: { apiPath: (id) => `/devices/${id}`, label: (d) => d.name || d.device_id || null },
orders: { apiPath: (id) => `/crm/orders/${id}`, label: (d) => d.order_number || null },
melodies: { apiPath: (id) => `/melodies/${id}`, label: (d) => d.name || null },
archetypes: { apiPath: (id) => `/builder/melodies/${id}`,label: (d) => d.name || null },
users: { apiPath: (id) => `/users/${id}`, label: (d) => d.name || d.display_name || null },
notes: { apiPath: (id) => `/equipment/notes/${id}`, label: (d) => d.title || d.subject || null },
staff: { apiPath: (id) => `/staff/${id}`, label: (d) => d.name || null },
};
// Fetch entity name for a dynamic ID segment
async function fetchLabel(fetchType, id) {
const cacheKey = `${fetchType}:${id}`;
if (labelCache[cacheKey]) return labelCache[cacheKey];
const resolver = ENTITY_RESOLVERS[fetchType];
if (!resolver) return id;
try {
const data = await api.get(resolver.apiPath(id));
const label = resolver.label(data);
if (label) {
const short = String(label).length > 28 ? String(label).slice(0, 26) + "…" : String(label);
labelCache[cacheKey] = short;
return short;
}
} catch {
// ignore — fall back to id
}
return id;
}
// Given a full path, return breadcrumb segments: [{ label, to }]
function parseSegments(pathname) {
const parts = pathname.split("/").filter(Boolean);
const segments = [{ label: "Home", to: "/" }];
let i = 0;
while (i < parts.length) {
const part = parts[i];
const built = "/" + parts.slice(0, i + 1).join("/");
// Special multi-segment patterns
if (part === "crm") {
segments.push({ label: "CRM", to: "/crm/customers" });
i++;
continue;
}
if (part === "manufacturing" && parts[i + 1] === "batch" && parts[i + 2] === "new") {
segments.push({ label: "Manufacturing", to: "/manufacturing" });
segments.push({ label: "New Batch", to: built + "/batch/new" });
i += 3;
continue;
}
if (part === "manufacturing" && parts[i + 1] === "provision") {
segments.push({ label: "Manufacturing", to: "/manufacturing" });
segments.push({ label: "Provision Device", to: built + "/provision" });
i += 2;
continue;
}
if (part === "manufacturing" && parts[i + 1] === "devices") {
segments.push({ label: "Manufacturing", to: "/manufacturing" });
i++;
continue;
}
// "devices" is handled by STATIC_LABELS below so the following ID segment
// can detect prevPart === "devices" for entity resolution.
// equipment/notes
if (part === "equipment" && parts[i + 1] === "notes") {
segments.push({ label: "Issues & Notes", to: "/equipment/notes" });
i += 2;
continue;
}
const staticLabel = STATIC_LABELS[part];
if (staticLabel) {
segments.push({ label: staticLabel, to: built });
} else {
// Dynamic ID segment — determine type from previous path segment
const prevPart = parts[i - 1];
const fetchType = ENTITY_RESOLVERS[prevPart] ? prevPart : null;
// Use the raw id as placeholder — will be replaced asynchronously if fetchType is known
segments.push({ label: part, to: built, dynamicId: part, fetchType });
}
i++;
}
return segments;
}
function Breadcrumb() {
const location = useLocation();
const [segments, setSegments] = useState(() => parseSegments(location.pathname));
useEffect(() => {
const parsed = parseSegments(location.pathname);
setSegments(parsed);
// Resolve any dynamic segments asynchronously
const dynamics = parsed.filter((s) => s.fetchType && s.dynamicId);
if (dynamics.length === 0) return;
let cancelled = false;
(async () => {
const resolved = [...parsed];
for (const seg of dynamics) {
const label = await fetchLabel(seg.fetchType, seg.dynamicId);
if (cancelled) return;
const idx = resolved.findIndex((s) => s.dynamicId === seg.dynamicId && s.fetchType === seg.fetchType);
if (idx !== -1) resolved[idx] = { ...resolved[idx], label };
}
if (!cancelled) setSegments([...resolved]);
})();
return () => { cancelled = true; };
}, [location.pathname]);
// Don't show breadcrumb for root
if (segments.length <= 1) return null;
// Remove "Home" from display if not on root
const display = segments.slice(1);
return (
<nav aria-label="Breadcrumb" style={{ display: "flex", alignItems: "center", gap: 4, fontSize: 13 }}>
{/* Brand prefix */}
<span style={{ color: "var(--text-heading)", fontWeight: 700, fontSize: 13, letterSpacing: "0.01em", whiteSpace: "nowrap" }}>
BellSystems Console
</span>
<span style={{ color: "var(--border-primary)", userSelect: "none", margin: "0 8px", fontSize: 15 }}>|</span>
{display.map((seg, i) => (
<span key={i} style={{ display: "flex", alignItems: "center", gap: 4 }}>
{i > 0 && (
<span style={{ color: "var(--text-muted)", userSelect: "none", fontSize: 11 }}></span>
)}
{i === display.length - 1 ? (
<span style={{ color: "var(--text-heading)", fontWeight: 600 }}>{seg.label}</span>
) : (
<Link
to={seg.to}
style={{ color: "var(--text-secondary)", textDecoration: "none" }}
onMouseEnter={(e) => (e.currentTarget.style.color = "var(--text-heading)")}
onMouseLeave={(e) => (e.currentTarget.style.color = "var(--text-secondary)")}
>
{seg.label}
</Link>
)}
</span>
))}
</nav>
);
}
export default function Header() {
const { user, logout } = useAuth();
@@ -11,9 +209,7 @@ export default function Header() {
borderColor: "var(--border-primary)",
}}
>
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>
BellCloud - Console
</h2>
<Breadcrumb />
<div className="flex items-center gap-4">
<span className="text-sm" style={{ color: "var(--text-secondary)" }}>
@@ -41,5 +237,3 @@ export default function Header() {
</header>
);
}
/* my test string */

View File

@@ -8,7 +8,7 @@ const navItems = [
label: "Melodies",
permission: "melodies",
children: [
{ to: "/melodies", label: "Main Editor" },
{ to: "/melodies", label: "Main Editor", exact: true },
{ to: "/melodies/archetypes", label: "Archetypes" },
{ to: "/melodies/settings", label: "Settings" },
{ to: "/melodies/composer", label: "Composer" },
@@ -20,17 +20,28 @@ const navItems = [
label: "MQTT",
permission: "mqtt",
children: [
{ to: "/mqtt", label: "Dashboard" },
{ to: "/mqtt", label: "Dashboard", exact: true },
{ to: "/mqtt/commands", label: "Commands" },
{ to: "/mqtt/logs", label: "Logs" },
],
},
{ to: "/equipment/notes", label: "Issues and Notes", permission: "equipment" },
{ to: "/mail", label: "Mail", permission: "crm" },
{
label: "CRM",
permission: "crm",
children: [
{ to: "/crm/comms", label: "Activity Log" },
{ to: "/crm/customers", label: "Customers" },
{ to: "/crm/orders", label: "Orders" },
{ to: "/crm/products", label: "Products" },
],
},
{
label: "Manufacturing",
permission: "manufacturing",
children: [
{ to: "/manufacturing", label: "Device Inventory" },
{ to: "/manufacturing", label: "Device Inventory", exact: true },
{ to: "/manufacturing/batch/new", label: "New Batch" },
{ to: "/manufacturing/provision", label: "Provision Device" },
{ to: "/firmware", label: "Firmware" },
@@ -47,17 +58,39 @@ const linkClass = (isActive, locked) =>
: "text-[var(--text-secondary)] hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-heading)]"
}`;
function isGroupActive(children, pathname) {
return children.some((child) => {
if (child.exact) return pathname === child.to;
return pathname === child.to || pathname.startsWith(child.to + "/");
});
}
export default function Sidebar() {
const { hasPermission, hasRole } = useAuth();
const location = useLocation();
const [openGroup, setOpenGroup] = useState(() => {
// Open the group that contains the current route on initial load
for (const item of navItems) {
if (item.children && isGroupActive(item.children, location.pathname)) {
return item.label;
}
}
return null;
});
const canViewSection = (permission) => {
if (!permission) return true;
return hasPermission(permission, "view");
};
// Settings visible only to sysadmin and admin
const canManageStaff = hasRole("sysadmin", "admin");
const canViewDeveloper = hasRole("sysadmin", "admin");
const handleGroupToggle = (label) => {
setOpenGroup((prev) => (prev === label ? null : label));
};
const settingsChildren = [{ to: "/settings/staff", label: "Staff" }];
return (
<aside className="w-56 h-screen flex-shrink-0 p-4 border-r flex flex-col overflow-y-auto" style={{ backgroundColor: "var(--bg-sidebar)", borderColor: "var(--border-primary)" }}>
@@ -78,6 +111,8 @@ export default function Sidebar() {
children={item.children}
currentPath={location.pathname}
locked={!hasAccess}
open={openGroup === item.label}
onToggle={() => handleGroupToggle(item.label)}
/>
) : (
<NavLink
@@ -85,7 +120,10 @@ export default function Sidebar() {
to={hasAccess ? item.to : "#"}
end={item.to === "/"}
className={({ isActive }) => linkClass(isActive && hasAccess, !hasAccess)}
onClick={(e) => !hasAccess && e.preventDefault()}
onClick={(e) => {
if (!hasAccess) { e.preventDefault(); return; }
setOpenGroup(null);
}}
>
<span className="flex items-center gap-2">
{item.label}
@@ -100,16 +138,31 @@ export default function Sidebar() {
})}
</nav>
{/* Developer section */}
{canViewDeveloper && (
<div className="mt-4 pt-4 border-t" style={{ borderColor: "var(--border-primary)" }}>
<nav className="space-y-1">
<NavLink
to="/developer/api"
className={({ isActive }) => linkClass(isActive, false)}
onClick={() => setOpenGroup(null)}
>
API Reference
</NavLink>
</nav>
</div>
)}
{/* Settings section at the bottom */}
{canManageStaff && (
<div className="mt-4 pt-4 border-t" style={{ borderColor: "var(--border-primary)" }}>
<nav className="space-y-1">
<CollapsibleGroup
label="Settings"
children={[
{ to: "/settings/staff", label: "Staff" },
]}
children={settingsChildren}
currentPath={location.pathname}
open={openGroup === "Settings"}
onToggle={() => handleGroupToggle("Settings")}
/>
</nav>
</div>
@@ -118,25 +171,19 @@ export default function Sidebar() {
);
}
function CollapsibleGroup({ label, children, currentPath, locked = false }) {
const isChildActive = children.some(
(child) =>
currentPath === child.to ||
(child.to !== "/" && currentPath.startsWith(child.to + "/"))
);
const [open, setOpen] = useState(isChildActive);
const shouldBeOpen = open || isChildActive;
function CollapsibleGroup({ label, children, currentPath, locked = false, open, onToggle }) {
const childActive = isGroupActive(children, currentPath);
const shouldBeOpen = open || childActive;
return (
<div>
<button
type="button"
onClick={() => !locked && setOpen(!shouldBeOpen)}
onClick={() => !locked && onToggle()}
className={`w-full flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors ${
locked
? "opacity-40 cursor-not-allowed"
: isChildActive
: childActive
? "text-[var(--text-heading)]"
: "text-[var(--text-secondary)] hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-heading)]"
}`}
@@ -145,7 +192,7 @@ function CollapsibleGroup({ label, children, currentPath, locked = false }) {
{label}
{locked && (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
)}
</span>
@@ -166,7 +213,7 @@ function CollapsibleGroup({ label, children, currentPath, locked = false }) {
<NavLink
key={child.to}
to={child.to}
end
end={child.exact === true}
className={({ isActive }) =>
`block pl-4 pr-3 py-1.5 rounded-md text-sm transition-colors ${
isActive
@@ -183,5 +230,3 @@ function CollapsibleGroup({ label, children, currentPath, locked = false }) {
</div>
);
}

View File

@@ -3,13 +3,13 @@ import { useNavigate } from "react-router-dom";
import api from "../api/client";
const BOARD_TYPES = [
{ value: "vx", name: "VESPER PRO", codename: "vesper-pro", desc: "Full-featured pro controller", family: "vesper" },
{ value: "vp", name: "VESPER PLUS", codename: "vesper-plus", desc: "Extended output controller", family: "vesper" },
{ value: "vs", name: "VESPER", codename: "vesper-basic", desc: "Standard bell controller", family: "vesper" },
{ value: "ab", name: "AGNUS", codename: "agnus-basic", desc: "Standard carillon module", family: "agnus" },
{ value: "am", name: "AGNUS MINI", codename: "agnus-mini", desc: "Compact carillon module", family: "agnus" },
{ value: "cp", name: "CHRONOS PRO", codename: "chronos-pro", desc: "Pro clock controller", family: "chronos" },
{ value: "cb", name: "CHRONOS", codename: "chronos-basic", desc: "Basic clock controller", family: "chronos" },
{ value: "vesper_pro", name: "VESPER PRO", codename: "vesper-pro", desc: "Full-featured pro controller", family: "vesper" },
{ value: "vesper_plus", name: "VESPER PLUS", codename: "vesper-plus", desc: "Extended output controller", family: "vesper" },
{ value: "vesper", name: "VESPER", codename: "vesper-basic", desc: "Standard bell controller", family: "vesper" },
{ value: "agnus", name: "AGNUS", codename: "agnus-basic", desc: "Standard carillon module", family: "agnus" },
{ value: "agnus_mini", name: "AGNUS MINI", codename: "agnus-mini", desc: "Compact carillon module", family: "agnus" },
{ value: "chronos_pro", name: "CHRONOS PRO", codename: "chronos-pro", desc: "Pro clock controller", family: "chronos" },
{ value: "chronos", name: "CHRONOS", codename: "chronos-basic", desc: "Basic clock controller", family: "chronos" },
];
const BOARD_FAMILY_COLORS = {
@@ -99,12 +99,11 @@ export default function BatchCreator() {
{!result ? (
<div
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
className="ui-section-card"
>
<h2 className="text-base font-semibold mb-5" style={{ color: "var(--text-heading)" }}>
Batch Parameters
</h2>
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">Batch Parameters</h2>
</div>
{error && (
<div className="text-sm rounded-md p-3 mb-4 border"
@@ -116,7 +115,7 @@ export default function BatchCreator() {
<form onSubmit={handleSubmit} className="space-y-5">
{/* Board Type tiles — Vesper: 3 col, Agnus: 2 col, Chronos: 2 col */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Board Type
</label>
<div className="space-y-2">
@@ -147,7 +146,7 @@ export default function BatchCreator() {
{/* Board Revision */}
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Board Revision
</label>
<div className="flex items-center gap-2">
@@ -169,7 +168,7 @@ export default function BatchCreator() {
{/* Quantity */}
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Quantity
</label>
<input
@@ -206,8 +205,7 @@ export default function BatchCreator() {
</div>
) : (
<div
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
className="ui-section-card"
>
<div className="flex items-start justify-between mb-4">
<div>

View File

@@ -7,13 +7,13 @@ import api from "../api/client";
// Row layout: Vesper Pro / Vesper Plus / Vesper | Agnus / Agnus Mini | Chronos Pro / Chronos
const BOARD_TYPES = [
{ value: "vx", name: "VESPER PRO", codename: "vesper-pro", family: "vesper" },
{ value: "vp", name: "VESPER PLUS", codename: "vesper-plus", family: "vesper" },
{ value: "vs", name: "VESPER", codename: "vesper-basic", family: "vesper" },
{ value: "ab", name: "AGNUS", codename: "agnus-basic", family: "agnus" },
{ value: "am", name: "AGNUS MINI", codename: "agnus-mini", family: "agnus" },
{ value: "cp", name: "CHRONOS PRO", codename: "chronos-pro", family: "chronos" },
{ value: "cb", name: "CHRONOS", codename: "chronos-basic", family: "chronos" },
{ value: "vesper_pro", name: "VESPER PRO", codename: "vesper-pro", family: "vesper" },
{ value: "vesper_plus", name: "VESPER PLUS", codename: "vesper-plus", family: "vesper" },
{ value: "vesper", name: "VESPER", codename: "vesper-basic", family: "vesper" },
{ value: "agnus", name: "AGNUS", codename: "agnus-basic", family: "agnus" },
{ value: "agnus_mini", name: "AGNUS MINI", codename: "agnus-mini", family: "agnus" },
{ value: "chronos_pro", name: "CHRONOS PRO", codename: "chronos-pro", family: "chronos" },
{ value: "chronos", name: "CHRONOS", codename: "chronos-basic", family: "chronos" },
];
const BOARD_FAMILY_COLORS = {
@@ -589,7 +589,16 @@ export default function DeviceInventory() {
const renderCell = (col, device) => {
switch (col.id) {
case "serial": return <span className="font-mono text-xs" style={{ color: "var(--text-primary)" }}>{device.serial_number}</span>;
case "serial": return (
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
<span className="font-mono text-xs" style={{ color: "var(--text-primary)" }}>{device.serial_number}</span>
{/^[A-Z0-9]{10,}$/.test(device.serial_number || "") && (
<span style={{ fontSize: 9, fontWeight: 700, padding: "1px 5px", borderRadius: 3, backgroundColor: "#2e1a00", color: "#fb923c", letterSpacing: "0.04em" }}>
LEGACY
</span>
)}
</span>
);
case "type": return <span style={{ color: "var(--text-secondary)" }}>{BOARD_TYPE_LABELS[device.hw_type] || device.hw_type}</span>;
case "version": return <span style={{ color: "var(--text-muted)" }}>{formatHwVersion(device.hw_version)}</span>;
case "status": return <StatusBadge status={device.mfg_status} />;

View File

@@ -4,8 +4,8 @@ import { useAuth } from "../auth/AuthContext";
import api from "../api/client";
const BOARD_TYPE_LABELS = {
vs: "Vesper", vp: "Vesper Plus", vx: "Vesper Pro",
cb: "Chronos", cp: "Chronos Pro", am: "Agnus Mini", ab: "Agnus",
vesper: "Vesper", vesper_plus: "Vesper+", vesper_pro: "Vesper Pro",
chronos: "Chronos", chronos_pro: "Chronos Pro", agnus_mini: "Agnus Mini", agnus: "Agnus",
};
const STATUS_STYLES = {
@@ -47,9 +47,7 @@ function StatusBadge({ status }) {
function Field({ label, value, mono = false }) {
return (
<div>
<p className="text-xs font-medium uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>
{label}
</p>
<p className="ui-field-label mb-0.5">{label}</p>
<p className={`text-sm ${mono ? "font-mono" : ""}`} style={{ color: "var(--text-primary)" }}>
{value || "—"}
</p>
@@ -320,11 +318,10 @@ export default function DeviceInventoryDetail() {
)}
{/* Identity card */}
<div className="rounded-lg border p-5 mb-4"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<h2 className="text-sm font-semibold uppercase tracking-wide mb-4" style={{ color: "var(--text-muted)" }}>
Device Identity
</h2>
<div className="ui-section-card mb-4">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">Device Identity</h2>
</div>
<div className="grid grid-cols-2 gap-4">
<Field label="Serial Number" value={device?.serial_number} mono />
<Field label="Board Type" value={BOARD_TYPE_LABELS[device?.hw_type] || device?.hw_type} />
@@ -339,12 +336,9 @@ export default function DeviceInventoryDetail() {
</div>
{/* Status card */}
<div className="rounded-lg border p-5 mb-4"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
Status
</h2>
<div className="ui-section-card mb-4">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">Status</h2>
{canEdit && !editingStatus && (
<button
onClick={() => { setNewStatus(device.mfg_status); setEditingStatus(true); }}
@@ -404,11 +398,10 @@ export default function DeviceInventoryDetail() {
</div>
{/* Actions card */}
<div className="rounded-lg border p-5 mb-4"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<h2 className="text-sm font-semibold uppercase tracking-wide mb-3" style={{ color: "var(--text-muted)" }}>
Actions
</h2>
<div className="ui-section-card mb-4">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">Actions</h2>
</div>
<div className="flex flex-wrap gap-3">
<button
onClick={downloadNvs}
@@ -429,11 +422,10 @@ export default function DeviceInventoryDetail() {
{/* Assign to Customer card */}
{canEdit && ["provisioned", "flashed"].includes(device?.mfg_status) && (
<div className="rounded-lg border p-5"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<h2 className="text-sm font-semibold uppercase tracking-wide mb-3" style={{ color: "var(--text-muted)" }}>
Assign to Customer
</h2>
<div className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">Assign to Customer</h2>
</div>
{assignSuccess ? (
<div className="text-sm rounded-md p-3 border"
style={{ backgroundColor: "#0a2e2a", borderColor: "#4dd6c8", color: "#4dd6c8" }}>

View File

@@ -7,13 +7,13 @@ import api from "../api/client";
// Row layout: Vesper Pro / Vesper Plus / Vesper | Agnus / Agnus Mini | Chronos Pro / Chronos
const BOARD_TYPES = [
{ value: "vx", name: "VESPER PRO", codename: "vesper-pro", desc: "Full-featured pro controller", family: "vesper" },
{ value: "vp", name: "VESPER PLUS", codename: "vesper-plus", desc: "Extended output controller", family: "vesper" },
{ value: "vs", name: "VESPER", codename: "vesper-basic", desc: "Standard bell controller", family: "vesper" },
{ value: "ab", name: "AGNUS", codename: "agnus-basic", desc: "Standard carillon module", family: "agnus" },
{ value: "am", name: "AGNUS MINI", codename: "agnus-mini", desc: "Compact carillon module", family: "agnus" },
{ value: "cp", name: "CHRONOS PRO", codename: "chronos-pro", desc: "Pro clock controller", family: "chronos"},
{ value: "cb", name: "CHRONOS", codename: "chronos-basic", desc: "Basic clock controller", family: "chronos"},
{ value: "vesper_pro", name: "VESPER PRO", codename: "vesper-pro", desc: "Full-featured pro controller", family: "vesper" },
{ value: "vesper_plus", name: "VESPER PLUS", codename: "vesper-plus", desc: "Extended output controller", family: "vesper" },
{ value: "vesper", name: "VESPER", codename: "vesper-basic", desc: "Standard bell controller", family: "vesper" },
{ value: "agnus", name: "AGNUS", codename: "agnus-basic", desc: "Standard carillon module", family: "agnus" },
{ value: "agnus_mini", name: "AGNUS MINI", codename: "agnus-mini", desc: "Compact carillon module", family: "agnus" },
{ value: "chronos_pro", name: "CHRONOS PRO", codename: "chronos-pro", desc: "Pro clock controller", family: "chronos" },
{ value: "chronos", name: "CHRONOS", codename: "chronos-basic", desc: "Basic clock controller", family: "chronos" },
];
// Color palette per board family (idle → selected → hover glow)

View File

@@ -400,10 +400,7 @@ export default function MelodyComposer() {
</div>
)}
<section
className="rounded-lg border p-4"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<section className="ui-section-card">
<div className="flex flex-wrap items-center gap-2">
<button type="button" onClick={addStep} className="px-3 py-1.5 text-sm rounded-md" style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}>+ Step</button>
<button type="button" onClick={removeStep} className="px-3 py-1.5 text-sm rounded-md" style={{ backgroundColor: "var(--btn-neutral)", color: "var(--text-white)" }}>- Step</button>
@@ -485,10 +482,7 @@ export default function MelodyComposer() {
</div>
</section>
<section
className="rounded-lg border"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<section className="ui-section-card">
<div className="overflow-x-auto">
<table className="min-w-max border-separate border-spacing-0">
<thead>
@@ -633,10 +627,7 @@ export default function MelodyComposer() {
</div>
</section>
<section
className="rounded-lg border p-4"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<section className="ui-section-card">
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<div>
<p className="text-xs mb-1" style={{ color: "var(--text-muted)" }}>Generated CSV notation</p>

View File

@@ -58,9 +58,7 @@ function normalizeFileUrl(url) {
function Field({ label, children }) {
return (
<div>
<dt className="text-xs font-medium uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
{label}
</dt>
<dt className="ui-field-label">{label}</dt>
<dd className="mt-1 text-sm" style={{ color: "var(--text-primary)" }}>{children || "-"}</dd>
</div>
);
@@ -70,9 +68,7 @@ function UrlField({ label, value }) {
const [copied, setCopied] = useState(false);
return (
<div>
<dt className="text-xs font-medium uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>
{label}
</dt>
<dt className="ui-field-label mb-1">{label}</dt>
<dd className="flex items-center gap-2">
<span
className="text-sm font-mono flex-1 min-w-0"
@@ -354,12 +350,11 @@ export default function MelodyDetail() {
<div className="space-y-6">
{/* Melody Information */}
<section
className="rounded-lg p-6 border"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
className="ui-section-card"
>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Melody Information
</h2>
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">Melody Information</h2>
</div>
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
<Field label="Color">
{info.color ? (
@@ -422,12 +417,11 @@ export default function MelodyDetail() {
{/* Identifiers */}
<section
className="rounded-lg p-6 border"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
className="ui-section-card"
>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Identifiers
</h2>
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">Identifiers</h2>
</div>
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
<Field label="Document ID">{melody.id}</Field>
<Field label="PID (Playback ID)">{melody.pid}</Field>
@@ -444,12 +438,11 @@ export default function MelodyDetail() {
<div className="space-y-6">
{/* Default Settings */}
<section
className="rounded-lg p-6 border"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
className="ui-section-card"
>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Default Settings
</h2>
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">Default Settings</h2>
</div>
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
<Field label="Speed">
{settings.speed != null ? (
@@ -474,7 +467,7 @@ export default function MelodyDetail() {
</Field>
</div>
<div className="col-span-2 md:col-span-3">
<dt className="text-xs font-medium uppercase tracking-wide mb-2" style={{ color: "var(--text-muted)" }}>Note Assignments</dt>
<dt className="ui-field-label mb-2">Note Assignments</dt>
<dd>
{settings.noteAssignments?.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
@@ -512,10 +505,11 @@ export default function MelodyDetail() {
{/* Files */}
<section
className="rounded-lg p-6 border"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
className="ui-section-card"
>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Files</h2>
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">Files</h2>
</div>
<dl className="space-y-4">
<Field label="Available as Built-In">
<label className="inline-flex items-center gap-2">
@@ -681,12 +675,11 @@ export default function MelodyDetail() {
{/* Firmware Code section — only shown if a built melody with PROGMEM code is assigned */}
{builtMelody?.progmem_code && (
<section
className="rounded-lg p-6 border mt-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
className="ui-section-card mt-6"
>
<div className="flex items-center justify-between mb-3">
<div className="ui-section-card__title-row">
<div>
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>Firmware Code</h2>
<h2 className="ui-section-card__title">Firmware Code</h2>
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
PROGMEM code for built-in firmware playback &nbsp;·&nbsp; PID: <span className="font-mono">{builtMelody.pid}</span>
</p>
@@ -723,10 +716,11 @@ export default function MelodyDetail() {
{/* Metadata section */}
{melody.metadata && (
<section
className="rounded-lg p-6 border mt-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
className="ui-section-card mt-6"
>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>History</h2>
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">History</h2>
</div>
<dl className="grid grid-cols-2 md:grid-cols-4 gap-4">
{melody.metadata.dateCreated && (
<Field label="Date Created">
@@ -750,10 +744,11 @@ export default function MelodyDetail() {
{/* Admin Notes section */}
<section
className="rounded-lg p-6 border mt-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
className="ui-section-card mt-6"
>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Admin Notes</h2>
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">Admin Notes</h2>
</div>
{(melody.metadata?.adminNotes?.length || 0) > 0 ? (
<div className="space-y-2">
{melody.metadata.adminNotes.map((note, i) => (

View File

@@ -46,12 +46,6 @@ const defaultSettings = {
noteAssignments: [],
};
// Dark-themed styles
const sectionStyle = {
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
};
const headingStyle = { color: "var(--text-heading)" };
const labelStyle = { color: "var(--text-secondary)" };
const mutedStyle = { color: "var(--text-muted)" };
@@ -408,7 +402,7 @@ export default function MelodyForm() {
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold" style={headingStyle}>
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
{isEdit ? "Edit Melody" : "Add Melody"}
</h1>
<div className="flex items-center gap-2">
@@ -533,13 +527,15 @@ export default function MelodyForm() {
{/* ===== Left Column ===== */}
<div className="space-y-6">
{/* --- Melody Info Section --- */}
<section className="rounded-lg p-6 border" style={sectionStyle}>
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Melody Information</h2>
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">Melody Information</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Name (localized) */}
<div className="md:col-span-2">
<div className="flex items-center gap-2 mb-1">
<label className="block text-sm font-medium" style={labelStyle}>Name *</label>
<label className="ui-form-label">Name *</label>
<button
type="button"
onClick={() => setTranslationModal({ open: true, field: "Name", fieldKey: "name", multiline: false })}
@@ -561,7 +557,7 @@ export default function MelodyForm() {
{/* Description (localized) */}
<div className="md:col-span-2">
<div className="flex items-center gap-2 mb-1">
<label className="block text-sm font-medium" style={labelStyle}>Description</label>
<label className="ui-form-label">Description</label>
<button
type="button"
onClick={() => setTranslationModal({ open: true, field: "Description", fieldKey: "description", multiline: true })}
@@ -576,31 +572,31 @@ export default function MelodyForm() {
</div>
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Melody Tone</label>
<label className="ui-form-label">Melody Tone</label>
<select value={information.melodyTone} onChange={(e) => updateInfo("melodyTone", e.target.value)} className={inputClass}>
{MELODY_TONES.map((t) => (<option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Type</label>
<label className="ui-form-label">Type</label>
<select value={type} onChange={(e) => setType(e.target.value)} className={inputClass}>
{MELODY_TYPES.map((t) => (<option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Total Archetype Notes</label>
<label className="ui-form-label">Total Archetype Notes</label>
<input type="number" min="1" max="16" value={information.totalNotes} onChange={(e) => { const val = parseInt(e.target.value, 10); updateInfo("totalNotes", Math.max(1, Math.min(16, val || 1))); }} className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Steps</label>
<label className="ui-form-label">Steps</label>
<input type="number" min="0" value={information.steps} onChange={(e) => updateInfo("steps", parseInt(e.target.value, 10) || 0)} className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Min Speed</label>
<label className="ui-form-label">Min Speed</label>
<input type="number" min="0" value={information.minSpeed} onChange={(e) => updateInfo("minSpeed", parseInt(e.target.value, 10) || 0)} className={inputClass} />
{information.minSpeed > 0 && (
<p className="text-xs mt-1" style={mutedStyle}>{minBpm} bpm · {information.minSpeed} ms</p>
@@ -608,7 +604,7 @@ export default function MelodyForm() {
</div>
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Max Speed</label>
<label className="ui-form-label">Max Speed</label>
<input type="number" min="0" value={information.maxSpeed} onChange={(e) => updateInfo("maxSpeed", parseInt(e.target.value, 10) || 0)} className={inputClass} />
{information.maxSpeed > 0 && (
<p className="text-xs mt-1" style={mutedStyle}>{maxBpm} bpm · {information.maxSpeed} ms</p>
@@ -617,7 +613,7 @@ export default function MelodyForm() {
{/* Color */}
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-1" style={labelStyle}>Color</label>
<label className="ui-form-label">Color</label>
<div className="flex items-center gap-2 mb-2">
<span className="w-8 h-8 rounded flex-shrink-0 border" style={{ backgroundColor: information.color ? normalizeColor(information.color) : "transparent", borderColor: "var(--border-primary)" }} />
<input type="text" value={information.color} onChange={(e) => updateInfo("color", e.target.value)} placeholder="e.g. #FF5733 or 0xFF5733" className="flex-1 px-3 py-2 rounded-md text-sm border" />
@@ -645,7 +641,7 @@ export default function MelodyForm() {
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-1" style={labelStyle}>Custom Tags</label>
<label className="ui-form-label">Custom Tags</label>
<div className="flex gap-2 mb-2">
<input type="text" value={tagInput} onChange={(e) => setTagInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addTag(); } }} placeholder="Add a tag and press Enter" className="flex-1 px-3 py-2 rounded-md text-sm border" />
<button type="button" onClick={addTag} className="px-3 py-2 text-sm rounded-md transition-colors" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}>Add</button>
@@ -665,16 +661,18 @@ export default function MelodyForm() {
</section>
{/* --- Identifiers Section --- */}
<section className="rounded-lg p-6 border" style={sectionStyle}>
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Identifiers</h2>
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">Identifiers</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>PID (Playback ID)</label>
<label className="ui-form-label">PID (Playback ID)</label>
<input type="text" value={pid} onChange={(e) => setPid(e.target.value)} placeholder="eg. builtin_festive_vesper" className={inputClass} />
</div>
{url && (
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-1" style={labelStyle}>URL (auto-set from binary upload)</label>
<label className="ui-form-label">URL (auto-set from binary upload)</label>
<input type="text" value={url} readOnly className={inputClass} style={{ opacity: 0.7 }} />
</div>
)}
@@ -685,11 +683,13 @@ export default function MelodyForm() {
{/* ===== Right Column ===== */}
<div className="space-y-6">
{/* --- Default Settings Section --- */}
<section className="rounded-lg p-6 border" style={sectionStyle}>
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Default Settings</h2>
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">Default Settings</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-1" style={labelStyle}>Speed</label>
<label className="ui-form-label">Speed</label>
<div className="flex items-center gap-3">
<input type="range" min="1" max="100" value={settings.speed} onChange={(e) => updateSettings("speed", parseInt(e.target.value, 10))} className="flex-1 h-2 rounded-lg appearance-none cursor-pointer" />
<span className="text-sm font-medium w-12 text-right" style={labelStyle}>{settings.speed}%</span>
@@ -700,7 +700,7 @@ export default function MelodyForm() {
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-1" style={labelStyle}>Duration</label>
<label className="ui-form-label">Duration</label>
<div className="flex items-center gap-3">
<input type="range" min="0" max={Math.max(0, durationValues.length - 1)} value={currentDurationIdx} onChange={(e) => updateSettings("duration", durationValues[parseInt(e.target.value, 10)] ?? 0)} className="flex-1 h-2 rounded-lg appearance-none cursor-pointer" />
<span className="text-sm font-medium w-24 text-right" style={labelStyle}>{formatDuration(settings.duration)}</span>
@@ -709,12 +709,12 @@ export default function MelodyForm() {
</div>
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Total Run Duration</label>
<label className="ui-form-label">Total Run Duration</label>
<input type="number" min="0" value={settings.totalRunDuration} onChange={(e) => updateSettings("totalRunDuration", parseInt(e.target.value, 10) || 0)} className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Pause Duration</label>
<label className="ui-form-label">Pause Duration</label>
<input type="number" min="0" value={settings.pauseDuration} onChange={(e) => updateSettings("pauseDuration", parseInt(e.target.value, 10) || 0)} className={inputClass} />
</div>
@@ -724,13 +724,13 @@ export default function MelodyForm() {
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-1" style={labelStyle}>Echo Ring (comma-separated integers)</label>
<label className="ui-form-label">Echo Ring (comma-separated integers)</label>
<input type="text" value={settings.echoRing.join(", ")} onChange={(e) => updateSettings("echoRing", parseIntList(e.target.value))} placeholder="e.g. 0, 1, 0, 1" className={inputClass} />
</div>
<div className="md:col-span-2">
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium" style={labelStyle}>Note Assignments</label>
<label className="ui-form-label">Note Assignments</label>
<span className="text-xs px-2 py-0.5 rounded-full" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>
{computeTotalActiveBells(settings.noteAssignments)} active bell{computeTotalActiveBells(settings.noteAssignments) !== 1 ? "s" : ""}
</span>
@@ -752,8 +752,10 @@ export default function MelodyForm() {
</section>
{/* --- File Upload Section --- */}
<section className="rounded-lg p-6 border" style={sectionStyle}>
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Files</h2>
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">Files</h2>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2">
<input
@@ -768,7 +770,7 @@ export default function MelodyForm() {
</label>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Binary File (.bsm)</label>
<label className="ui-form-label">Binary File (.bsm)</label>
{(() => {
const { effectiveUrl: binaryUrl, effectiveName: binaryName } = getEffectiveBinary();
const missingArchetype = Boolean(pid) && !builtMelody?.id;
@@ -867,7 +869,7 @@ export default function MelodyForm() {
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Audio Preview (.mp3)</label>
<label className="ui-form-label">Audio Preview (.mp3)</label>
{normalizeFileUrl(existingFiles.preview_url) ? (
<div className="mb-2 space-y-1">
{(() => {
@@ -916,8 +918,10 @@ export default function MelodyForm() {
</div>
{/* --- Admin Notes Section --- */}
<section className="rounded-lg p-6 border mt-6" style={sectionStyle}>
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Admin Notes</h2>
<section className="ui-section-card mt-6">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">Admin Notes</h2>
</div>
<div className="space-y-3">
{adminNotes.map((note, i) => (
<div key={i} className="flex items-start gap-3 rounded-lg p-3 border" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}>

View File

@@ -14,12 +14,7 @@ const DEFAULT_NOTE_ASSIGNMENT_COLORS = [
"#F87171", "#EF4444", "#DC2626", "#B91C1C",
];
const sectionStyle = {
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
};
const headingStyle = { color: "var(--text-heading)" };
const labelStyle = { color: "var(--text-secondary)" };
const mutedStyle = { color: "var(--text-muted)" };
export default function MelodySettings() {
@@ -200,8 +195,10 @@ export default function MelodySettings() {
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* --- Languages Section --- */}
<section className="rounded-lg p-6 border" style={sectionStyle}>
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Available Languages</h2>
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__header-title">Available Languages</h2>
</div>
<div className="space-y-2 mb-4">
{settings.available_languages.map((code) => (
<div
@@ -237,8 +234,10 @@ export default function MelodySettings() {
</section>
{/* --- Quick Colors Section --- */}
<section className="rounded-lg p-6 border" style={sectionStyle}>
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Quick Selection Colors</h2>
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__header-title">Quick Selection Colors</h2>
</div>
<div className="flex flex-wrap gap-3 mb-4">
{settings.quick_colors.map((color) => (
<div key={color} className="relative group">
@@ -279,8 +278,10 @@ export default function MelodySettings() {
</section>
{/* --- Duration Presets Section --- */}
<section className="rounded-lg p-6 xl:col-span-2 border" style={sectionStyle}>
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Duration Presets (seconds)</h2>
<section className="ui-section-card xl:col-span-2">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__header-title">Duration Presets (seconds)</h2>
</div>
<div className="flex flex-wrap gap-2 mb-4">
{settings.duration_values.map((val) => (
<div
@@ -317,8 +318,10 @@ export default function MelodySettings() {
</section>
{/* --- Note Assignment Colors --- */}
<section className="rounded-lg p-6 xl:col-span-2 border" style={sectionStyle}>
<h2 className="text-lg font-semibold mb-2" style={headingStyle}>Note Assignment Color Coding</h2>
<section className="ui-section-card xl:col-span-2">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__header-title">Note Assignment Color Coding</h2>
</div>
<p className="text-xs mb-4" style={mutedStyle}>
Colors used in Composer, Playback, and View table dots. Click a bell to customize.
</p>

View File

@@ -3,8 +3,6 @@ import { useNavigate, useParams } from "react-router-dom";
import api from "../../api/client";
import ConfirmDialog from "../../components/ConfirmDialog";
const sectionStyle = { backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" };
const labelStyle = { color: "var(--text-secondary)" };
const mutedStyle = { color: "var(--text-muted)" };
const inputClass = "w-full px-3 py-2 rounded-md text-sm border";
@@ -262,21 +260,23 @@ export default function ArchetypeForm() {
)}
<div className="space-y-6">
<section className="rounded-lg p-6 border" style={sectionStyle}>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Archetype Info</h2>
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">Archetype Info</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Name *</label>
<label className="ui-form-label">Name *</label>
<input type="text" value={name} onChange={(e) => handleNameChange(e.target.value)} placeholder="e.g. Doksologia_3k" className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>PID (Playback ID) *</label>
<label className="ui-form-label">PID (Playback ID) *</label>
<input type="text" value={pid} onChange={(e) => handlePidChange(e.target.value)} placeholder="e.g. builtin_doksologia_3k" className={inputClass} />
<p className="text-xs mt-1" style={mutedStyle}>Used as the built-in firmware identifier. Must be unique.</p>
</div>
<div className="md:col-span-2">
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium" style={labelStyle}>Steps *</label>
<label className="ui-form-label">Steps *</label>
<span className="text-xs" style={mutedStyle}>{countSteps(steps)} steps</span>
</div>
<textarea
@@ -295,8 +295,10 @@ export default function ArchetypeForm() {
</section>
{isEdit && (
<section className="rounded-lg p-6 border" style={sectionStyle}>
<h2 className="text-lg font-semibold mb-1" style={{ color: "var(--text-heading)" }}>Build</h2>
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">Build</h2>
</div>
<p className="text-sm mb-4" style={mutedStyle}>
Save any changes above before building. Rebuilding will overwrite previous output.
{hasUnsavedChanges && (
@@ -402,7 +404,7 @@ export default function ArchetypeForm() {
)}
{!isEdit && (
<div className="rounded-lg p-4 border text-sm" style={{ borderColor: "var(--border-primary)", ...sectionStyle, color: "var(--text-muted)" }}>
<div className="ui-section-card text-sm" style={{ color: "var(--text-muted)" }}>
Build actions (Binary + PROGMEM Code) will be available after saving.
</div>
)}

View File

@@ -6,20 +6,11 @@ import ConfirmDialog from "../components/ConfirmDialog";
const ROLE_COLORS = {
sysadmin: { bg: "var(--danger-bg)", text: "var(--danger-text)" },
admin: { bg: "#3b2a0a", text: "#f6ad55" },
editor: { bg: "var(--badge-blue-bg)", text: "var(--badge-blue-text)" },
user: { bg: "var(--bg-card-hover)", text: "var(--text-muted)" },
admin: { bg: "#3b2a0a", text: "#f6ad55" },
editor: { bg: "var(--badge-blue-bg)", text: "var(--badge-blue-text)" },
user: { bg: "var(--bg-card-hover)", text: "var(--text-muted)" },
};
const SECTIONS = [
{ key: "melodies", label: "Melodies" },
{ key: "devices", label: "Devices" },
{ key: "app_users", label: "App Users" },
{ key: "equipment", label: "Issues and Notes" },
];
const ACTIONS = ["view", "add", "edit", "delete"];
function Field({ label, children }) {
return (
<div>
@@ -29,6 +20,56 @@ function Field({ label, children }) {
);
}
/** Yes/No badge */
function PBadge({ value }) {
return value ? (
<span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>Yes</span>
) : (
<span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>No</span>
);
}
/** Access level badge for segmented permissions */
function LevelBadge({ viewVal, editVal }) {
if (editVal) return <span className="text-xs px-2 py-0.5 rounded" style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>Edit</span>;
if (viewVal) return <span className="text-xs px-2 py-0.5 rounded" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>View Only</span>;
return <span className="text-xs px-2 py-0.5 rounded" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>No Access</span>;
}
/** A row in the permission display */
function PermRow({ label, value }) {
return (
<div className="flex items-center justify-between py-1.5" style={{ borderBottom: "1px solid var(--border-secondary)" }}>
<span className="text-sm" style={{ color: "var(--text-primary)" }}>{label}</span>
<PBadge value={value} />
</div>
);
}
/** A segmented row in the permission display */
function SegmentedRow({ label, viewVal, editVal }) {
return (
<div className="flex items-center justify-between py-1.5" style={{ borderBottom: "1px solid var(--border-secondary)" }}>
<span className="text-sm" style={{ color: "var(--text-primary)" }}>{label}</span>
<LevelBadge viewVal={viewVal} editVal={editVal} />
</div>
);
}
/** Section card for permissions display */
function PermSection({ title, children }) {
return (
<section className="ui-section-card">
<h2 className="ui-section-card__title-row">
<span className="ui-section-card__title">{title}</span>
</h2>
<div className="pt-2">
{children}
</div>
</section>
);
}
export default function StaffDetail() {
const { id } = useParams();
const navigate = useNavigate();
@@ -101,6 +142,21 @@ export default function StaffDetail() {
const canDelete = canEdit && member.id !== user?.sub;
const roleColors = ROLE_COLORS[member.role] || ROLE_COLORS.user;
const showPerms = (member.role === "editor" || member.role === "user") && member.permissions;
const q = member.permissions || {};
const mel = q.melodies || {};
const dev = q.devices || {};
const usr = q.app_users || {};
const iss = q.issues_notes || {};
const mail = q.mail || {};
const crm = q.crm || {};
const cc = q.crm_customers || {};
const cprod = q.crm_products || {};
const mfg = q.mfg || {};
const apir = q.api_reference || {};
const mqtt = q.mqtt || {};
return (
<div>
{/* Header */}
@@ -116,11 +172,9 @@ export default function StaffDetail() {
</span>
<span
className="px-2 py-0.5 text-xs rounded-full"
style={
member.is_active
? { backgroundColor: "var(--success-bg)", color: "var(--success-text)" }
: { backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }
}
style={member.is_active
? { backgroundColor: "var(--success-bg)", color: "var(--success-text)" }
: { backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }}
>
{member.is_active ? "Active" : "Inactive"}
</span>
@@ -143,97 +197,161 @@ export default function StaffDetail() {
)}
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Account Info */}
<section className="rounded-lg border p-6" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Account Information</h2>
<dl className="grid grid-cols-2 gap-4">
<Field label="Name">{member.name}</Field>
<Field label="Email">{member.email}</Field>
<Field label="Role">
<span className="px-2 py-0.5 text-xs rounded-full capitalize" style={{ backgroundColor: roleColors.bg, color: roleColors.text }}>
{member.role}
</span>
</Field>
<Field label="Status">
<span
className="px-2 py-0.5 text-xs rounded-full"
style={
member.is_active
? { backgroundColor: "var(--success-bg)", color: "var(--success-text)" }
: { backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }
}
>
{member.is_active ? "Active" : "Inactive"}
</span>
</Field>
<Field label="Document ID">
<span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>{member.id}</span>
</Field>
</dl>
{/* Account Info — full width */}
<section className="ui-section-card mb-6">
<h2 className="ui-section-card__header-title mb-4">Account Information</h2>
<dl className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Field label="Name">{member.name}</Field>
<Field label="Email">{member.email}</Field>
<Field label="Role">
<span className="px-2 py-0.5 text-xs rounded-full capitalize" style={{ backgroundColor: roleColors.bg, color: roleColors.text }}>
{member.role}
</span>
</Field>
<Field label="Status">
<span
className="px-2 py-0.5 text-xs rounded-full"
style={member.is_active
? { backgroundColor: "var(--success-bg)", color: "var(--success-text)" }
: { backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }}
>
{member.is_active ? "Active" : "Inactive"}
</span>
</Field>
<Field label="Document ID">
<span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>{member.id}</span>
</Field>
</dl>
</section>
{/* Admin / SysAdmin notice */}
{(member.role === "sysadmin" || member.role === "admin") && (
<section className="ui-section-card">
<h2 className="ui-section-card__header-title mb-3">Permissions</h2>
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
{member.role === "sysadmin"
? "SysAdmin has full god-mode access to all features and settings."
: "Admin has full access to all features, except managing SysAdmin accounts and system-level settings."}
</p>
</section>
)}
{/* Permissions */}
{(member.role === "editor" || member.role === "user") && member.permissions && (
<section className="rounded-lg border p-6" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Permissions</h2>
{/* Permission sections */}
{showPerms && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr style={{ borderBottom: "1px solid var(--border-primary)" }}>
<th className="px-3 py-2 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Section</th>
{ACTIONS.map((a) => (
<th key={a} className="px-3 py-2 text-center font-medium capitalize" style={{ color: "var(--text-secondary)" }}>{a}</th>
))}
</tr>
</thead>
<tbody>
{SECTIONS.map((sec) => {
const sp = member.permissions[sec.key] || {};
return (
<tr key={sec.key} style={{ borderBottom: "1px solid var(--border-secondary)" }}>
<td className="px-3 py-2 font-medium" style={{ color: "var(--text-primary)" }}>{sec.label}</td>
{ACTIONS.map((a) => (
<td key={a} className="px-3 py-2 text-center">
{sp[a] ? (
<span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>Yes</span>
) : (
<span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>No</span>
)}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
{/* Melodies */}
<PermSection title="Melodies">
<PermRow label="View" value={mel.view} />
<PermRow label="Add" value={mel.add} />
<PermRow label="Delete" value={mel.delete} />
<PermRow label="Safe Edit" value={mel.safe_edit || mel.full_edit} />
<PermRow label="Full Edit" value={mel.full_edit} />
<PermRow label="Archetype Access" value={mel.archetype_access} />
<PermRow label="Settings Access" value={mel.settings_access} />
<PermRow label="Compose Access" value={mel.compose_access} />
</PermSection>
<div className="mt-4 pt-4 border-t" style={{ borderColor: "var(--border-secondary)" }}>
<div className="flex items-center gap-3">
<span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>MQTT Access:</span>
{member.permissions.mqtt ? (
<span className="text-xs px-2 py-0.5 rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>Enabled</span>
) : (
<span className="text-xs px-2 py-0.5 rounded-full" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>Disabled</span>
)}
{/* Devices */}
<PermSection title="Devices">
<PermRow label="View" value={dev.view} />
<PermRow label="Add" value={dev.add} />
<PermRow label="Delete" value={dev.delete} />
<PermRow label="Safe Edit (Info)" value={dev.safe_edit || dev.full_edit} />
<PermRow label="Edit Bells" value={dev.edit_bells || dev.full_edit} />
<PermRow label="Edit Clock & Alerts" value={dev.edit_clock || dev.full_edit} />
<PermRow label="Edit Warranty/Sub" value={dev.edit_warranty || dev.full_edit} />
<PermRow label="Full Edit" value={dev.full_edit} />
<PermRow label="Control" value={dev.control} />
</PermSection>
{/* App Users */}
<PermSection title="App Users">
<PermRow label="View" value={usr.view} />
<PermRow label="Add" value={usr.add} />
<PermRow label="Delete" value={usr.delete} />
<PermRow label="Safe Edit" value={usr.safe_edit || usr.full_edit} />
<PermRow label="Full Edit" value={usr.full_edit} />
</PermSection>
{/* Issues & Notes */}
<PermSection title="Issues & Notes">
<PermRow label="View" value={iss.view} />
<PermRow label="Add" value={iss.add} />
<PermRow label="Delete" value={iss.delete} />
<PermRow label="Edit" value={iss.edit} />
</PermSection>
{/* Mail */}
<PermSection title="Mail">
<PermRow label="View Inbox" value={mail.view} />
<PermRow label="Compose" value={mail.compose} />
<PermRow label="Reply" value={mail.reply} />
</PermSection>
{/* CRM */}
<PermSection title="CRM">
<PermRow label="View Activity Log" value={crm.activity_log} />
</PermSection>
{/* CRM Products */}
<PermSection title="CRM Products">
<PermRow label="View" value={cprod.view} />
<PermRow label="Add" value={cprod.add} />
<PermRow label="Edit/Delete" value={cprod.edit} />
</PermSection>
{/* Manufacturing */}
<PermSection title="Manufacturing">
<PermRow label="View Inventory" value={mfg.view_inventory} />
<PermRow label="Edit" value={mfg.edit} />
<PermRow label="Provision Device" value={mfg.provision} />
<SegmentedRow label="Firmware" viewVal={mfg.firmware_view} editVal={mfg.firmware_edit} />
</PermSection>
</div>
{/* CRM Customers — full width */}
<section className="ui-section-card mt-6">
<h2 className="ui-section-card__title-row">
<span className="ui-section-card__title">CRM Customers</span>
{cc.full_access && (
<span className="text-xs px-2 py-0.5 rounded ml-3" style={{ backgroundColor: "rgba(116,184,22,0.15)", color: "var(--accent)" }}>
Full Access
</span>
)}
</h2>
<div className="pt-2 grid grid-cols-1 md:grid-cols-2 gap-x-8">
<div>
<PermRow label="Overview Tab" value={cc.full_access || cc.overview} />
<PermRow label="Add Customer" value={cc.full_access || cc.add} />
<PermRow label="Delete Customer" value={cc.full_access || cc.delete} />
<SegmentedRow label="Orders" viewVal={cc.full_access || cc.orders_view} editVal={cc.full_access || cc.orders_edit} />
<SegmentedRow label="Quotations" viewVal={cc.full_access || cc.quotations_view} editVal={cc.full_access || cc.quotations_edit} />
</div>
<div>
<SegmentedRow label="Files & Media" viewVal={cc.full_access || cc.files_view} editVal={cc.full_access || cc.files_edit} />
<SegmentedRow label="Devices Tab" viewVal={cc.full_access || cc.devices_view} editVal={cc.full_access || cc.devices_edit} />
<PermRow label="Comms: View" value={cc.full_access || cc.comms_view} />
<PermRow label="Comms: Log Entry" value={cc.full_access || cc.comms_log} />
<PermRow label="Comms: Edit Entries" value={cc.full_access || cc.comms_edit} />
<PermRow label="Comms: Compose & Send" value={cc.full_access || cc.comms_compose} />
</div>
</div>
</section>
)}
{(member.role === "sysadmin" || member.role === "admin") && (
<section className="rounded-lg border p-6" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Permissions</h2>
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
{member.role === "sysadmin"
? "SysAdmin has full access to all features and settings."
: "Admin has full access to all features, except managing SysAdmin accounts and system-level settings."}
</p>
</section>
)}
</div>
{/* API Reference + MQTT */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6">
<PermSection title="API Reference">
<PermRow label="Access API Reference" value={apir.access} />
</PermSection>
<PermSection title="MQTT">
<PermRow label="MQTT Access" value={mqtt.access} />
</PermSection>
</div>
</>
)}
{/* Reset Password Dialog */}
{showResetPw && (
@@ -249,7 +367,7 @@ export default function StaffDetail() {
className="w-full px-3 py-2 rounded-md text-sm border mb-3"
style={{ backgroundColor: "var(--bg-input)", color: "var(--text-primary)", borderColor: "var(--border-primary)" }}
/>
{pwError && <p className="text-xs mb-2" style={{ color: "var(--danger-text)" }}>{pwError}</p>}
{pwError && <p className="text-xs mb-2" style={{ color: "var(--danger-text)" }}>{pwError}</p>}
{pwSuccess && <p className="text-xs mb-2" style={{ color: "var(--success-text)" }}>{pwSuccess}</p>}
<div className="flex gap-2 justify-end">
<button

View File

@@ -3,123 +3,278 @@ import { useParams, useNavigate } from "react-router-dom";
import api from "../api/client";
import { useAuth } from "../auth/AuthContext";
const SECTIONS = [
{ key: "melodies", label: "Melodies" },
{ key: "devices", label: "Devices" },
{ key: "app_users", label: "App Users" },
{ key: "equipment", label: "Issues and Notes" },
];
// ─── Default permission sets ───────────────────────────────────────────────
const ACTIONS = ["view", "add", "edit", "delete"];
const DEFAULT_PERMS_EDITOR = {
melodies: { view: true, add: true, edit: true, delete: true },
devices: { view: true, add: true, edit: true, delete: true },
app_users: { view: true, add: true, edit: true, delete: true },
equipment: { view: true, add: true, edit: true, delete: true },
mqtt: true,
const EDITOR_PERMS = {
melodies: { view: true, add: true, delete: true, safe_edit: true, full_edit: true, archetype_access: true, settings_access: true, compose_access: true },
devices: { view: true, add: true, delete: true, safe_edit: true, edit_bells: true, edit_clock: true, edit_warranty: true, full_edit: true, control: true },
app_users: { view: true, add: true, delete: true, safe_edit: true, full_edit: true },
issues_notes: { view: true, add: true, delete: true, edit: true },
mail: { view: true, compose: true, reply: true },
crm: { activity_log: true },
crm_customers: { full_access: true, overview: true, orders_view: true, orders_edit: true, quotations_view: true, quotations_edit: true, comms_view: true, comms_log: true, comms_edit: true, comms_compose: true, add: true, delete: true, files_view: true, files_edit: true, devices_view: true, devices_edit: true },
crm_products: { view: true, add: true, edit: true },
mfg: { view_inventory: true, edit: true, provision: true, firmware_view: true, firmware_edit: true },
api_reference: { access: true },
mqtt: { access: true },
};
const DEFAULT_PERMS_USER = {
melodies: { view: true, add: false, edit: false, delete: false },
devices: { view: true, add: false, edit: false, delete: false },
app_users: { view: true, add: false, edit: false, delete: false },
equipment: { view: true, add: false, edit: false, delete: false },
mqtt: false,
const USER_PERMS = {
melodies: { view: true, add: false, delete: false, safe_edit: false, full_edit: false, archetype_access: false, settings_access: false, compose_access: false },
devices: { view: true, add: false, delete: false, safe_edit: false, edit_bells: false, edit_clock: false, edit_warranty: false, full_edit: false, control: false },
app_users: { view: true, add: false, delete: false, safe_edit: false, full_edit: false },
issues_notes: { view: true, add: false, delete: false, edit: false },
mail: { view: true, compose: false, reply: false },
crm: { activity_log: false },
crm_customers: { full_access: false, overview: true, orders_view: true, orders_edit: false, quotations_view: true, quotations_edit: false, comms_view: true, comms_log: false, comms_edit: false, comms_compose: false, add: false, delete: false, files_view: true, files_edit: false, devices_view: true, devices_edit: false },
crm_products: { view: true, add: false, edit: false },
mfg: { view_inventory: true, edit: false, provision: false, firmware_view: true, firmware_edit: false },
api_reference: { access: false },
mqtt: { access: false },
};
function deepClone(obj) {
return JSON.parse(JSON.stringify(obj));
}
// ─── Dependency rules ──────────────────────────────────────────────────────
function applyDependencies(section, key, value, prev) {
const s = { ...prev[section] };
s[key] = value;
if (value) {
const VIEW_FORCING = ["add", "delete", "safe_edit", "full_edit", "edit_bells", "edit_clock", "edit_warranty", "control", "edit"];
if (VIEW_FORCING.includes(key) && "view" in s) s.view = true;
if (section === "melodies" && key === "full_edit") s.safe_edit = true;
if (section === "devices" && key === "full_edit") { s.safe_edit = true; s.edit_bells = true; s.edit_clock = true; s.edit_warranty = true; s.view = true; }
if (section === "app_users" && key === "full_edit") s.safe_edit = true;
if (section === "crm_customers") {
if (key === "full_access") Object.keys(s).forEach((k) => { s[k] = true; });
if (key === "orders_edit") s.orders_view = true;
if (key === "quotations_edit") s.quotations_view = true;
if (key === "files_edit") s.files_view = true;
if (key === "devices_edit") s.devices_view = true;
if (["comms_log", "comms_edit", "comms_compose"].includes(key)) s.comms_view = true;
}
if (section === "mfg" && key === "firmware_edit") s.firmware_view = true;
if (section === "mfg" && key === "edit") s.view_inventory = true;
if (section === "mfg" && key === "provision") s.view_inventory = true;
}
return { ...prev, [section]: s };
}
// ─── Pill button colors ────────────────────────────────────────────────────
// Disabled / No Access → danger red
// View (middle) → badge blue
// Enabled / Edit → accent green
const PILL_STYLES = {
off: {
active: { bg: "var(--danger-bg)", border: "var(--danger)", color: "var(--danger-text)", fontWeight: 600 },
inactive: { bg: "var(--bg-input)", border: "var(--border-primary)", color: "var(--text-muted)", fontWeight: 400 },
},
view: {
active: { bg: "var(--badge-blue-bg)", border: "var(--badge-blue-text)", color: "var(--badge-blue-text)", fontWeight: 600 },
inactive: { bg: "var(--bg-input)", border: "var(--border-primary)", color: "var(--text-muted)", fontWeight: 400 },
},
on: {
active: { bg: "var(--success-bg)", border: "var(--accent)", color: "var(--success-text)", fontWeight: 600 },
inactive: { bg: "var(--bg-input)", border: "var(--border-primary)", color: "var(--text-muted)", fontWeight: 400 },
},
};
function pillStyle(tone, isActive) {
const s = PILL_STYLES[tone][isActive ? "active" : "inactive"];
return { backgroundColor: s.bg, borderColor: s.border, color: s.color, fontWeight: s.fontWeight };
}
// ─── Sub-components ────────────────────────────────────────────────────────
/**
* A permission row: label/description on the left, dual pill buttons on the right.
* Disabled / Enabled (red / green)
*/
function PermRow({ label, description, value, onChange, disabled }) {
return (
<div
className="flex items-center justify-between gap-4 py-2.5"
style={{
borderBottom: "1px solid var(--border-secondary)",
opacity: disabled ? 0.4 : 1,
pointerEvents: disabled ? "none" : "auto",
}}
>
<div className="min-w-0">
<span className="text-sm" style={{ color: "var(--text-primary)" }}>{label}</span>
{description && (
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>{description}</p>
)}
</div>
<div className="flex rounded-md overflow-hidden border flex-shrink-0" style={{ borderColor: "var(--border-primary)" }}>
<button
type="button"
onClick={() => onChange(false)}
className="px-3 py-1.5 text-xs cursor-pointer transition-colors"
style={{ ...pillStyle("off", !value), borderRight: "1px solid var(--border-primary)" }}
>
Disabled
</button>
<button
type="button"
onClick={() => onChange(true)}
className="px-3 py-1.5 text-xs cursor-pointer transition-colors"
style={pillStyle("on", !!value)}
>
Enabled
</button>
</div>
</div>
);
}
/**
* 3-state row: No Access (red) | View (blue) | Edit (green)
* value: "none" | "view" | "edit"
*/
function SegmentedRow({ label, description, value, onChange, disabled }) {
return (
<div
className="flex items-center justify-between gap-4 py-2.5"
style={{
borderBottom: "1px solid var(--border-secondary)",
opacity: disabled ? 0.4 : 1,
pointerEvents: disabled ? "none" : "auto",
}}
>
<div className="min-w-0">
<span className="text-sm" style={{ color: "var(--text-primary)" }}>{label}</span>
{description && (
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>{description}</p>
)}
</div>
<div className="flex rounded-md overflow-hidden border flex-shrink-0" style={{ borderColor: "var(--border-primary)" }}>
<button
type="button"
onClick={() => onChange("none")}
className="px-3 py-1.5 text-xs cursor-pointer transition-colors"
style={{ ...pillStyle("off", value === "none"), borderRight: "1px solid var(--border-primary)" }}
>
No Access
</button>
<button
type="button"
onClick={() => onChange("view")}
className="px-3 py-1.5 text-xs cursor-pointer transition-colors"
style={{ ...pillStyle("view", value === "view"), borderRight: "1px solid var(--border-primary)" }}
>
View
</button>
<button
type="button"
onClick={() => onChange("edit")}
className="px-3 py-1.5 text-xs cursor-pointer transition-colors"
style={pillStyle("on", value === "edit")}
>
Edit
</button>
</div>
</div>
);
}
/** Section card wrapper */
function PermSection({ title, children }) {
return (
<section className="ui-section-card">
<h2 className="ui-section-card__title-row">
<span className="ui-section-card__title">{title}</span>
</h2>
<div className="pt-1">
{children}
</div>
</section>
);
}
// ─── Main Component ────────────────────────────────────────────────────────
export default function StaffForm() {
const { id } = useParams();
const navigate = useNavigate();
const { user } = useAuth();
const isEdit = !!id;
const [form, setForm] = useState({
name: "",
email: "",
password: "",
role: "user",
is_active: true,
});
const [permissions, setPermissions] = useState({ ...DEFAULT_PERMS_USER });
const [form, setForm] = useState({ name: "", email: "", password: "", role: "user", is_active: true });
const [permissions, setPermissions] = useState(deepClone(USER_PERMS));
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (isEdit) {
setLoading(true);
api.get(`/staff/${id}`)
.then((data) => {
setForm({
name: data.name,
email: data.email,
password: "",
role: data.role,
is_active: data.is_active,
});
if (data.permissions) {
setPermissions(data.permissions);
} else if (data.role === "editor") {
setPermissions({ ...DEFAULT_PERMS_EDITOR });
} else if (data.role === "user") {
setPermissions({ ...DEFAULT_PERMS_USER });
}
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}
if (!isEdit) return;
setLoading(true);
api.get(`/staff/${id}`)
.then((data) => {
setForm({ name: data.name, email: data.email, password: "", role: data.role, is_active: data.is_active });
if (data.permissions) {
const base = data.role === "editor" ? deepClone(EDITOR_PERMS) : deepClone(USER_PERMS);
const merged = {};
Object.keys(base).forEach((sec) => { merged[sec] = { ...base[sec], ...(data.permissions[sec] || {}) }; });
setPermissions(merged);
} else if (data.role === "editor") {
setPermissions(deepClone(EDITOR_PERMS));
} else {
setPermissions(deepClone(USER_PERMS));
}
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [id]);
const handleRoleChange = (newRole) => {
setForm((f) => ({ ...f, role: newRole }));
if (newRole === "editor") {
setPermissions({ ...DEFAULT_PERMS_EDITOR });
} else if (newRole === "user") {
setPermissions({ ...DEFAULT_PERMS_USER });
}
if (newRole === "editor") setPermissions(deepClone(EDITOR_PERMS));
else if (newRole === "user") setPermissions(deepClone(USER_PERMS));
};
const togglePermission = (section, action) => {
setPermissions((prev) => ({
...prev,
[section]: {
...prev[section],
[action]: !prev[section][action],
},
}));
const setPerm = (section, key, value) =>
setPermissions((prev) => applyDependencies(section, key, value, prev));
const setSegmented = (section, viewKey, editKey, val) => {
setPermissions((prev) => {
const next = { ...prev, [section]: { ...prev[section] } };
next[section][viewKey] = val !== "none";
next[section][editKey] = val === "edit";
return next;
});
};
const toggleMqtt = () => {
setPermissions((prev) => ({ ...prev, mqtt: !prev.mqtt }));
const segVal = (section, viewKey, editKey) => {
const s = permissions[section] || {};
if (s[editKey]) return "edit";
if (s[viewKey]) return "view";
return "none";
};
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
setSaving(true);
try {
const body = {
name: form.name,
email: form.email,
role: form.role,
};
const body = { name: form.name, email: form.email, role: form.role };
if (isEdit) {
body.is_active = form.is_active;
if (form.role === "editor" || form.role === "user") {
body.permissions = permissions;
} else {
body.permissions = null;
}
body.permissions = (form.role === "editor" || form.role === "user") ? permissions : null;
await api.put(`/staff/${id}`, body);
navigate(`/settings/staff/${id}`);
} else {
body.password = form.password;
if (form.role === "editor" || form.role === "user") {
body.permissions = permissions;
}
if (form.role === "editor" || form.role === "user") body.permissions = permissions;
const result = await api.post("/staff", body);
navigate(`/settings/staff/${result.id}`);
}
@@ -134,18 +289,36 @@ export default function StaffForm() {
const roleOptions = user?.role === "sysadmin"
? ["sysadmin", "admin", "editor", "user"]
: ["editor", "user"]; // Admin can only create editor/user
: ["editor", "user"];
const inputClass = "w-full px-3 py-2 rounded-md text-sm border";
const inputStyle = { backgroundColor: "var(--bg-input)", color: "var(--text-primary)", borderColor: "var(--border-primary)" };
const showPerms = form.role === "editor" || form.role === "user";
const mel = permissions.melodies || {};
const dev = permissions.devices || {};
const usr = permissions.app_users || {};
const iss = permissions.issues_notes || {};
const mail = permissions.mail || {};
const crm = permissions.crm || {};
const cc = permissions.crm_customers || {};
const cprod = permissions.crm_products || {};
const mfg = permissions.mfg || {};
const apir = permissions.api_reference || {};
const mqtt = permissions.mqtt || {};
const ccLocked = !!cc.full_access;
return (
<div>
<div className="mb-6">
<button onClick={() => navigate(isEdit ? `/settings/staff/${id}` : "/settings/staff")} className="text-sm hover:underline mb-2 inline-block" style={{ color: "var(--accent)" }}>
&larr; {isEdit ? "Back to Staff Member" : "Back to Staff"}
</button>
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>{isEdit ? "Edit Staff Member" : "Add Staff Member"}</h1>
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
{isEdit ? "Edit Staff Member" : "Add Staff Member"}
</h1>
</div>
{error && (
@@ -154,56 +327,29 @@ export default function StaffForm() {
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6 max-w-4xl">
{/* Basic Info */}
<section className="rounded-lg border p-6" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Account Information</h2>
<form onSubmit={handleSubmit} className="space-y-6">
{/* ── Account Information ── */}
<section className="ui-section-card">
<h2 className="ui-section-card__header-title mb-4">Account Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium mb-1 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Name</label>
<input
type="text"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
required
className={inputClass}
style={inputStyle}
/>
<input type="text" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} required className={inputClass} style={inputStyle} />
</div>
<div>
<label className="block text-xs font-medium mb-1 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Email</label>
<input
type="email"
value={form.email}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
required
className={inputClass}
style={inputStyle}
/>
<input type="email" value={form.email} onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))} required className={inputClass} style={inputStyle} />
</div>
{!isEdit && (
<div>
<label className="block text-xs font-medium mb-1 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Password</label>
<input
type="password"
value={form.password}
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
required
minLength={6}
className={inputClass}
style={inputStyle}
placeholder="Min 6 characters"
/>
<input type="password" value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} required minLength={6} className={inputClass} style={inputStyle} placeholder="Min 6 characters" />
</div>
)}
<div>
<label className="block text-xs font-medium mb-1 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Role</label>
<select
value={form.role}
onChange={(e) => handleRoleChange(e.target.value)}
className={`${inputClass} cursor-pointer`}
style={inputStyle}
>
<select value={form.role} onChange={(e) => handleRoleChange(e.target.value)} className={`${inputClass} cursor-pointer`} style={inputStyle}>
{roleOptions.map((r) => (
<option key={r} value={r}>{r.charAt(0).toUpperCase() + r.slice(1)}</option>
))}
@@ -212,12 +358,7 @@ export default function StaffForm() {
{isEdit && (
<div>
<label className="block text-xs font-medium mb-1 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Status</label>
<select
value={form.is_active ? "active" : "inactive"}
onChange={(e) => setForm((f) => ({ ...f, is_active: e.target.value === "active" }))}
className={`${inputClass} cursor-pointer`}
style={inputStyle}
>
<select value={form.is_active ? "active" : "inactive"} onChange={(e) => setForm((f) => ({ ...f, is_active: e.target.value === "active" }))} className={`${inputClass} cursor-pointer`} style={inputStyle}>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
@@ -226,77 +367,234 @@ export default function StaffForm() {
</div>
</section>
{/* Permissions Matrix - only for editor/user */}
{(form.role === "editor" || form.role === "user") && (
<section className="rounded-lg border p-6" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Permissions</h2>
<p className="text-sm mb-4" style={{ color: "var(--text-muted)" }}>
Configure which sections and actions this staff member can access.
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr style={{ borderBottom: "1px solid var(--border-primary)" }}>
<th className="px-3 py-2 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Section</th>
{ACTIONS.map((a) => (
<th key={a} className="px-3 py-2 text-center font-medium capitalize" style={{ color: "var(--text-secondary)" }}>{a}</th>
))}
</tr>
</thead>
<tbody>
{SECTIONS.map((sec) => {
const sp = permissions[sec.key] || {};
return (
<tr key={sec.key} style={{ borderBottom: "1px solid var(--border-secondary)" }}>
<td className="px-3 py-3 font-medium" style={{ color: "var(--text-primary)" }}>{sec.label}</td>
{ACTIONS.map((a) => (
<td key={a} className="px-3 py-3 text-center">
<input
type="checkbox"
checked={!!sp[a]}
onChange={() => togglePermission(sec.key, a)}
className="h-4 w-4 rounded cursor-pointer"
/>
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
<div className="mt-4 pt-4 border-t" style={{ borderColor: "var(--border-secondary)" }}>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={!!permissions.mqtt}
onChange={toggleMqtt}
className="h-4 w-4 rounded cursor-pointer"
/>
<span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>MQTT Access</span>
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
(Dashboard, Commands, Logs, WebSocket)
</span>
</label>
</div>
</section>
)}
{/* ── Admin / SysAdmin notice ── */}
{(form.role === "sysadmin" || form.role === "admin") && (
<section className="rounded-lg border p-6" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Permissions</h2>
<section className="ui-section-card">
<h2 className="ui-section-card__header-title mb-3">Permissions</h2>
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
{form.role === "sysadmin"
? "SysAdmin has full access to all features and settings. No permission customization needed."
? "SysAdmin has full god-mode access to all features and settings. No permission customization needed."
: "Admin has full access to all features, except managing SysAdmin accounts and system-level settings. No permission customization needed."}
</p>
</section>
)}
{/* Submit */}
<div className="flex gap-3">
{/* ── Permission Sections ── */}
{showPerms && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Melodies */}
<PermSection title="Melodies">
<PermRow label="View" value={mel.view} onChange={(v) => setPerm("melodies", "view", v)} />
<PermRow label="Add" value={mel.add} onChange={(v) => setPerm("melodies", "add", v)} />
<PermRow label="Delete" value={mel.delete} onChange={(v) => setPerm("melodies", "delete", v)} />
<PermRow label="Safe Edit" value={mel.safe_edit} onChange={(v) => setPerm("melodies", "safe_edit", v)} description="Name, Description, Tone, Type, Steps, Colour, Tags" disabled={mel.full_edit} />
<PermRow label="Full Edit" value={mel.full_edit} onChange={(v) => setPerm("melodies", "full_edit", v)} description="Edit everything — enables Safe Edit automatically" />
<PermRow label="Archetype Access" value={mel.archetype_access} onChange={(v) => setPerm("melodies", "archetype_access", v)} description="Access the Archetype Editor" />
<PermRow label="Settings Access" value={mel.settings_access} onChange={(v) => setPerm("melodies", "settings_access", v)} description="View & change global melody settings" />
<PermRow label="Compose Access" value={mel.compose_access} onChange={(v) => setPerm("melodies", "compose_access", v)} description="Use the Composer to create melodies" />
</PermSection>
{/* Devices */}
<PermSection title="Devices">
<PermRow label="View" value={dev.view} onChange={(v) => setPerm("devices", "view", v)} />
<PermRow label="Add" value={dev.add} onChange={(v) => setPerm("devices", "add", v)} />
<PermRow label="Delete" value={dev.delete} onChange={(v) => setPerm("devices", "delete", v)} />
<PermRow label="Safe Edit" value={dev.safe_edit} onChange={(v) => setPerm("devices", "safe_edit", v)} description="Edit General Info tab only" disabled={dev.full_edit} />
<PermRow label="Edit Bells" value={dev.edit_bells} onChange={(v) => setPerm("devices", "edit_bells", v)} description="Bell Mechanisms tab" disabled={dev.full_edit} />
<PermRow label="Edit Clock & Alerts" value={dev.edit_clock} onChange={(v) => setPerm("devices", "edit_clock", v)} description="Clock & Alerts tab" disabled={dev.full_edit} />
<PermRow label="Edit Warranty / Sub" value={dev.edit_warranty} onChange={(v) => setPerm("devices", "edit_warranty", v)} description="Warranty and Subscription tab" disabled={dev.full_edit} />
<PermRow label="Full Edit" value={dev.full_edit} onChange={(v) => setPerm("devices", "full_edit", v)} description="Enables all edit options above" />
<PermRow label="Control" value={dev.control} onChange={(v) => setPerm("devices", "control", v)} description="Send commands via the Control tab" />
</PermSection>
{/* App Users */}
<PermSection title="App Users">
<PermRow label="View" value={usr.view} onChange={(v) => setPerm("app_users", "view", v)} />
<PermRow label="Add" value={usr.add} onChange={(v) => setPerm("app_users", "add", v)} />
<PermRow label="Delete" value={usr.delete} onChange={(v) => setPerm("app_users", "delete", v)} />
<PermRow label="Safe Edit" value={usr.safe_edit} onChange={(v) => setPerm("app_users", "safe_edit", v)} description="Photo, Name, Email, Phone, Title only" disabled={usr.full_edit} />
<PermRow label="Full Edit" value={usr.full_edit} onChange={(v) => setPerm("app_users", "full_edit", v)} description="Edit everything — enables Safe Edit automatically" />
</PermSection>
{/* Issues & Notes */}
<PermSection title="Issues & Notes">
<p className="text-xs pb-2 pt-1" style={{ color: "var(--text-muted)", borderBottom: "1px solid var(--border-secondary)" }}>
These permissions also apply to Notes linked from Device and User pages.
</p>
<PermRow label="View" value={iss.view} onChange={(v) => setPerm("issues_notes", "view", v)} />
<PermRow label="Add" value={iss.add} onChange={(v) => setPerm("issues_notes", "add", v)} />
<PermRow label="Delete" value={iss.delete} onChange={(v) => setPerm("issues_notes", "delete", v)} />
<PermRow label="Edit" value={iss.edit} onChange={(v) => setPerm("issues_notes", "edit", v)} />
</PermSection>
{/* Mail */}
<PermSection title="Mail">
<PermRow label="View Inbox" value={mail.view} onChange={(v) => setPerm("mail", "view", v)} />
<PermRow label="Compose" value={mail.compose} onChange={(v) => setPerm("mail", "compose", v)} description="Send new emails" />
<PermRow label="Reply" value={mail.reply} onChange={(v) => setPerm("mail", "reply", v)} description="Reply to existing emails" />
</PermSection>
{/* CRM */}
<PermSection title="CRM">
<PermRow label="View Activity Log" value={crm.activity_log} onChange={(v) => setPerm("crm", "activity_log", v)} />
</PermSection>
{/* CRM Products */}
<PermSection title="CRM Products">
<PermRow label="View" value={cprod.view} onChange={(v) => setPerm("crm_products", "view", v)} />
<PermRow label="Add" value={cprod.add} onChange={(v) => setPerm("crm_products", "add", v)} />
<PermRow label="Edit / Delete" value={cprod.edit} onChange={(v) => setPerm("crm_products", "edit", v)} />
</PermSection>
{/* Manufacturing */}
<PermSection title="Manufacturing">
<PermRow label="View Inventory" value={mfg.view_inventory} onChange={(v) => setPerm("mfg", "view_inventory", v)} />
<PermRow label="Edit" value={mfg.edit} onChange={(v) => setPerm("mfg", "edit", v)} description="Change device status, delete, download NVS Binary" />
<PermRow label="Provision Device" value={mfg.provision} onChange={(v) => setPerm("mfg", "provision", v)} description="Flash devices via the Provisioning page" />
<SegmentedRow
label="Firmware"
value={segVal("mfg", "firmware_view", "firmware_edit")}
onChange={(val) => setSegmented("mfg", "firmware_view", "firmware_edit", val)}
/>
</PermSection>
</div>
{/* ── CRM Customers — full width ── */}
<section className="ui-section-card">
<h2 className="ui-section-card__title-row">
<span className="ui-section-card__title">CRM Customers</span>
</h2>
{/* Full Access row */}
<div
className="flex items-center justify-between gap-4 mt-3 mb-4 p-3 rounded-md"
style={{
border: `1px solid ${cc.full_access ? "var(--accent)" : "var(--border-secondary)"}`,
backgroundColor: cc.full_access ? "rgba(116,184,22,0.07)" : "transparent",
}}
>
<div>
<span className="text-sm font-semibold" style={{ color: cc.full_access ? "var(--accent)" : "var(--text-primary)" }}>
Full Access
</span>
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
Enables all CRM Customer permissions. When active, individual settings are locked.
</p>
</div>
<div className="flex rounded-md overflow-hidden border flex-shrink-0" style={{ borderColor: "var(--border-primary)" }}>
<button
type="button"
onClick={() => setPerm("crm_customers", "full_access", false)}
className="px-3 py-1.5 text-xs cursor-pointer transition-colors"
style={{ ...pillStyle("off", !cc.full_access), borderRight: "1px solid var(--border-primary)" }}
>
Disabled
</button>
<button
type="button"
onClick={() => setPerm("crm_customers", "full_access", true)}
className="px-3 py-1.5 text-xs cursor-pointer transition-colors"
style={pillStyle("on", !!cc.full_access)}
>
Enabled
</button>
</div>
</div>
{/* Two-column grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
{/* Left column */}
<div>
<PermRow
label="Overview Tab"
value={cc.overview}
onChange={(v) => setPerm("crm_customers", "overview", v)}
disabled={ccLocked}
/>
<SegmentedRow
label="Orders"
value={segVal("crm_customers", "orders_view", "orders_edit")}
onChange={(val) => setSegmented("crm_customers", "orders_view", "orders_edit", val)}
disabled={ccLocked}
/>
<SegmentedRow
label="Quotations"
value={segVal("crm_customers", "quotations_view", "quotations_edit")}
onChange={(val) => setSegmented("crm_customers", "quotations_view", "quotations_edit", val)}
disabled={ccLocked}
/>
<SegmentedRow
label="Files & Media"
value={segVal("crm_customers", "files_view", "files_edit")}
onChange={(val) => setSegmented("crm_customers", "files_view", "files_edit", val)}
disabled={ccLocked}
/>
<SegmentedRow
label="Devices Tab"
value={segVal("crm_customers", "devices_view", "devices_edit")}
onChange={(val) => setSegmented("crm_customers", "devices_view", "devices_edit", val)}
disabled={ccLocked}
/>
</div>
{/* Right column */}
<div>
{/* Add + Delete on one row */}
<div
className="flex items-center justify-between gap-4 py-2.5"
style={{
borderBottom: "1px solid var(--border-secondary)",
opacity: ccLocked ? 0.4 : 1,
pointerEvents: ccLocked ? "none" : "auto",
}}
>
<span className="text-sm" style={{ color: "var(--text-primary)" }}>Customer</span>
<div className="flex items-center gap-10 flex-shrink-0">
{/* Add */}
<div className="flex items-center gap-1.5">
<span className="text-xs" style={{ color: "var(--text-muted)" }}>Add:</span>
<div className="flex rounded-md overflow-hidden border" style={{ borderColor: "var(--border-primary)" }}>
<button type="button" onClick={() => setPerm("crm_customers", "add", false)} className="px-3 py-1.5 text-xs cursor-pointer transition-colors" style={{ ...pillStyle("off", !cc.add), borderRight: "1px solid var(--border-primary)" }}>Disabled</button>
<button type="button" onClick={() => setPerm("crm_customers", "add", true)} className="px-3 py-1.5 text-xs cursor-pointer transition-colors" style={pillStyle("on", !!cc.add)}>Enabled</button>
</div>
</div>
{/* Delete */}
<div className="flex items-center gap-1.5">
<span className="text-xs" style={{ color: "var(--text-muted)" }}>Delete:</span>
<div className="flex rounded-md overflow-hidden border" style={{ borderColor: "var(--border-primary)" }}>
<button type="button" onClick={() => setPerm("crm_customers", "delete", false)} className="px-3 py-1.5 text-xs cursor-pointer transition-colors" style={{ ...pillStyle("off", !cc.delete), borderRight: "1px solid var(--border-primary)" }}>Disabled</button>
<button type="button" onClick={() => setPerm("crm_customers", "delete", true)} className="px-3 py-1.5 text-xs cursor-pointer transition-colors" style={pillStyle("on", !!cc.delete)}>Enabled</button>
</div>
</div>
</div>
</div>
<PermRow label="Comms: View" value={cc.comms_view} onChange={(v) => setPerm("crm_customers", "comms_view", v)} disabled={ccLocked} />
<PermRow label="Comms: Log Entry" value={cc.comms_log} onChange={(v) => setPerm("crm_customers", "comms_log", v)} disabled={ccLocked} />
<PermRow label="Comms: Edit Entries" value={cc.comms_edit} onChange={(v) => setPerm("crm_customers", "comms_edit", v)} disabled={ccLocked} />
<PermRow label="Comms: Compose & Send" value={cc.comms_compose} onChange={(v) => setPerm("crm_customers", "comms_compose", v)} disabled={ccLocked} />
</div>
</div>
</section>
{/* ── API Reference + MQTT ── */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<PermSection title="API Reference">
<PermRow label="Access API Reference Page" value={apir.access} onChange={(v) => setPerm("api_reference", "access", v)} />
</PermSection>
<PermSection title="MQTT">
<PermRow label="MQTT Access" value={mqtt.access} onChange={(v) => setPerm("mqtt", "access", v)} description="Dashboard, Commands, Logs, WebSocket" />
</PermSection>
</div>
</>
)}
{/* ── Submit ── */}
<div className="flex gap-3 pb-8">
<button
type="submit"
disabled={saving}
@@ -314,6 +612,7 @@ export default function StaffForm() {
Cancel
</button>
</div>
</form>
</div>
);

View File

@@ -8,12 +8,7 @@ import NotesPanel from "../equipment/NotesPanel";
function Field({ label, children }) {
return (
<div>
<dt
className="text-xs font-medium uppercase tracking-wide"
style={{ color: "var(--text-muted)" }}
>
{label}
</dt>
<dt className="ui-field-label">{label}</dt>
<dd className="mt-1 text-sm" style={{ color: "var(--text-primary)" }}>
{children || "-"}
</dd>
@@ -255,16 +250,10 @@ export default function UserDetail() {
{/* Left column */}
<div className="space-y-6">
{/* Account Info */}
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2
className="text-lg font-semibold mb-4"
style={{ color: "var(--text-heading)" }}
>
Account Information
</h2>
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__header-title">Account Information</h2>
</div>
<div style={{ display: "flex", gap: "1.5rem" }}>
{/* Profile Photo */}
<div className="shrink-0" style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "0.5rem" }}>
@@ -336,32 +325,20 @@ export default function UserDetail() {
</section>
{/* Profile */}
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2
className="text-lg font-semibold mb-4"
style={{ color: "var(--text-heading)" }}
>
Profile
</h2>
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__header-title">Profile</h2>
</div>
<dl className="grid grid-cols-1 gap-4">
<Field label="Bio">{user.bio}</Field>
</dl>
</section>
{/* Timestamps */}
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2
className="text-lg font-semibold mb-4"
style={{ color: "var(--text-heading)" }}
>
Activity
</h2>
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__header-title">Activity</h2>
</div>
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
<Field label="Created Time">{user.created_time}</Field>
<Field label="Created At">{user.createdAt}</Field>
@@ -373,16 +350,10 @@ export default function UserDetail() {
{/* Right column */}
<div className="space-y-6">
{/* Security */}
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2
className="text-lg font-semibold mb-4"
style={{ color: "var(--text-heading)" }}
>
Security
</h2>
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__header-title">Security</h2>
</div>
<dl className="grid grid-cols-2 gap-4">
<Field label="Settings PIN">{user.settingsPIN ? "****" : "-"}</Field>
<Field label="Quick Settings PIN">{user.quickSettingsPIN ? "****" : "-"}</Field>
@@ -390,16 +361,10 @@ export default function UserDetail() {
</section>
{/* Friends */}
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2
className="text-lg font-semibold mb-4"
style={{ color: "var(--text-heading)" }}
>
Friends
</h2>
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__header-title">Friends</h2>
</div>
<dl className="grid grid-cols-2 gap-4">
<Field label="Friends">
{user.friendsList?.length ?? 0}
@@ -411,17 +376,9 @@ export default function UserDetail() {
</section>
{/* Assigned Devices */}
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div className="flex items-center justify-between mb-4">
<h2
className="text-lg font-semibold"
style={{ color: "var(--text-heading)" }}
>
Assigned Devices ({devices.length})
</h2>
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__header-title">Assigned Devices ({devices.length})</h2>
{canEdit && (
<button
onClick={openAssignPanel}

View File

@@ -87,6 +87,11 @@ export default function UserForm() {
}
const inputClass = "w-full px-3 py-2 rounded-md text-sm border";
const inputStyle = {
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-input)",
color: "var(--text-primary)",
};
return (
<div>
@@ -129,16 +134,13 @@ export default function UserForm() {
{/* ===== Left Column ===== */}
<div className="space-y-6">
{/* --- Account Info --- */}
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Account Information
</h2>
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__header-title">Account Information</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Display Name *
</label>
<input
@@ -147,10 +149,11 @@ export default function UserForm() {
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className={inputClass}
style={inputStyle}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Email *
</label>
<input
@@ -159,22 +162,24 @@ export default function UserForm() {
value={email}
onChange={(e) => setEmail(e.target.value)}
className={inputClass}
style={inputStyle}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Phone Number
</label>
<input
type="tel"
type="text"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
placeholder="e.g. +1234567890"
className={inputClass}
style={inputStyle}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
UID
</label>
<input
@@ -182,38 +187,49 @@ export default function UserForm() {
value={uid}
onChange={(e) => setUid(e.target.value)}
className={inputClass}
style={isEdit ? { ...inputStyle, opacity: 0.5 } : inputStyle}
disabled={isEdit}
style={isEdit ? { opacity: 0.5 } : undefined}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Status
</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
className={inputClass}
>
<option value="">Select status</option>
<option value="active">Active</option>
<option value="blocked">Blocked</option>
</select>
<label className="ui-form-label">Status</label>
<div style={{ display: "flex", borderRadius: 6, overflow: "hidden", border: "1px solid var(--border-input)" }}>
{[
{ value: "active", label: "Active", activeColor: "#31ee76", activeBg: "#14532d" },
{ value: "blocked", label: "Blocked", activeColor: "#f34b4b", activeBg: "#3b1a1a" },
].map((opt, idx) => {
const isActive = status === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => setStatus(opt.value)}
style={{
flex: 1, padding: "8px 0", fontSize: 13, fontWeight: 600,
border: "none", cursor: "pointer",
backgroundColor: isActive ? opt.activeBg : "var(--bg-input)",
color: isActive ? opt.activeColor : "var(--text-secondary)",
borderRight: idx === 0 ? "1px solid var(--border-input)" : "none",
transition: "background-color 0.15s",
}}
>
{opt.label}
</button>
);
})}
</div>
</div>
</div>
</section>
{/* --- Profile --- */}
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Profile
</h2>
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__header-title">Profile</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Title
</label>
<input
@@ -222,10 +238,11 @@ export default function UserForm() {
onChange={(e) => setUserTitle(e.target.value)}
placeholder="e.g. Church Administrator"
className={inputClass}
style={inputStyle}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Photo URL
</label>
<input
@@ -233,10 +250,11 @@ export default function UserForm() {
value={photoUrl}
onChange={(e) => setPhotoUrl(e.target.value)}
className={inputClass}
style={inputStyle}
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Bio
</label>
<textarea
@@ -244,7 +262,7 @@ export default function UserForm() {
onChange={(e) => setBio(e.target.value)}
rows={3}
className={inputClass}
style={{ resize: "vertical" }}
style={{ ...inputStyle, resize: "vertical" }}
/>
</div>
</div>
@@ -254,36 +272,37 @@ export default function UserForm() {
{/* ===== Right Column ===== */}
<div className="space-y-6">
{/* --- Security --- */}
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Security
</h2>
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__header-title">Security</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Settings PIN
</label>
<input
type="text"
inputMode="numeric"
value={settingsPIN}
onChange={(e) => setSettingsPIN(e.target.value)}
onChange={(e) => setSettingsPIN(e.target.value.replace(/\D/g, ""))}
placeholder="e.g. 1234"
className={inputClass}
style={inputStyle}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
<label className="ui-form-label">
Quick Settings PIN
</label>
<input
type="text"
inputMode="numeric"
value={quickSettingsPIN}
onChange={(e) => setQuickSettingsPIN(e.target.value)}
onChange={(e) => setQuickSettingsPIN(e.target.value.replace(/\D/g, ""))}
placeholder="e.g. 0000"
className={inputClass}
style={inputStyle}
/>
</div>
</div>