update: Major Overhault to all subsystems
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>BellSystems Admin</title>
|
||||
</head>
|
||||
|
||||
BIN
frontend/public/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
1
frontend/public/favicon.svg
Normal file
|
After Width: | Height: | Size: 43 KiB |
@@ -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>} />
|
||||
|
||||
25
frontend/src/assets/comms/call.svg
Normal 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 |
8
frontend/src/assets/comms/email.svg
Normal 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 |
4
frontend/src/assets/comms/inbound.svg
Normal 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 |
17
frontend/src/assets/comms/inperson.svg
Normal 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 |
8
frontend/src/assets/comms/internal.svg
Normal 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 |
2
frontend/src/assets/comms/mail.svg
Normal 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 |
2
frontend/src/assets/comms/note.svg
Normal 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 |
4
frontend/src/assets/comms/outbound.svg
Normal 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 |
2
frontend/src/assets/comms/sms.svg
Normal 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 |
12
frontend/src/assets/comms/whatsapp.svg
Normal 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 |
@@ -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];
|
||||
};
|
||||
|
||||
|
||||
141
frontend/src/crm/components/CommIcons.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
928
frontend/src/crm/components/ComposeEmailModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
669
frontend/src/crm/components/MailViewModal.jsx
Normal 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, "<")}</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'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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
2883
frontend/src/crm/customers/CustomerDetail.jsx
Normal file
579
frontend/src/crm/customers/CustomerForm.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
174
frontend/src/crm/customers/CustomerList.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
frontend/src/crm/customers/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as CustomerList } from "./CustomerList";
|
||||
export { default as CustomerForm } from "./CustomerForm";
|
||||
export { default as CustomerDetail } from "./CustomerDetail";
|
||||
466
frontend/src/crm/inbox/CommsPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
327
frontend/src/crm/inbox/InboxPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
838
frontend/src/crm/mail/MailPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
293
frontend/src/crm/orders/OrderDetail.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
662
frontend/src/crm/orders/OrderForm.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
207
frontend/src/crm/orders/OrderList.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
frontend/src/crm/orders/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as OrderList } from "./OrderList";
|
||||
export { default as OrderForm } from "./OrderForm";
|
||||
export { default as OrderDetail } from "./OrderDetail";
|
||||
635
frontend/src/crm/products/ProductForm.jsx
Normal 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="Min–Max 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 (min–max 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>
|
||||
);
|
||||
}
|
||||
215
frontend/src/crm/products/ProductList.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
frontend/src/crm/products/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as ProductList } from "./ProductList";
|
||||
export { default as ProductForm } from "./ProductForm";
|
||||
1070
frontend/src/crm/quotations/QuotationForm.jsx
Normal file
438
frontend/src/crm/quotations/QuotationList.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
frontend/src/crm/quotations/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as QuotationForm } from "./QuotationForm";
|
||||
export { default as QuotationList } from "./QuotationList";
|
||||
1490
frontend/src/developer/ApiReferencePage.jsx
Normal 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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 · 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) => (
|
||||
|
||||
@@ -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)" }}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)" }}>
|
||||
← {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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||