Compare commits

..

5 Commits

Author SHA1 Message Date
435aa88e29 update: Added asset upload for bespoke boards 2026-03-31 18:01:32 +03:00
7a5321c097 update: Added NVS Gen on the Flasher 2026-03-27 11:17:10 +02:00
2b05ff8b02 feat: CRM customer/order UI overhaul
Orders:
- Auto-set customer status to ACTIVE when creating a new order (both "+ New Order" and "Init Negotiations")
- Update Status panel now resets datetime to current time each time it opens
- Empty note on status update saves as empty string instead of falling back to previous note
- Default note pre-filled per status type when Update Status panel opens or status changes
- Timeline items now show verbose date/time ("25 March 2026, 4:49 pm") with muted updated-by indicator

CustomerDetail:
- Reordered tabs: Overview | Communication | Quotations | Orders | Finance | Files & Media | Devices | Support
- Renamed "Financials" tab to "Finance"

CustomerList:
- Location column shows city only, falls back to country if city is empty

OverviewTab:
- Hero status container redesigned: icon + status name + verbose description + shimmer border
- Issues, Support, Orders shown as matching hero cards on the same row (status flex-grows to fill space)
- All four cards share identical height, padding, and animated shimmer border effect
- Stat card borders use muted opacity to stay visually consistent with the status card

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 20:21:10 +02:00
5d8ef96d4c update: CRM customers, orders, device detail, and status system changes
- CustomerList, CustomerForm, CustomerDetail: various updates
- Orders: removed OrderDetail and OrderForm, updated OrderList and index
- DeviceDetail: updates
- index.css: added new styles
- CRM_STATUS_SYSTEM_PLAN.md: new planning document
- Added customer-status assets and CustomerDetail subfolder

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:39:38 +02:00
fee686a9f3 feat: add Serial Monitor public page and Log Viewer settings page
- New public page at /serial-monitor: connects to Web Serial (115200 baud),
  streams live output, saves sessions to localStorage + downloads .txt
- New protected page at /settings/serial-logs (admin/sysadmin only):
  lists saved sessions, expandable with full scrollable log, search,
  export and delete per session
- Registered routes in App.jsx and added Log Viewer to Console Settings sidebar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:39:32 +02:00
41 changed files with 4639 additions and 1478 deletions

404
CRM_STATUS_SYSTEM_PLAN.md Normal file
View File

@@ -0,0 +1,404 @@
# CRM Customer Status System — Implementation Plan
## Context
This project is a Vue/React + FastAPI + Firestore admin console located at `C:\development\bellsystems-cp`.
The frontend lives in `frontend/src/` and the backend in `backend/`.
The CRM module is at `frontend/src/crm/` and `backend/crm/`.
Currently, customers have two flat boolean flags on their Firestore document:
- `negotiating: bool`
- `has_problem: bool`
These need to be replaced with a richer, structured system as described below.
---
## 1. Target Data Model
### 1A. On the Customer Document (`customers/{id}`)
Remove `negotiating` and `has_problem`. Add the following:
```
relationship_status: string
— one of: "lead" | "prospect" | "active" | "inactive" | "churned"
— default: "lead"
technical_issues: array of {
active: bool,
opened_date: Firestore Timestamp,
resolved_date: Firestore Timestamp | null,
note: string,
opened_by: string, ← display name or user ID of staff member
resolved_by: string | null
}
install_support: array of {
active: bool,
opened_date: Firestore Timestamp,
resolved_date: Firestore Timestamp | null,
note: string,
opened_by: string,
resolved_by: string | null
}
transaction_history: array of {
date: Firestore Timestamp,
flow: string, ← "invoice" | "payment" | "refund" | "credit"
payment_type: string | null, ← "cash" | "bank_transfer" | "card" | "paypal" — null for invoices
category: string, ← "full_payment" | "advance" | "installment"
amount: number,
currency: string, ← default "EUR"
invoice_ref: string | null,
order_ref: string | null, ← references an order document ID, nullable
recorded_by: string,
note: string
}
```
### 1B. Orders Subcollection (`customers/{id}/orders/{order_id}`)
Orders live **exclusively** as a subcollection under each customer. There is no top-level `orders`
collection. The existing top-level `orders` collection in Firestore and its corresponding backend
routes should be **removed entirely** and replaced with subcollection-based routes under
`/crm/customers/{customer_id}/orders/`.
If cross-customer order querying is ever needed in the future, use Firestore's native
`collectionGroup("orders")` query — no top-level mirror collection is required.
Each order document carries the following fields:
```
order_number: string ← e.g. "ORD-2026-041" (already exists — keep)
title: string ← NEW: human-readable name e.g. "3x Wall Mount Units - Athens Office"
created_by: string ← NEW: staff user ID or display name
status: string ← REPLACE existing OrderStatus enum with new values:
— "negotiating" | "awaiting_quotation" | "awaiting_customer_confirmation"
| "awaiting_fulfilment" | "awaiting_payment" | "manufacturing"
| "shipped" | "installed" | "declined" | "complete"
status_updated_date: Firestore Timestamp ← NEW
status_updated_by: string ← NEW
payment_status: object { ← NEW — replaces the flat PaymentStatus enum
required_amount: number,
received_amount: number, ← computed from transaction_history where order_ref matches
balance_due: number, ← computed: required_amount - received_amount
advance_required: bool,
advance_amount: number | null,
payment_complete: bool
}
timeline: array of { ← NEW — order event log
date: Firestore Timestamp,
type: string, ← "quote_request" | "quote_sent" | "quote_accepted" | "quote_declined"
| "mfg_started" | "mfg_complete" | "order_shipped" | "installed"
| "payment_received" | "invoice_sent" | "note"
note: string,
updated_by: string
}
```
---
## 2. Backend Changes
### 2A. `backend/crm/models.py`
- **Remove** `negotiating: bool` and `has_problem: bool` from `CustomerCreate` and `CustomerUpdate`.
- **Add** `relationship_status: Optional[str] = "lead"` to `CustomerCreate` and `CustomerUpdate`.
- **Add** `technical_issues: List[dict] = []` to `CustomerCreate` and `CustomerUpdate`.
- **Add** `install_support: List[dict] = []` to `CustomerCreate` and `CustomerUpdate`.
- **Add** `transaction_history: List[dict] = []` to `CustomerCreate` and `CustomerUpdate`.
- **Add** proper Pydantic models for each of the above array item shapes:
- `TechnicalIssue` model
- `InstallSupportEntry` model
- `TransactionEntry` model
- **Update** `OrderStatus` enum with the new values:
`negotiating`, `awaiting_quotation`, `awaiting_customer_confirmation`,
`awaiting_fulfilment`, `awaiting_payment`, `manufacturing`,
`shipped`, `installed`, `declined`, `complete`
- **Replace** the flat `PaymentStatus` enum on `OrderCreate` / `OrderUpdate` with a new `OrderPaymentStatus` Pydantic model matching the structure above.
- **Add** `title: Optional[str]`, `created_by: Optional[str]`, `status_updated_date: Optional[str]`,
`status_updated_by: Optional[str]`, and `timeline: List[dict] = []` to `OrderCreate` and `OrderUpdate`.
### 2B. `backend/crm/customers_router.py`
- Update any route that reads/writes `negotiating` or `has_problem` to use the new fields.
- Add new dedicated endpoints:
```
POST /crm/customers/{id}/technical-issues
— body: { note: str, opened_by: str }
— appends a new active issue to the array
PATCH /crm/customers/{id}/technical-issues/{index}/resolve
— body: { resolved_by: str }
— sets active=false and resolved_date=now on the item at that index
POST /crm/customers/{id}/install-support
— same pattern as technical-issues above
PATCH /crm/customers/{id}/install-support/{index}/resolve
— same as technical-issues resolve
POST /crm/customers/{id}/transactions
— body: TransactionEntry (see model above)
— appends to transaction_history
PATCH /crm/customers/{id}/relationship-status
— body: { status: str }
— updates relationship_status field
```
### 2C. `backend/crm/orders_router.py`
- **Remove** all top-level `/crm/orders/` routes entirely.
- Re-implement all order CRUD under `/crm/customers/{customer_id}/orders/`:
```
GET /crm/customers/{customer_id}/orders/
POST /crm/customers/{customer_id}/orders/
GET /crm/customers/{customer_id}/orders/{order_id}
PATCH /crm/customers/{customer_id}/orders/{order_id}
DELETE /crm/customers/{customer_id}/orders/{order_id}
```
- Add endpoint to append a timeline event:
```
POST /crm/customers/{customer_id}/orders/{order_id}/timeline
— body: { type: str, note: str, updated_by: str }
— appends to the timeline array and updates status_updated_date + status_updated_by
```
- Add endpoint to update payment status:
```
PATCH /crm/customers/{customer_id}/orders/{order_id}/payment-status
— body: OrderPaymentStatus fields (partial update allowed)
```
- Add a dedicated "Init Negotiations" endpoint:
```
POST /crm/customers/{customer_id}/orders/init-negotiations
— body: { title: str, note: str, date: datetime, created_by: str }
— creates a new order with status="negotiating", auto-fills all other fields
— simultaneously updates the customer's relationship_status to "active"
(only if currently "lead" or "prospect" — do not downgrade an already "active" customer)
— returns the newly created order document
```
---
## 3. Frontend Changes
### 3A. `frontend/src/crm/customers/CustomerList.jsx`
- When Notes: Quick filter is set, replace the `negotiating` and `has_problem` boolean badge display in the Status column with:
- A **relationship status chip** (color-coded pill: lead=grey, prospect=blue, active=green, inactive=amber, churned=soft red)
- A small **red dot / warning icon** if `technical_issues.some(i => i.active)` is true, under a new "Support" column. Add this column to the list of arrangeable and toggleable columns.
- A small **amber dot / support icon** if `install_support.some(i => i.active)` is true, under the same "Support" column.
- These are derived from the arrays — do not store a separate boolean on the document.
- When Notes: Expanded filter is set, replace the `negotiating` and `has_problem` verbose displays with the active order status (if any) in this format:
`"<Status Label> — <Date> — <Note>"` e.g. `"Negotiating — 24.03.26 — Customer requested a more affordable quotation"`
### 3B. `frontend/src/crm/customers/CustomerDetail.jsx`
The customer detail page currently has a tab structure: Overview, Orders, Quotations, Communication, Files & Media, Devices.
Make the following changes:
#### Whole page
- On the top of the page where we display the name, organization and full address, change it to:
Line 1: `Full Title + Name + Surname`
Line 2: `Organization · City` (city only, not full address)
- Remove the horizontal separation line after the title and before the tabs.
- On the top right side, there is an Edit Customer button. To its left, add **3 new buttons** in this
order (left → right): **Init Negotiations**, **Record Issue/Support**, **Record Payment**, then
the existing Edit button. All 4 buttons are the same size. Add solid single-color icons to each.
**"Init Negotiations" button** (blue/indigo accent):
- Opens a mini modal.
- Fields: Date (defaults to NOW), Title (text input, required), Note (textarea, optional).
- Auto-filled server-side: `status = "negotiating"`, `created_by` = current user,
`status_updated_date` = now, `status_updated_by` = current user,
`payment_status` defaults to zeroed object.
- On confirm: calls `POST /crm/customers/{id}/orders/init-negotiations`.
- After success: refreshes customer data and orders list. The customer's `relationship_status`
is set to `"active"` server-side — no separate frontend call needed.
- This is a fast-entry shortcut only. All subsequent edits to this order happen via the Orders tab.
**"Record Issue/Support" button** (amber/orange accent):
- Opens a mini modal.
- At the top: a **2-button toggle selector** (not a dropdown) to choose: `Technical Issue` | `Install Support`.
- Fields: Date (defaults to NOW), Note (textarea, required).
- On confirm: calls `POST /crm/customers/{id}/technical-issues` or
`POST /crm/customers/{id}/install-support` depending on selection.
**"Record Payment" button** (green accent):
- Opens a mini modal.
- Fields: Date (defaults to NOW), Payment Type (cash | bank transfer | card | paypal),
Category (full payment | advance | installment), Amount (number), Currency (defaults to EUR),
Invoice Ref (searchable over the customer's invoices, optional),
Order Ref (searchable/selectable from the customer's orders, optional),
Note (textarea, optional).
- On confirm: calls `POST /crm/customers/{id}/transactions`.
#### Overview Tab
- The main hero section gets a complete overhaul — start fresh:
- **Row 1 — Relationship Status selector**: The 5 statuses (`lead | prospect | active | inactive | churned`) as styled pill/tab buttons in a row. Current status is highlighted with a glow effect. Color-code using global CSS variables (add to `index.css` if not already present). Clicking a status immediately calls `PATCH /crm/customers/{id}/relationship-status`.
- **Row 2 — Customer info**: All fields except Name and Organization (shown in page header). Include language, religion, tags, etc.
- **Row 3 — Contacts**: All contact entries (phone, email, WhatsApp, etc.).
- **Row 4 — Notes**: Responsive column grid. 1 column below 1100px, 2 columns 11002000px, 3 columns above 2000px. Masonry/wrap layout with no gaps between note cards.
- Move the Latest Orders section to just below the hero section, before Latest Communications.
Hide this section entirely if no orders exist for this customer.
- For all other sections (Latest Communications, Latest Quotations, Devices): hide each section
entirely if it has no data. Show dynamically when data exists.
#### New "Support" Tab (add to TABS array, after Overview)
Two full-width section cards:
**Technical Issues Card**
- Header shows active count badge (e.g. "2 active")
- All issues listed, newest first (active and resolved)
- Each row: colored status dot, opened date, note, opened_by — "Resolve" button if active
- If more than 5 items: list is scrollable (fixed max-height), does not expand the page
- "Report New Issue" button → small inline form with note field + submit
**Install Support Card**
- Identical structure to Technical Issues card
- Same scrollable behavior if more than 5 items
#### New "Financials" Tab (add to TABS array, after Support)
Two sections:
**Active Order Payment Status** (shown only if an active order exists)
- required_amount, received_amount, balance_due
- Advance required indicator + advance amount if applicable
- Payment complete indicator
**Transaction History**
- Ledger table: Date | Flow | Amount | Currency | Method | Category | Order Ref | Invoice Ref | Note | Recorded By | Actions
- "Add Transaction" button → modal with all TransactionEntry fields
- Totals row: Total Invoiced vs Total Paid vs Outstanding Balance
- Each row: right-aligned **Actions** button (consistent with other tables in the project)
with options: **Edit** (opens edit form) and **Delete** (requires confirmation dialog)
#### Orders Tab (existing — update in place)
- Each order card/row shows:
- `title` as primary heading
- `status` with human-readable label and color coding (see Section 4)
- `payment_status` summary: required / received / balance due
- **"View Timeline"** toggle: expands a vertical event log below the order card
- **"Add Timeline Event"** button: small inline form with type dropdown + note field
- Update all API calls to use `/crm/customers/{customer_id}/orders/` routes.
### 3C. `frontend/src/crm/customers/CustomerForm.jsx`
- Remove `negotiating` and `has_problem` fields.
- Add `relationship_status` dropdown (default: `"lead"`).
- No issue/transaction forms needed here — managed from the detail page.
### 3D. `frontend/src/crm/orders/OrderForm.jsx` and `OrderDetail.jsx`
- Update status dropdown with new values and labels:
- `negotiating` → "Negotiating"
- `awaiting_quotation` → "Awaiting Quotation"
- `awaiting_customer_confirmation` → "Awaiting Customer Confirmation"
- `awaiting_fulfilment` → "Awaiting Fulfilment"
- `awaiting_payment` → "Awaiting Payment"
- `manufacturing` → "Manufacturing"
- `shipped` → "Shipped"
- `installed` → "Installed"
- `declined` → "Declined"
- `complete` → "Complete"
- Add `title` input field (required).
- Replace flat `payment_status` enum with the new `payment_status` object fields.
- Add Timeline section to `OrderDetail.jsx`: vertical event log + add-entry inline form.
- Update all API calls to use `/crm/customers/{customer_id}/orders/` routes.
---
## 4. Status Color Coding Reference
Define all as CSS variables in `index.css` and use consistently across all views:
### Relationship Status
| Status | Color |
|---|---|
| lead | grey / muted |
| prospect | blue |
| active | green |
| inactive | amber |
| churned | dark or soft red |
### Order Status
| Status | Color |
|---|---|
| negotiating | blue |
| awaiting_quotation | purple |
| awaiting_customer_confirmation | indigo |
| awaiting_fulfilment | amber |
| awaiting_payment | orange |
| manufacturing | cyan |
| shipped | teal |
| installed | green |
| declined | red |
| complete | muted/grey |
### Issue / Support Flags
| State | Color |
|---|---|
| active issue | red |
| active support | amber |
| resolved | muted/grey |
---
## 5. Migration Notes
- The old `negotiating` and `has_problem` fields will remain in Firestore until the migration script is run. The backend should **read both old and new fields** during the transition period, preferring the new structure if present.
- A one-time migration script (`backend/migrate_customer_flags.py`) should:
1. Read all customer documents
2. If `negotiating: true` → create an order in the customer's `orders` subcollection with `status = "negotiating"` and set `relationship_status = "active"` on the customer
3. If `has_problem: true` → append one entry to `technical_issues` with `active: true`, `opened_date: customer.updated_at`, `note: "Migrated from legacy has_problem flag"`, `opened_by: "system"`
4. Remove `negotiating` and `has_problem` from the customer document
- Do **not** run the migration script until all frontend and backend changes are deployed and tested.
---
## 6. File Summary — What to Touch
```
backend/crm/models.py ← model updates (primary changes)
backend/crm/customers_router.py ← new endpoints + field updates
backend/crm/orders_router.py ← remove top-level routes, re-implement as subcollection,
add timeline + payment-status + init-negotiations endpoints
backend/migrate_customer_flags.py ← NEW one-time migration script
frontend/src/index.css ← add CSS variables for all new status colors
frontend/src/crm/customers/CustomerList.jsx ← relationship status chip + support flag dots column
frontend/src/crm/customers/CustomerDetail.jsx ← page header, 3 new quick-entry buttons + modals,
Overview tab overhaul, new Support tab,
new Financials tab, Orders tab updates
frontend/src/crm/customers/CustomerForm.jsx ← remove old flags, add relationship_status
frontend/src/crm/orders/OrderForm.jsx ← new status values, title field, payment_status,
updated API route paths
frontend/src/crm/orders/OrderDetail.jsx ← timeline section, updated status/payment,
updated API route paths
```
---
## 7. Do NOT Change (out of scope)
- Quotations system — leave as-is
- Communications / inbox — leave as-is
- Files & Media tab — leave as-is
- Devices tab — leave as-is
- Any other module outside `crm/`

View File

@@ -274,9 +274,10 @@ async def download_nvs(
sn: str, sn: str,
hw_type_override: Optional[str] = Query(None, description="Override hw_type written to NVS (for bespoke firmware)"), hw_type_override: Optional[str] = Query(None, description="Override hw_type written to NVS (for bespoke firmware)"),
hw_revision_override: Optional[str] = Query(None, description="Override hw_revision written to NVS (for bespoke firmware)"), hw_revision_override: Optional[str] = Query(None, description="Override hw_revision written to NVS (for bespoke firmware)"),
nvs_schema: Optional[str] = Query(None, description="NVS schema to use: 'legacy' or 'new' (default)"),
user: TokenPayload = Depends(require_permission("manufacturing", "view")), user: TokenPayload = Depends(require_permission("manufacturing", "view")),
): ):
binary = service.get_nvs_binary(sn, hw_type_override=hw_type_override, hw_revision_override=hw_revision_override) binary = service.get_nvs_binary(sn, hw_type_override=hw_type_override, hw_revision_override=hw_revision_override, legacy=(nvs_schema == "legacy"))
await audit.log_action( await audit.log_action(
admin_user=user.email, admin_user=user.email,
action="device_flashed", action="device_flashed",

View File

@@ -197,12 +197,13 @@ def update_device_status(sn: str, data: DeviceStatusUpdate, set_by: str | None =
return _doc_to_inventory_item(doc_ref.get()) return _doc_to_inventory_item(doc_ref.get())
def get_nvs_binary(sn: str, hw_type_override: str | None = None, hw_revision_override: str | None = None) -> bytes: def get_nvs_binary(sn: str, hw_type_override: str | None = None, hw_revision_override: str | None = None, legacy: bool = False) -> bytes:
item = get_device_by_sn(sn) item = get_device_by_sn(sn)
return generate_nvs_binary( return generate_nvs_binary(
serial_number=item.serial_number, serial_number=item.serial_number,
hw_family=hw_type_override if hw_type_override else item.hw_type, hw_family=hw_type_override if hw_type_override else item.hw_type,
hw_revision=hw_revision_override if hw_revision_override else item.hw_version, hw_revision=hw_revision_override if hw_revision_override else item.hw_version,
legacy=legacy,
) )

View File

@@ -1,6 +1,8 @@
import { Routes, Route, Navigate } from "react-router-dom"; import { Routes, Route, Navigate } from "react-router-dom";
import { useAuth } from "./auth/AuthContext"; import { useAuth } from "./auth/AuthContext";
import CloudFlashPage from "./cloudflash/CloudFlashPage"; import CloudFlashPage from "./cloudflash/CloudFlashPage";
import SerialMonitorPage from "./serial/SerialMonitorPage";
import SerialLogViewer from "./serial/SerialLogViewer";
import PublicFeaturesSettings from "./settings/PublicFeaturesSettings"; import PublicFeaturesSettings from "./settings/PublicFeaturesSettings";
import LoginPage from "./auth/LoginPage"; import LoginPage from "./auth/LoginPage";
import MainLayout from "./layout/MainLayout"; import MainLayout from "./layout/MainLayout";
@@ -35,7 +37,7 @@ import DashboardPage from "./dashboard/DashboardPage";
import ApiReferencePage from "./developer/ApiReferencePage"; import ApiReferencePage from "./developer/ApiReferencePage";
import { ProductList, ProductForm } from "./crm/products"; import { ProductList, ProductForm } from "./crm/products";
import { CustomerList, CustomerForm, CustomerDetail } from "./crm/customers"; import { CustomerList, CustomerForm, CustomerDetail } from "./crm/customers";
import { OrderList, OrderForm, OrderDetail } from "./crm/orders"; import { OrderList } from "./crm/orders";
import { QuotationForm, AllQuotationsList } from "./crm/quotations"; import { QuotationForm, AllQuotationsList } from "./crm/quotations";
import CommsPage from "./crm/inbox/CommsPage"; import CommsPage from "./crm/inbox/CommsPage";
import MailPage from "./crm/mail/MailPage"; import MailPage from "./crm/mail/MailPage";
@@ -110,6 +112,7 @@ export default function App() {
<Routes> <Routes>
{/* Public routes — no login required */} {/* Public routes — no login required */}
<Route path="/cloudflash" element={<CloudFlashPage />} /> <Route path="/cloudflash" element={<CloudFlashPage />} />
<Route path="/serial-monitor" element={<SerialMonitorPage />} />
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route <Route
@@ -176,9 +179,6 @@ export default function App() {
<Route path="crm/customers/:id" element={<PermissionGate section="crm"><CustomerDetail /></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/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" 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" element={<PermissionGate section="crm"><AllQuotationsList /></PermissionGate>} /> <Route path="crm/quotations" element={<PermissionGate section="crm"><AllQuotationsList /></PermissionGate>} />
<Route path="crm/quotations/new" element={<PermissionGate section="crm" action="edit"><QuotationForm /></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>} /> <Route path="crm/quotations/:id" element={<PermissionGate section="crm" action="edit"><QuotationForm /></PermissionGate>} />
@@ -196,6 +196,9 @@ export default function App() {
{/* Settings - Public Features */} {/* Settings - Public Features */}
<Route path="settings/public-features" element={<RoleGate roles={["sysadmin", "admin"]}><PublicFeaturesSettings /></RoleGate>} /> <Route path="settings/public-features" element={<RoleGate roles={["sysadmin", "admin"]}><PublicFeaturesSettings /></RoleGate>} />
{/* Settings - Serial Log Viewer */}
<Route path="settings/serial-logs" element={<RoleGate roles={["sysadmin", "admin"]}><SerialLogViewer /></RoleGate>} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Route> </Route>
</Routes> </Routes>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44.73 44.73">
<g id="Layer_1-2" data-name="Layer 1">
<path d="m4.09,8.19v32.45h32.45v4.09H4.09c-1.1,0-2.06-.41-2.87-1.22-.81-.81-1.22-1.77-1.22-2.87V8.19h4.09ZM40.64,0c1.1,0,2.06.41,2.87,1.22.81.81,1.22,1.77,1.22,2.87v28.46c0,1.1-.41,2.05-1.22,2.83-.81.78-1.77,1.17-2.87,1.17H12.18c-1.1,0-2.05-.39-2.83-1.17-.78-.78-1.17-1.72-1.17-2.83V4.09c0-1.1.39-2.06,1.17-2.87.78-.81,1.72-1.22,2.83-1.22h28.46Zm0,32.55V4.09H12.18v28.46h28.46Zm-8.09-8.19c0,1.14-.41,2.1-1.22,2.9-.81.8-1.77,1.19-2.87,1.19h-8.09v-4.09h8.09v-4h-4.09v-4.09h4.09v-4.09h-8.09v-4h8.09c1.1,0,2.06.38,2.87,1.15.81.76,1.22,1.71,1.22,2.85v3.02c0,.88-.29,1.61-.88,2.19-.58.58-1.3.88-2.14.88.84,0,1.56.29,2.14.88.58.58.88,1.3.88,2.14v3.07Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 845 B

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44.73 44.73">
<g id="Layer_1-2" data-name="Layer 1">
<path d="m4.09,8.19v32.45h32.45v4.09H4.09c-1.1,0-2.06-.41-2.87-1.22-.81-.81-1.22-1.77-1.22-2.87V8.19h4.09ZM40.64,0c1.1,0,2.06.41,2.87,1.22.81.81,1.22,1.77,1.22,2.87v28.46c0,1.1-.41,2.05-1.22,2.83-.81.78-1.77,1.17-2.87,1.17H12.18c-1.1,0-2.05-.39-2.83-1.17-.78-.78-1.17-1.72-1.17-2.83V4.09c0-1.1.39-2.06,1.17-2.87.78-.81,1.72-1.22,2.83-1.22h28.46Zm0,32.55V4.09H12.18v28.46h28.46Zm-16.27-4.09c-1.1,0-2.05-.4-2.83-1.19-.78-.8-1.17-1.76-1.17-2.9v-12.18c0-1.14.39-2.09,1.17-2.85.78-.76,1.72-1.15,2.83-1.15h8.19v4h-8.19v4.09h4.09c1.1,0,2.06.4,2.87,1.19.81.8,1.22,1.76,1.22,2.9v4c0,1.14-.41,2.1-1.22,2.9-.81.8-1.77,1.19-2.87,1.19h-4.09Zm0-8.09v4h4.09v-4h-4.09Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 853 B

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44.68 44.68">
<g id="Layer_1-2" data-name="Layer 1">
<path d="m4.09,8.18v32.41h32.41v4.09H4.09c-1.1,0-2.06-.41-2.87-1.22-.81-.81-1.22-1.77-1.22-2.87V8.18h4.09ZM40.59,0c1.1,0,2.06.41,2.87,1.22.81.81,1.22,1.77,1.22,2.87v28.42c0,1.1-.41,2.04-1.22,2.82-.81.78-1.77,1.17-2.87,1.17H12.17c-1.1,0-2.04-.39-2.82-1.17s-1.17-1.72-1.17-2.82V4.09c0-1.1.39-2.06,1.17-2.87.78-.81,1.72-1.22,2.82-1.22h28.42Zm0,32.51V4.09H12.17v28.42h28.42Zm-12.17-24.33c1.1,0,2.06.38,2.87,1.14.81.76,1.22,1.71,1.22,2.85v12.17c0,1.14-.41,2.1-1.22,2.9-.81.8-1.77,1.19-2.87,1.19h-8.08v-4.09h8.08v-3.99h-4.09c-1.1,0-2.04-.4-2.82-1.19-.78-.79-1.17-1.76-1.17-2.9v-4.09c0-1.14.39-2.08,1.17-2.85.78-.76,1.72-1.14,2.82-1.14h4.09Zm0,8.08v-4.09h-4.09v4.09h4.09Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 865 B

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44.14 44.14">
<g id="Layer_1-2" data-name="Layer 1">
<g>
<path d="m24.72,10.46v13.65c0,.25-.09.47-.27.66-.18.18-.42.27-.71.27h-9.72c-.29,0-.53-.09-.71-.27-.18-.18-.27-.4-.27-.66v-1.97c0-.29.09-.53.27-.71.18-.18.42-.27.71-.27h6.83v-10.71c0-.29.09-.53.27-.71s.4-.27.66-.27h1.97c.29,0,.53.09.71.27.18.18.27.42.27.71Z"/>
<path d="m0,22.07c0-6.09,2.16-11.3,6.49-15.62C10.81,2.12,16.01-.03,22.07,0c6.06.03,11.27,2.18,15.62,6.44,4.35,4.27,6.5,9.48,6.44,15.62-.06,6.15-2.21,11.36-6.44,15.62-4.24,4.27-9.45,6.41-15.62,6.44-6.18.03-11.37-2.12-15.58-6.44C2.28,33.37.12,28.16,0,22.07Zm4.81,0c0,4.77,1.69,8.83,5.08,12.18,3.38,3.35,7.44,5.05,12.18,5.08,4.74.03,8.8-1.66,12.18-5.08,3.38-3.41,5.08-7.47,5.08-12.18s-1.69-8.77-5.08-12.18c-3.38-3.41-7.44-5.1-12.18-5.08-4.74.03-8.8,1.72-12.18,5.08-3.38,3.35-5.08,7.42-5.08,12.18Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 979 B

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44.14 44.14">
<g id="Layer_1-2" data-name="Layer 1">
<g>
<path d="m0,22.07c0-6.09,2.16-11.3,6.49-15.62C10.81,2.12,16.01-.03,22.07,0c6.06.03,11.27,2.18,15.62,6.44,4.35,4.27,6.5,9.48,6.44,15.62-.06,6.15-2.21,11.36-6.44,15.62s-9.45,6.41-15.62,6.44c-6.18.03-11.37-2.12-15.58-6.44C2.28,33.37.12,28.16,0,22.07Zm4.81,0c0,4.77,1.69,8.83,5.08,12.18,3.38,3.35,7.44,5.05,12.18,5.08,4.74.03,8.8-1.66,12.18-5.08,3.38-3.41,5.08-7.47,5.08-12.18s-1.69-8.77-5.08-12.18c-3.38-3.41-7.44-5.1-12.18-5.08-4.74.03-8.8,1.72-12.18,5.08-3.38,3.35-5.08,7.42-5.08,12.18Z"/>
<path d="m23.69,30.21c1.22-.03,2.25-.5,3.1-1.38.85-.89,1.28-1.96,1.28-3.21,0-1.01-.3-1.93-.91-2.77-.61-.83-1.38-1.39-2.32-1.67l-4.59-1.36c-.28-.07-.5-.23-.65-.47-.16-.24-.23-.54-.23-.89s.11-.65.34-.91c.23-.26.51-.39.86-.39h2.87c.42,0,.85.14,1.3.42.14.1.3.15.5.13.19-.02.37-.1.55-.23l1.2-1.15c.17-.14.25-.34.23-.6-.02-.26-.11-.46-.29-.6-.9-.7-1.98-1.08-3.23-1.15v-2.5c0-.24-.08-.44-.23-.6s-.34-.23-.55-.23h-1.67c-.21,0-.39.08-.55.23s-.23.36-.23.6v2.45c-1.22.04-2.25.5-3.1,1.38-.85.89-1.28,1.96-1.28,3.21,0,1.01.3,1.93.91,2.77.61.83,1.38,1.39,2.32,1.67l4.59,1.36c.24.07.45.23.63.5.17.26.26.53.26.81,0,.38-.11.7-.34.97-.23.26-.51.39-.86.39h-2.87c-.38,0-.82-.12-1.3-.37-.17-.1-.36-.16-.55-.16s-.36.07-.5.21l-1.2,1.15c-.17.17-.25.39-.23.65.02.26.13.46.34.6.94.7,2,1.08,3.18,1.15v2.45c0,.24.08.44.23.6.16.16.34.23.55.23h1.67c.21,0,.39-.08.55-.23.16-.16.23-.36.23-.6v-2.45Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48.38 43">
<g id="Layer_1-2" data-name="Layer 1">
<g>
<path d="m32.25,10.23c0-.54-.2-1.02-.6-1.42L23.43.6c-.4-.4-.87-.6-1.42-.6h-.52v10.75h10.75v-.52Z"/>
<path d="m19.4,29.15c-.26-.26-.39-.57-.39-.95v-2.67c0-.37.13-.69.39-.97.26-.27.57-.41.95-.41h11.9v-10.75h-11.44c-.54,0-1.01-.19-1.4-.58-.39-.39-.58-.85-.58-1.4V0H2.02c-.54,0-1.02.19-1.42.58-.4.39-.6.87-.6,1.44v38.96c0,.54.2,1.02.6,1.42s.87.6,1.42.6h28.21c.57,0,1.05-.2,1.44-.6.39-.4.58-.87.58-1.42v-11.44h-11.9c-.37,0-.69-.13-.95-.39Zm-5.34-2.56c-.7.73-1.56,1.11-2.56,1.14v2.02c0,.2-.06.37-.19.49-.13.13-.28.19-.45.19h-1.38c-.17,0-.32-.06-.45-.19-.13-.13-.19-.29-.19-.49v-2.02c-.97-.06-1.85-.37-2.62-.95-.17-.11-.27-.28-.28-.49-.01-.22.05-.39.19-.54l.99-.95c.11-.11.25-.17.41-.17s.31.04.45.13c.4.2.76.3,1.07.3h2.37c.29,0,.52-.11.71-.32.19-.22.28-.48.28-.8,0-.23-.07-.45-.21-.67-.14-.22-.32-.35-.52-.41l-3.78-1.12c-.77-.23-1.41-.69-1.91-1.38-.5-.69-.75-1.45-.75-2.28,0-1.03.35-1.91,1.05-2.64.7-.73,1.55-1.11,2.56-1.14v-2.02c0-.2.06-.37.19-.49.13-.13.28-.19.45-.19h1.38c.17,0,.32.06.45.19.13.13.19.29.19.49v2.06c1.03.06,1.92.37,2.67.95.14.11.22.28.24.49.01.22-.05.38-.19.49l-.99.95c-.14.11-.29.18-.45.19-.16.01-.29-.02-.41-.11-.37-.23-.73-.34-1.07-.34h-2.37c-.29,0-.52.11-.71.32-.19.21-.28.47-.28.75s.06.53.19.73c.13.2.31.33.54.39l3.78,1.12c.77.23,1.41.69,1.91,1.38.5.69.75,1.45.75,2.28,0,1.03-.35,1.91-1.05,2.64Z"/>
<path d="m47.95,25.89l-8.04-8.13c-.26-.26-.57-.39-.95-.39s-.69.13-.95.39c-.26.26-.39.57-.39.95v5.46h-5.37v5.37h5.37v5.5c0,.4.14.72.41.97.27.24.59.37.95.39.36.01.67-.12.92-.41l8.04-8.13c.29-.29.43-.62.43-1.01s-.14-.71-.43-.97Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 43.63 43.63">
<g id="Layer_1-2" data-name="Layer 1">
<path d="m2.52,0l41.1,41.15-2.52,2.48-11.34-11.34H11.91l-7.91,7.91V6.53L0,2.52,2.52,0Zm37.2.57c1.08,0,2,.38,2.76,1.14s1.14,1.68,1.14,2.76v23.81c0,1.08-.37,2.01-1.1,2.79-.73.78-1.64,1.18-2.71,1.21l-13.86-13.86h9.76v-4h-13.76l-1.95-1.95h15.72v-4h-15.91v3.81L8.1.57h31.63ZM11.91,18.43h4l-4-4v4Zm4,5.95v-4h-4v4h4Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 510 B

View File

@@ -0,0 +1,5 @@
<?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="M8 7C9.65685 7 11 5.65685 11 4C11 2.34315 9.65685 1 8 1C6.34315 1 5 2.34315 5 4C5 5.65685 6.34315 7 8 7Z" fill="currentColor"/>
<path d="M14 12C14 10.3431 12.6569 9 11 9H5C3.34315 9 2 10.3431 2 12V15H14V12Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 466 B

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44.14 44.14">
<g id="Layer_1-2" data-name="Layer 1">
<path d="m0,22.07c0-6.09,2.16-11.3,6.49-15.62C10.81,2.12,16.01-.03,22.07,0c6.06.03,11.27,2.18,15.62,6.44,4.35,4.27,6.5,9.48,6.44,15.62-.06,6.15-2.21,11.36-6.44,15.62-4.24,4.27-9.45,6.41-15.62,6.44-6.18.03-11.37-2.12-15.58-6.44C2.28,33.37.12,28.16,0,22.07Zm4.81,0c0,4.77,1.69,8.83,5.08,12.18,3.38,3.35,7.44,5.05,12.18,5.08,4.74.03,8.8-1.66,12.18-5.08,3.38-3.41,5.08-7.47,5.08-12.18s-1.69-8.77-5.08-12.18c-3.38-3.41-7.44-5.1-12.18-5.08-4.74.03-8.8,1.72-12.18,5.08-3.38,3.35-5.08,7.42-5.08,12.18Zm6.4,6.05l6.05-6.05-6.05-6.05,4.81-4.81,6.05,6.05,6.05-6.05,4.81,4.81-6.05,6.05,6.05,6.05-4.81,4.81-6.05-6.05-6.05,6.05-4.81-4.81Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 824 B

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 39.16">
<g id="Layer_1-2" data-name="Layer 1">
<path d="m43.51,33.64c.44.81.59,1.64.43,2.51-.16.86-.57,1.58-1.23,2.15-.67.57-1.46.86-2.37.86H3.66c-.91,0-1.7-.29-2.37-.86-.67-.57-1.08-1.29-1.23-2.15-.16-.86-.01-1.7.43-2.51L18.81,1.86c.44-.81,1.09-1.36,1.94-1.64.85-.29,1.69-.29,2.52,0,.83.29,1.47.84,1.92,1.64l18.32,31.78Zm-21.49-6.58c-.97,0-1.79.35-2.49,1.04-.69.69-1.04,1.52-1.04,2.49s.35,1.79,1.04,2.49c.69.69,1.52,1.04,2.49,1.04s1.79-.34,2.47-1.02c.68-.68,1.02-1.51,1.02-2.49s-.34-1.81-1.02-2.51c-.68-.69-1.5-1.04-2.47-1.04Zm-3.37-12.64l.59,10.41c0,.23.09.44.27.61.18.17.39.25.63.25h3.72c.23,0,.44-.08.63-.25.18-.17.27-.37.27-.61l.59-10.41c.03-.26-.05-.48-.23-.67-.18-.18-.42-.27-.7-.27h-4.81c-.26,0-.48.09-.67.27-.18.18-.27.4-.27.67Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 888 B

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44.14 44.14">
<g id="Layer_1-2" data-name="Layer 1">
<path d="m0,22.07c0-6.09,2.16-11.3,6.49-15.62C10.81,2.12,16.01-.03,22.07,0c6.06.03,11.27,2.18,15.62,6.44,4.35,4.27,6.5,9.48,6.44,15.62-.06,6.15-2.21,11.36-6.44,15.62s-9.45,6.41-15.62,6.44c-6.18.03-11.37-2.12-15.58-6.44C2.28,33.37.12,28.16,0,22.07Zm4.81,0c0,4.77,1.69,8.83,5.08,12.18,3.38,3.35,7.44,5.05,12.18,5.08,4.74.03,8.8-1.66,12.18-5.08,3.38-3.41,5.08-7.47,5.08-12.18s-1.69-8.77-5.08-12.18c-3.38-3.41-7.44-5.1-12.18-5.08-4.74.03-8.8,1.72-12.18,5.08-3.38,3.35-5.08,7.42-5.08,12.18Zm4.68,2.25l4.15-4.06,4.1,4.15,12.76-12.84,4.15,4.1-12.84,12.76-4.06,4.15-4.1-4.15-4.15-4.1Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 777 B

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 41.48 41.48">
<g id="Layer_1-2" data-name="Layer 1">
<path d="m31.16,20.74c0,.57-.21,1.05-.62,1.45-.42.4-.92.6-1.52.6H8.28L0,31.16V2.04C0,1.48.2,1,.6.6c.4-.4.88-.6,1.45-.6h26.97c.6,0,1.1.2,1.52.6.42.4.62.88.62,1.45v18.7Zm8.28-12.46c.56,0,1.05.2,1.45.6.4.4.6.88.6,1.45v31.16l-8.28-8.28H10.32c-.57,0-1.05-.2-1.45-.6s-.6-.88-.6-1.45v-4.19h26.97V8.28h4.19Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 500 B

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.58579 4.58579C5 5.17157 5 6.11438 5 8V17C5 18.8856 5 19.8284 5.58579 20.4142C6.17157 21 7.11438 21 9 21H15C16.8856 21 17.8284 21 18.4142 20.4142C19 19.8284 19 18.8856 19 17V8C19 6.11438 19 5.17157 18.4142 4.58579C17.8284 4 16.8856 4 15 4H9C7.11438 4 6.17157 4 5.58579 4.58579ZM9 8C8.44772 8 8 8.44772 8 9C8 9.55228 8.44772 10 9 10H15C15.5523 10 16 9.55228 16 9C16 8.44772 15.5523 8 15 8H9ZM9 12C8.44772 12 8 12.4477 8 13C8 13.5523 8.44772 14 9 14H15C15.5523 14 16 13.5523 16 13C16 12.4477 15.5523 12 15 12H9ZM9 16C8.44772 16 8 16.4477 8 17C8 17.5523 8.44772 18 9 18H13C13.5523 18 14 17.5523 14 17C14 16.4477 13.5523 16 13 16H9Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 930 B

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 53.85 41.66">
<g id="Layer_1-2" data-name="Layer 1">
<g>
<path d="m39.43,33.73l.04-.51.72.55c1.2.88,2.82.73,3.84-.36.91-.97.88-2.36-.08-3.54-4.03-4.91-8.06-9.82-12.09-14.72l-3.61-4.4q-.5-.6-1.18-.22l-1.56.87c-1.59.89-3.17,1.78-4.77,2.65-2.53,1.37-5.1,1.54-7.65.51-1.77-.72-2.79-2.07-2.94-3.89-.08-.95.1-1.87.54-2.84.13-.28.15-.5.07-.64-.08-.14-.3-.23-.6-.25-.12,0-.23,0-.35,0H1.75C.36,6.94,0,7.31,0,8.71,0,14.45,0,20.19,0,25.93c0,.21,0,.46.04.68.1.65.48.99,1.14,1.01.46.01.91.01,1.37,0,.36,0,.72,0,1.08,0,.2.02.32-.06.45-.22.31-.39.63-.77.94-1.16.22-.26.43-.52.64-.78,1.1-1.35,2.65-2.11,4.26-2.11.24,0,.48.02.72.05,1.85.26,3.42,1.52,4.2,3.35.11.27.24.39.47.48,1.48.53,2.49,1.55,3.03,3.01.1.28.25.42.55.54,1.01.4,1.78,1.03,2.31,1.87.12.18.26.28.54.36,1.67.45,2.86,1.44,3.52,2.95.72,1.64.64,3.29-.25,4.9-.16.28-.18.38-.18.42.02.03.16.1.41.19.72.27,1.52.22,2.21-.14.68-.36,1.17-.99,1.34-1.73.05-.23.08-.46.12-.7.02-.12.03-.24.05-.37l.08-.51.38.35c.87.79,2.07,1.04,3.07.63,1.02-.42,1.64-1.45,1.7-2.81l.02-.56.43.36c1.05.88,2.09,1.07,3.18.58,1.01-.45,1.5-1.3,1.61-2.84Z"/>
<path d="m17.3,6.35c-1.11.85-2.22,1.7-3.34,2.54-.6.45-.82.96-.72,1.61.11.65.48,1.06,1.19,1.32,1.75.62,3.4.46,5.06-.5,2.23-1.29,4.52-2.57,6.72-3.81l.74-.42c.44-.24.82-.37,1.18-.37.54,0,1.02.29,1.51.88l17.11,20.77c.07.08.28.34.38.35.06,0,.16-.04.4-.26.7-.64,1.51-.93,2.43-.87.87.05,1.74.03,2.59,0,.73-.02,1.13-.37,1.24-1.1.04-.27.04-.55.04-.79,0-2.26,0-4.52,0-6.77v-4.43c0-2.02,0-4.05,0-6.07,0-1.1-.38-1.47-1.5-1.48h-.13c-.66,0-1.32-.01-1.98,0-1.08,0-2.11-.16-3.1-.54l-2.48-.95c-4.16-1.6-8.33-3.2-12.5-4.77-3.24-1.22-6.29-.79-9.06,1.28-1.94,1.45-3.87,2.92-5.8,4.4Z"/>
<path d="m12.13,28.29c-.13-.93-.63-1.55-1.46-1.83-.93-.31-1.75-.08-2.43.69-.51.57-1,1.19-1.47,1.78l-.46.58c-.7.87-.78,1.95-.19,2.82.68,1,1.76,1.4,2.84,1.03.21-.07.44-.13.62,0,.21.14.21.41.22.6.02.79.35,1.47.92,1.92.58.46,1.34.61,2.13.44.21-.05.43-.07.62.05.18.13.24.35.27.57.1.76.48,1.39,1.04,1.78.55.38,1.24.49,1.95.32.13-.03.24-.05.35-.05.3,0,.53.15.69.6.42,1.16,1.68,1.82,2.85,1.51,1.06-.29,2.07-1.74,2.07-3,0-.72-.28-1.34-.8-1.77-.52-.43-1.19-.58-1.89-.45-.55.11-.83-.07-.96-.61-.31-1.38-1.31-2.04-2.74-1.82-.21.03-.48.05-.64-.14-.19-.21-.1-.51-.07-.64.09-.33.1-.68.02-1.03-.13-.62-.48-1.13-.97-1.44-.54-.33-1.22-.41-1.92-.23-.02,0-.04.02-.07.03-.11.05-.38.17-.61-.06-.24-.24-.11-.52-.05-.62.12-.41.2-.72.15-1.04Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg height="800px" width="800px" version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512" xml:space="preserve">
<style type="text/css">
.st0{fill:currentColor;}
</style>
<g>
<path class="st0" d="M116.713,337.355c-20.655,0-37.456,16.801-37.456,37.456c0,20.655,16.802,37.455,37.456,37.455
c20.649,0,37.448-16.8,37.448-37.455C154.161,354.156,137.362,337.355,116.713,337.355z"/>
<path class="st0" d="M403.81,337.355c-20.649,0-37.449,16.801-37.449,37.456c0,20.655,16.8,37.455,37.449,37.455
c20.649,0,37.45-16.8,37.45-37.455C441.261,354.156,424.459,337.355,403.81,337.355z"/>
<path class="st0" d="M497.571,99.735H252.065c-7.974,0-14.429,6.466-14.429,14.44v133.818c0,7.972,6.455,14.428,14.429,14.428
h245.506c7.966,0,14.429-6.456,14.429-14.428V114.174C512,106.201,505.538,99.735,497.571,99.735z"/>
<path class="st0" d="M499.966,279.409H224.225c-6.64,0-12.079-5.439-12.079-12.079V111.739c0-6.638-5.359-11.999-11.999-11.999
H90.554c-3.599,0-6.96,1.602-9.281,4.32L2.801,198.213C1.039,200.373,0,203.094,0,205.893v125.831
c0,6.64,5.439,11.999,12.079,11.999h57.516c10.08-15.358,27.438-25.438,47.118-25.438c19.678,0,37.036,10.08,47.116,25.438h192.868
c10.079-15.358,27.438-25.438,47.116-25.438c19.678,0,37.039,10.08,47.118,25.438h49.036c6.64,0,11.999-5.359,11.999-11.999
v-40.316C511.965,284.768,506.606,279.409,499.966,279.409z M43.997,215.493v-8.32c0-2.881,0.961-5.601,2.72-7.84l50.157-61.675
c2.318-2.881,5.839-4.56,9.599-4.56h49.116c6.8,0,12.4,5.519,12.4,12.4v69.995c0,6.798-5.599,12.398-12.4,12.398H56.396
C49.516,227.891,43.997,222.292,43.997,215.493z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 41.05 41.09">
<g id="Layer_1-2" data-name="Layer 1">
<g>
<path d="m0,41.09h41.05V15.4H0v25.69Zm13.42-14.41l3.62,3.66,11.24-11.32,3.66,3.62-11.32,11.24-3.58,3.66-3.62-3.66-3.66-3.62,3.66-3.58Z"/>
<polygon points="35.9 0 23.12 0 23.12 10.25 41.05 10.25 35.9 0"/>
<polygon points="17.97 0 5.15 0 0 10.25 17.97 10.25 17.97 0"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 495 B

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44.14 44.14">
<g id="Layer_1-2" data-name="Layer 1">
<path d="m0,22.07c0-6.09,2.16-11.3,6.49-15.62C10.81,2.12,16.01-.03,22.07,0c6.06.03,11.27,2.18,15.62,6.44,4.35,4.27,6.5,9.48,6.44,15.62-.06,6.15-2.21,11.36-6.44,15.62-4.24,4.27-9.45,6.41-15.62,6.44-6.18.03-11.37-2.12-15.58-6.44C2.28,33.37.12,28.16,0,22.07Zm8.78,8.87l4.41,4.41,12.49-12.54c1.21.47,2.52.56,3.93.26,1.41-.29,2.62-.94,3.62-1.94,1-1,1.65-2.19,1.94-3.58.29-1.38.21-2.69-.26-3.93l-4.41,4.37-3.18-1.19-1.19-3.18,4.37-4.41c-.74-.29-1.53-.44-2.38-.44-2.03,0-3.72.72-5.08,2.16-1.06.97-1.72,2.16-1.99,3.58-.26,1.41-.19,2.72.22,3.93l-12.49,12.49Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 750 B

View File

@@ -6,6 +6,11 @@ import ComposeEmailModal from "../components/ComposeEmailModal";
import MailViewModal from "../components/MailViewModal"; import MailViewModal from "../components/MailViewModal";
import QuotationList from "../quotations/QuotationList"; import QuotationList from "../quotations/QuotationList";
import { CommTypeIconBadge, CommDirectionIcon } from "../components/CommIcons"; import { CommTypeIconBadge, CommDirectionIcon } from "../components/CommIcons";
import OverviewTab from "./CustomerDetail/OverviewTab";
import SupportTab from "./CustomerDetail/SupportTab";
import FinancialsTab from "./CustomerDetail/FinancialsTab";
import OrdersTab from "./CustomerDetail/OrdersTab";
import { InitNegotiationsModal, RecordIssueModal, RecordPaymentModal } from "./CustomerDetail/QuickEntryModals";
// Inline SVG icons — all use currentColor // Inline SVG icons — all use currentColor
const IconExpand = ({ size = 13 }) => <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 8L21 3M21 3H16M21 3V8M8 8L3 3M3 3L3 8M3 3L8 3M8 16L3 21M3 21H8M3 21L3 16M16 16L21 21M21 21V16M21 21H16"/></svg>; const IconExpand = ({ size = 13 }) => <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 8L21 3M21 3H16M21 3V8M8 8L3 3M3 3L3 8M3 3L8 3M8 16L3 21M3 21H8M3 21L3 16M16 16L21 21M21 21V16M21 21H16"/></svg>;
@@ -136,7 +141,7 @@ function ReadField({ label, value }) {
); );
} }
const TABS = ["Overview", "Orders", "Quotations", "Communication", "Files & Media", "Devices"]; const TABS = ["Overview", "Communication", "Quotations", "Orders", "Finance", "Files & Media", "Devices", "Support"];
const LANGUAGE_LABELS = { const LANGUAGE_LABELS = {
el: "Greek", el: "Greek",
@@ -452,17 +457,24 @@ export default function CustomerDetail() {
const [error, setError] = useState(""); const [error, setError] = useState("");
const [activeTab, setActiveTab] = useState(() => { const [activeTab, setActiveTab] = useState(() => {
const tab = searchParams.get("tab"); const tab = searchParams.get("tab");
const TABS = ["Overview", "Orders", "Quotations", "Communication", "Files & Media", "Devices"]; const TABS = ["Overview", "Communication", "Quotations", "Orders", "Finance", "Files & Media", "Devices", "Support"];
// Also accept old tab names for backwards compat
if (tab === "Financials") return "Finance";
return TABS.includes(tab) ? tab : "Overview"; return TABS.includes(tab) ? tab : "Overview";
}); });
// Status toggles // Comm direction (used by other parts)
const [lastCommDirection, setLastCommDirection] = useState(null); const [lastCommDirection, setLastCommDirection] = useState(null);
const [statusToggling, setStatusToggling] = useState(null); // "negotiating" | "problem"
// Quick-entry modals
const [showInitNegModal, setShowInitNegModal] = useState(false);
const [showIssueModal, setShowIssueModal] = useState(false);
const [showPaymentModal, setShowPaymentModal] = useState(false);
// Orders tab // Orders tab
const [orders, setOrders] = useState([]); const [orders, setOrders] = useState([]);
const [ordersLoading, setOrdersLoading] = useState(false); const [ordersLoading, setOrdersLoading] = useState(false);
const [expandOrderId, setExpandOrderId] = useState(null);
// Quotations (overview preview) // Quotations (overview preview)
const [latestQuotations, setLatestQuotations] = useState([]); const [latestQuotations, setLatestQuotations] = useState([]);
@@ -564,6 +576,9 @@ export default function CustomerDetail() {
useEffect(() => { useEffect(() => {
const handler = (e) => { const handler = (e) => {
if (e.key !== "Escape") return; if (e.key !== "Escape") return;
if (showInitNegModal) { setShowInitNegModal(false); return; }
if (showIssueModal) { setShowIssueModal(false); return; }
if (showPaymentModal) { setShowPaymentModal(false); return; }
if (previewFile) { setPreviewFile(null); return; } if (previewFile) { setPreviewFile(null); return; }
if (mediaFilterModalOpen) { setMediaFilterModalOpen(false); return; } if (mediaFilterModalOpen) { setMediaFilterModalOpen(false); return; }
if (showUpload) { setShowUpload(false); return; } if (showUpload) { setShowUpload(false); return; }
@@ -578,7 +593,7 @@ export default function CustomerDetail() {
}; };
window.addEventListener("keydown", handler); window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler);
}, [previewFile, mediaFilterModalOpen, showUpload, showCreateTxt, showAddLinked, showAddOwned, commsViewEntry, composeEmailOpen, commsDeleteId, showCommsForm, showEmailCompose]); }, [showInitNegModal, showIssueModal, showPaymentModal, previewFile, mediaFilterModalOpen, showUpload, showCreateTxt, showAddLinked, showAddOwned, commsViewEntry, composeEmailOpen, commsDeleteId, showCommsForm, showEmailCompose]);
const loadCustomer = useCallback(() => { const loadCustomer = useCallback(() => {
setLoading(true); setLoading(true);
@@ -601,7 +616,7 @@ export default function CustomerDetail() {
const loadOrders = useCallback(() => { const loadOrders = useCallback(() => {
setOrdersLoading(true); setOrdersLoading(true);
api.get(`/crm/orders?customer_id=${id}`) api.get(`/crm/customers/${id}/orders`)
.then((data) => setOrders(data.orders || [])) .then((data) => setOrders(data.orders || []))
.catch(() => setOrders([])) .catch(() => setOrders([]))
.finally(() => setOrdersLoading(false)); .finally(() => setOrdersLoading(false));
@@ -668,6 +683,8 @@ export default function CustomerDetail() {
useEffect(() => { useEffect(() => {
if (activeTab === "Overview") { loadOrders(); loadComms(); loadDevicesAndProducts(); loadLatestQuotations(); } if (activeTab === "Overview") { loadOrders(); loadComms(); loadDevicesAndProducts(); loadLatestQuotations(); }
if (activeTab === "Support") { /* customer data already loaded */ }
if (activeTab === "Finance") { loadOrders(); }
if (activeTab === "Orders") loadOrders(); if (activeTab === "Orders") loadOrders();
if (activeTab === "Communication") loadComms(); if (activeTab === "Communication") loadComms();
if (activeTab === "Files & Media") { setNcThumbMapState(null); loadMedia(); browseNextcloud(); } if (activeTab === "Files & Media") { setNcThumbMapState(null); loadMedia(); browseNextcloud(); }
@@ -685,6 +702,13 @@ export default function CustomerDetail() {
fetch(url).then(r => r.text()).then(t => setTxtContent(t)).catch(() => setTxtContent("(failed to load)")); fetch(url).then(r => r.text()).then(t => setTxtContent(t)).catch(() => setTxtContent("(failed to load)"));
}, [previewFile]); }, [previewFile]);
const handleTabChange = useCallback((tab, extraArg) => {
setActiveTab(tab);
if (tab === "Orders" && extraArg) {
setExpandOrderId(extraArg);
}
}, []);
// Early returns after all hooks // Early returns after all hooks
if (loading) { if (loading) {
return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>; return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
@@ -698,37 +722,6 @@ export default function CustomerDetail() {
} }
if (!customer) return null; if (!customer) return null;
const handleToggleNegotiating = async () => {
setStatusToggling("negotiating");
try {
const updated = await api.post(`/crm/customers/${id}/toggle-negotiating`);
setCustomer(updated);
// refresh direction
api.get(`/crm/customers/${id}/last-comm-direction`)
.then((res) => setLastCommDirection(res.direction || null))
.catch(() => {});
} catch (err) {
alert(err.message);
} finally {
setStatusToggling(null);
}
};
const handleToggleProblem = async () => {
setStatusToggling("problem");
try {
const updated = await api.post(`/crm/customers/${id}/toggle-problem`);
setCustomer(updated);
api.get(`/crm/customers/${id}/last-comm-direction`)
.then((res) => setLastCommDirection(res.direction || null))
.catch(() => {});
} catch (err) {
alert(err.message);
} finally {
setStatusToggling(null);
}
};
const handleAddComms = async () => { const handleAddComms = async () => {
setCommsSaving(true); setCommsSaving(true);
try { try {
@@ -1062,32 +1055,105 @@ export default function CustomerDetail() {
return ( return (
<div> <div>
{/* Header */} {/* Header */}
<div className="flex items-start justify-between mb-6"> <div className="flex items-start justify-between mb-5">
<div> <div>
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}> <h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
{[customer.title, customer.name, customer.surname].filter(Boolean).join(" ")} {[customer.title, customer.name, customer.surname].filter(Boolean).join(" ")}
</h1> </h1>
{customer.organization && ( <p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>
<p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>{customer.organization}</p> {[customer.organization, loc.city].filter(Boolean).join(" · ")}
)} </p>
{locationStr && (
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>{locationStr}</p>
)}
</div> </div>
{canEdit && (
{/* Action buttons */}
<div style={{ display: "flex", gap: 8, alignItems: "center", flexShrink: 0 }}>
{canEdit && (
<>
{/* Init Negotiations */}
<button
type="button"
onClick={() => setShowInitNegModal(true)}
title="Init Negotiations"
style={{
display: "flex", alignItems: "center", gap: 6,
padding: "7px 14px", borderRadius: 7, fontSize: 13, fontWeight: 600,
border: "1px solid var(--crm-rel-prospect-border)",
backgroundColor: "var(--crm-rel-prospect-bg)",
color: "var(--crm-rel-prospect-text)",
cursor: "pointer",
minWidth: 150, justifyContent: "center",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
Init Negotiations
</button>
{/* Record Issue/Support */}
<button
type="button"
onClick={() => setShowIssueModal(true)}
title="Record Issue or Support"
style={{
display: "flex", alignItems: "center", gap: 6,
padding: "7px 14px", borderRadius: 7, fontSize: 13, fontWeight: 600,
border: "1px solid var(--crm-support-active-text)",
backgroundColor: "var(--crm-support-active-bg)",
color: "var(--crm-support-active-text)",
cursor: "pointer",
minWidth: 150, justifyContent: "center",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
Record Issue
</button>
{/* Record Payment */}
<button
type="button"
onClick={() => setShowPaymentModal(true)}
title="Record Payment"
style={{
display: "flex", alignItems: "center", gap: 6,
padding: "7px 14px", borderRadius: 7, fontSize: 13, fontWeight: 600,
border: "1px solid var(--crm-rel-active-border)",
backgroundColor: "var(--crm-rel-active-bg)",
color: "var(--crm-rel-active-text)",
cursor: "pointer",
minWidth: 150, justifyContent: "center",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/>
</svg>
Record Payment
</button>
</>
)}
{/* Edit Customer */}
<button <button
type="button"
onClick={() => navigate(`/crm/customers/${id}/edit`)} onClick={() => navigate(`/crm/customers/${id}/edit`)}
className="px-4 py-2 text-sm rounded-md border cursor-pointer hover:opacity-80" style={{
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }} display: "flex", alignItems: "center", gap: 6,
padding: "7px 14px", borderRadius: 7, fontSize: 13, fontWeight: 600,
border: "1px solid var(--border-primary)",
backgroundColor: "transparent",
color: "var(--text-secondary)",
cursor: "pointer",
minWidth: 150, justifyContent: "center",
}}
> >
Edit Customer Edit Customer
</button> </button>
)} </div>
</div> </div>
{/* Divider after header */}
<div style={{ borderTop: "1px solid var(--border-primary)", marginBottom: 20 }} />
{/* Tabs */} {/* Tabs */}
<div className="flex gap-1 mb-5 border-b" style={{ borderColor: "var(--border-primary)" }}> <div className="flex gap-1 mb-5 border-b" style={{ borderColor: "var(--border-primary)" }}>
{TABS.map((tab) => ( {TABS.map((tab) => (
@@ -1110,6 +1176,22 @@ export default function CustomerDetail() {
{/* Overview Tab */} {/* Overview Tab */}
{activeTab === "Overview" && ( {activeTab === "Overview" && (
<OverviewTab
customer={customer}
orders={orders}
comms={comms}
latestQuotations={latestQuotations}
allDevices={allDevices}
canEdit={canEdit}
onCustomerUpdated={setCustomer}
onTabChange={handleTabChange}
onExpandComm={(commId) => setExpandedComms((prev) => ({ ...prev, [commId]: true }))}
user={user}
/>
)}
{/* OLD Overview content — replaced, keeping stub to avoid parse error */}
{false && (
<div> <div>
{/* Hero: Basic Info card — 75/25 split */} {/* Hero: Basic Info card — 75/25 split */}
<div className="ui-section-card mb-4" style={{ display: "grid", gridTemplateColumns: "8fr 2fr", gap: 40 }}> <div className="ui-section-card mb-4" style={{ display: "grid", gridTemplateColumns: "8fr 2fr", gap: 40 }}>
@@ -1401,65 +1483,41 @@ export default function CustomerDetail() {
</div> </div>
)} )}
{/* Support Tab */}
{activeTab === "Support" && (
<SupportTab
customer={customer}
canEdit={canEdit}
onCustomerUpdated={setCustomer}
user={user}
/>
)}
{/* Finance Tab */}
{activeTab === "Finance" && (
<FinancialsTab
customer={customer}
orders={orders}
canEdit={canEdit}
onCustomerUpdated={setCustomer}
onReloadOrders={loadOrders}
user={user}
onTabChange={handleTabChange}
/>
)}
{/* Orders Tab */} {/* Orders Tab */}
{activeTab === "Orders" && ( {activeTab === "Orders" && (
<div> <OrdersTab
<div className="flex items-center justify-between mb-4"> customerId={id}
<span className="text-sm" style={{ color: "var(--text-muted)" }}>{orders.length} order{orders.length !== 1 ? "s" : ""}</span> orders={orders}
{canEdit && ( ordersLoading={ordersLoading}
<button canEdit={canEdit}
onClick={() => navigate(`/crm/orders/new?customer_id=${id}`)} user={user}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-90" onReload={loadOrders}
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }} expandOrderId={expandOrderId}
> onExpandOrderIdConsumed={() => setExpandOrderId(null)}
New Order />
</button>
)}
</div>
{ordersLoading ? (
<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 yet.
</div>
) : (
<div className="rounded-lg overflow-hidden border" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<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)" }}>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)" }}>Date</th>
</tr>
</thead>
<tbody>
{orders.map((o, idx) => (
<tr
key={o.id}
onClick={() => navigate(`/crm/orders/${o.id}`)}
className="cursor-pointer"
style={{ borderBottom: idx < orders.length - 1 ? "1px solid var(--border-secondary)" : "none" }}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "var(--bg-card-hover)")}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "transparent")}
>
<td className="px-4 py-3 font-mono text-xs" style={{ color: "var(--text-heading)" }}>{o.order_number}</td>
<td className="px-4 py-3">
<span className="px-2 py-0.5 text-xs rounded-full capitalize" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}>
{o.status}
</span>
</td>
<td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>{Number(o.total_price || 0).toFixed(2)}</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>
)} )}
{/* Quotations Tab */} {/* Quotations Tab */}
@@ -3644,6 +3702,33 @@ export default function CustomerDetail() {
</div> </div>
); );
})()} })()}
{/* Quick-entry modals */}
{showInitNegModal && (
<InitNegotiationsModal
customerId={id}
user={user}
onClose={() => setShowInitNegModal(false)}
onSuccess={() => { loadCustomer(); loadOrders(); }}
/>
)}
{showIssueModal && (
<RecordIssueModal
customerId={id}
user={user}
onClose={() => setShowIssueModal(false)}
onSuccess={loadCustomer}
/>
)}
{showPaymentModal && (
<RecordPaymentModal
customerId={id}
user={user}
orders={orders}
onClose={() => setShowPaymentModal(false)}
onSuccess={loadCustomer}
/>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,426 @@
import { useState, useEffect } from "react";
import api from "../../../api/client";
import {
FLOW_LABELS, PAYMENT_TYPE_LABELS, CATEGORY_LABELS,
ORDER_STATUS_LABELS, fmtDate,
} from "./shared";
const labelStyle = { fontSize: 11, fontWeight: 600, color: "var(--text-muted)", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 4 };
// Greek number format: 12500800.70 → "12.500.800,70"
function fmtEuro(amount, currency = "EUR") {
const n = Number(amount || 0);
const [intPart, decPart] = n.toFixed(2).split(".");
const intFormatted = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ".");
const suffix = currency && currency !== "EUR" ? ` ${currency}` : "";
return `${intFormatted},${decPart}${suffix}`;
}
const inputStyle = { backgroundColor: "var(--bg-input)", borderColor: "var(--border-primary)", color: "var(--text-primary)", border: "1px solid var(--border-primary)", borderRadius: 6, padding: "6px 10px", fontSize: 13, width: "100%" };
const TERMINAL = new Set(["declined", "complete"]);
const emptyTxn = () => ({
date: new Date().toISOString().slice(0, 16),
flow: "payment",
payment_type: "cash",
category: "full_payment",
amount: "",
currency: "EUR",
invoice_ref: "",
order_ref: "",
recorded_by: "",
note: "",
});
function TransactionModal({ initialData, orders, customerId, onClose, onSaved, user, editIndex, outstandingBalance }) {
const [form, setForm] = useState(() => ({
...emptyTxn(),
recorded_by: user?.name || "",
...initialData,
}));
const [saving, setSaving] = useState(false);
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }));
const isInvoice = form.flow === "invoice";
// Auto-fill amount when flow=payment + category=full_payment
// - No order_ref: fill total outstanding across all orders
// - With order_ref: fill balance due for that specific order
// - Any other flow/category: clear amount
const isFullPayment = form.flow === "payment" && form.category === "full_payment";
useEffect(() => {
if (initialData) return; // don't touch edits
if (!isFullPayment) {
setForm((f) => ({ ...f, amount: "" }));
return;
}
if (form.order_ref) {
// Find balance due for this specific order
const order = (orders || []).find((o) => o.id === form.order_ref);
const due = order?.payment_status?.balance_due ?? 0;
if (due > 0) setForm((f) => ({ ...f, amount: Number(due).toFixed(2) }));
} else {
// Total outstanding across all orders
if (outstandingBalance != null && outstandingBalance > 0) {
setForm((f) => ({ ...f, amount: outstandingBalance.toFixed(2) }));
}
}
}, [isFullPayment, form.order_ref]);
const handleSubmit = async () => {
if (!form.amount || !form.flow) {
alert("Amount and Flow are required.");
return;
}
// Category is required only for non-invoice flows
if (!isInvoice && !form.category) {
alert("Category is required.");
return;
}
setSaving(true);
try {
const payload = {
...form,
amount: parseFloat(form.amount) || 0,
date: form.date ? new Date(form.date).toISOString() : new Date().toISOString(),
invoice_ref: form.invoice_ref || null,
order_ref: form.order_ref || null,
payment_type: isInvoice ? null : (form.payment_type || null),
// For invoices, category defaults to "full_payment" as a neutral placeholder
category: isInvoice ? "full_payment" : (form.category || "full_payment"),
};
let updated;
if (editIndex !== undefined && editIndex !== null) {
updated = await api.patch(`/crm/customers/${customerId}/transactions/${editIndex}`, payload);
} else {
updated = await api.post(`/crm/customers/${customerId}/transactions`, payload);
}
onSaved(updated);
onClose();
} catch (err) {
alert(err.message);
} finally {
setSaving(false);
}
};
return (
<div style={{
position: "fixed", inset: 0, backgroundColor: "rgba(0,0,0,0.6)",
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1000,
}}>
<div style={{
backgroundColor: "var(--bg-card)", borderRadius: 12, padding: 24,
width: "100%", maxWidth: 520, border: "1px solid var(--border-primary)",
}}>
<h3 style={{ fontSize: 15, fontWeight: 700, color: "var(--text-heading)", marginBottom: 18 }}>
{editIndex !== null && editIndex !== undefined ? "Edit Transaction" : "Record Transaction"}
</h3>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<div>
<div style={labelStyle}>Date</div>
<input type="datetime-local" value={form.date} onChange={(e) => set("date", e.target.value)} style={inputStyle} />
</div>
<div>
<div style={labelStyle}>Flow</div>
<select value={form.flow} onChange={(e) => set("flow", e.target.value)} style={inputStyle}>
{Object.entries(FLOW_LABELS).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</div>
{/* Payment type — hidden for invoices */}
{!isInvoice && (
<div>
<div style={labelStyle}>Payment Type</div>
<select value={form.payment_type || ""} onChange={(e) => set("payment_type", e.target.value)} style={inputStyle}>
<option value=""></option>
{Object.entries(PAYMENT_TYPE_LABELS).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</div>
)}
{/* Category — hidden for invoices */}
{!isInvoice && (
<div>
<div style={labelStyle}>Category</div>
<select value={form.category} onChange={(e) => set("category", e.target.value)} style={inputStyle}>
{Object.entries(CATEGORY_LABELS).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</div>
)}
<div>
<div style={labelStyle}>Amount</div>
<input type="number" min="0" step="0.01" value={form.amount} onChange={(e) => set("amount", e.target.value)} style={inputStyle} placeholder="0.00" />
</div>
<div>
<div style={labelStyle}>Currency</div>
<select value={form.currency} onChange={(e) => set("currency", e.target.value)} style={inputStyle}>
{["EUR", "USD", "GBP"].map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<div style={labelStyle}>Invoice Ref</div>
<input type="text" value={form.invoice_ref || ""} onChange={(e) => set("invoice_ref", e.target.value)} style={inputStyle} placeholder="e.g. INV-2026-001" />
</div>
<div>
<div style={labelStyle}>Order Ref</div>
<select value={form.order_ref || ""} onChange={(e) => set("order_ref", e.target.value)} style={inputStyle}>
<option value=""> None </option>
{(orders || []).map((o) => (
<option key={o.id} value={o.id}>{o.order_number || o.id.slice(0, 8)}{o.title ? `${o.title}` : ""}</option>
))}
</select>
</div>
<div style={{ gridColumn: "1 / -1" }}>
<div style={labelStyle}>Recorded By</div>
<input type="text" value={form.recorded_by} onChange={(e) => set("recorded_by", e.target.value)} style={inputStyle} />
</div>
<div style={{ gridColumn: "1 / -1" }}>
<div style={labelStyle}>Note</div>
<textarea rows={2} value={form.note} onChange={(e) => set("note", e.target.value)} style={{ ...inputStyle, resize: "vertical" }} />
</div>
</div>
<div className="flex gap-2 mt-4 justify-end">
<button type="button" onClick={onClose}
style={{ fontSize: 13, padding: "6px 16px", borderRadius: 6, border: "1px solid var(--border-primary)", backgroundColor: "transparent", color: "var(--text-muted)", cursor: "pointer" }}>
Cancel
</button>
<button type="button" onClick={handleSubmit} disabled={saving}
style={{ fontSize: 13, fontWeight: 600, padding: "6px 18px", borderRadius: 6, border: "none", backgroundColor: "var(--btn-primary)", color: "#fff", cursor: "pointer", opacity: saving ? 0.6 : 1 }}>
{saving ? "Saving..." : "Save"}
</button>
</div>
</div>
</div>
);
}
function DeleteConfirmModal({ onConfirm, onCancel, deleting }) {
return (
<div style={{ position: "fixed", inset: 0, backgroundColor: "rgba(0,0,0,0.6)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1001 }}>
<div style={{ backgroundColor: "var(--bg-card)", borderRadius: 10, padding: 24, width: 340, border: "1px solid var(--border-primary)" }}>
<p style={{ fontSize: 14, color: "var(--text-primary)", marginBottom: 18 }}>Delete this transaction? This cannot be undone.</p>
<div className="flex gap-2 justify-end">
<button type="button" onClick={onCancel} style={{ fontSize: 13, padding: "5px 14px", borderRadius: 6, border: "1px solid var(--border-primary)", backgroundColor: "transparent", color: "var(--text-muted)", cursor: "pointer" }}>Cancel</button>
<button type="button" onClick={onConfirm} disabled={deleting}
style={{ fontSize: 13, fontWeight: 600, padding: "5px 14px", borderRadius: 6, border: "none", backgroundColor: "var(--danger)", color: "#fff", cursor: "pointer", opacity: deleting ? 0.6 : 1 }}>
{deleting ? "Deleting..." : "Delete"}
</button>
</div>
</div>
</div>
);
}
// Small stat box used in the financial summary sections
function StatBox({ label, value, color, note }) {
return (
<div style={{ backgroundColor: "var(--bg-primary)", padding: "10px 14px", borderRadius: 8, border: "1px solid var(--border-secondary)" }}>
<div style={{ ...labelStyle, marginBottom: 4 }}>{label}</div>
<div style={{ fontSize: 16, fontWeight: 700, color: color || "var(--text-primary)" }}>{value}</div>
{note && <div style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 2 }}>{note}</div>}
</div>
);
}
export default function FinancialsTab({ customer, orders, canEdit, onCustomerUpdated, onReloadOrders, user, onTabChange }) {
const [showTxnModal, setShowTxnModal] = useState(false);
const [editTxnIndex, setEditTxnIndex] = useState(null);
const [deleteTxnIndex, setDeleteTxnIndex] = useState(null);
const [deleting, setDeleting] = useState(false);
const txns = [...(customer.transaction_history || [])].sort((a, b) => (b.date || "").localeCompare(a.date || ""));
// Overall totals (all orders combined)
const totalInvoiced = (customer.transaction_history || []).filter((t) => t.flow === "invoice").reduce((s, t) => s + (t.amount || 0), 0);
const totalPaid = (customer.transaction_history || []).filter((t) => t.flow === "payment").reduce((s, t) => s + (t.amount || 0), 0);
const outstanding = totalInvoiced - totalPaid;
const totalOrders = orders.length;
// Active orders (not completed/declined) — for the per-order payment status section
const activeOrders = orders.filter((o) => !TERMINAL.has(o.status));
const handleDelete = async () => {
setDeleting(true);
try {
const updated = await api.delete(`/crm/customers/${customer.id}/transactions/${deleteTxnIndex}`);
onCustomerUpdated(updated);
setDeleteTxnIndex(null);
} catch (err) {
alert(err.message);
} finally {
setDeleting(false);
}
};
return (
<div>
{/* ── Overall Customer Financial Status ── */}
<div className="ui-section-card mb-4">
<div style={{ fontSize: 14, fontWeight: 600, color: "var(--text-heading)", marginBottom: 14 }}>Overall Financial Status</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(130px, 1fr))", gap: 12 }}>
<StatBox label="Total Invoiced" value={fmtEuro(totalInvoiced)} />
<StatBox
label="Total Received"
value={fmtEuro(totalPaid)}
color="var(--crm-rel-active-text)"
/>
<StatBox
label="Balance Due"
value={fmtEuro(outstanding)}
color={outstanding > 0 ? "var(--crm-ord-awaiting_payment-text)" : "var(--crm-rel-active-text)"}
/>
<StatBox label="Total Orders" value={totalOrders} color="var(--text-heading)" />
</div>
</div>
{/* ── Active Orders Payment Status ── */}
{activeOrders.length > 0 && (
<div className="ui-section-card mb-4">
<div style={{ fontSize: 14, fontWeight: 600, color: "var(--text-heading)", marginBottom: 14 }}>Active Orders Status</div>
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
{activeOrders.map((order) => {
const ps = order.payment_status || {};
return (
<div key={order.id}>
{/* Order label */}
<div style={{ fontSize: 12, fontWeight: 600, color: "var(--text-muted)", marginBottom: 8, display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontFamily: "monospace", color: "var(--text-heading)" }}>{order.order_number}</span>
{order.title && <span> {order.title}</span>}
<span style={{ padding: "1px 7px", borderRadius: 8, fontSize: 10, backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}>
{ORDER_STATUS_LABELS[order.status] || order.status}
</span>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(120px, 1fr))", gap: 10 }}>
<StatBox label="Required" value={fmtEuro(ps.required_amount)} />
<StatBox
label="Received"
value={fmtEuro(ps.received_amount)}
color="var(--crm-rel-active-text)"
/>
<StatBox
label="Balance Due"
value={fmtEuro(ps.balance_due)}
color={(ps.balance_due || 0) > 0 ? "var(--crm-ord-awaiting_payment-text)" : "var(--crm-rel-active-text)"}
/>
<div style={{ backgroundColor: "var(--bg-primary)", padding: "10px 14px", borderRadius: 8, border: "1px solid var(--border-secondary)" }}>
<div style={{ ...labelStyle, marginBottom: 4 }}>Payment</div>
<div style={{ fontSize: 13, fontWeight: 600, color: ps.payment_complete ? "var(--crm-rel-active-text)" : "var(--text-muted)" }}>
{ps.payment_complete ? "Complete" : (ps.required_amount || 0) === 0 ? "No Invoice" : "Pending"}
</div>
</div>
</div>
{/* Separator between orders */}
{activeOrders.indexOf(order) < activeOrders.length - 1 && (
<div style={{ borderBottom: "1px solid var(--border-secondary)", marginTop: 14 }} />
)}
</div>
);
})}
</div>
</div>
)}
{/* ── Transaction History ── */}
<div className="ui-section-card">
<div className="flex items-center justify-between mb-4">
<span style={{ fontSize: 14, fontWeight: 600, color: "var(--text-heading)" }}>Transaction History</span>
{canEdit && (
<button
type="button"
onClick={() => { setEditTxnIndex(null); setShowTxnModal(true); }}
style={{ fontSize: 12, fontWeight: 600, padding: "5px 14px", borderRadius: 6, border: "none", backgroundColor: "var(--btn-primary)", color: "#fff", cursor: "pointer" }}
>
+ Add Transaction
</button>
)}
</div>
{txns.length === 0 ? (
<p style={{ fontSize: 13, color: "var(--text-muted)", fontStyle: "italic" }}>No transactions recorded.</p>
) : (
<div style={{ overflowX: "auto" }}>
<table className="w-full text-sm" style={{ borderCollapse: "collapse", minWidth: 700 }}>
<thead>
<tr style={{ borderBottom: "1px solid var(--border-primary)" }}>
{["Date", "Flow", "Amount", "Method", "Category", "Order Ref", "Note", "By", ""].map((h) => (
<th key={h} style={{ padding: "6px 10px", textAlign: "left", ...labelStyle, marginBottom: 0 }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{txns.map((t, i) => {
const origIdx = (customer.transaction_history || []).findIndex(
(x) => x.date === t.date && x.amount === t.amount && x.note === t.note && x.recorded_by === t.recorded_by
);
const linkedOrder = t.order_ref ? orders.find((o) => o.id === t.order_ref) : null;
return (
<tr key={i} style={{ borderBottom: "1px solid var(--border-secondary)" }}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = "transparent"}>
<td style={{ padding: "7px 10px", color: "var(--text-muted)", whiteSpace: "nowrap" }}>{fmtDate(t.date)}</td>
<td style={{ padding: "7px 10px" }}>
<span style={{
padding: "2px 8px", borderRadius: 10, fontSize: 11, fontWeight: 600,
backgroundColor: t.flow === "payment" ? "var(--crm-rel-active-bg)" : t.flow === "invoice" ? "var(--badge-blue-bg)" : "var(--bg-card-hover)",
color: t.flow === "payment" ? "var(--crm-rel-active-text)" : t.flow === "invoice" ? "var(--badge-blue-text)" : "var(--text-secondary)",
}}>
{FLOW_LABELS[t.flow] || t.flow}
</span>
</td>
<td style={{ padding: "7px 10px", fontWeight: 600, color: t.flow === "payment" ? "var(--crm-rel-active-text)" : "var(--text-primary)", whiteSpace: "nowrap" }}>
{t.flow === "payment" ? "+" : t.flow === "invoice" ? "-" : ""}{fmtEuro(t.amount, t.currency)}
</td>
<td style={{ padding: "7px 10px", color: "var(--text-muted)" }}>{PAYMENT_TYPE_LABELS[t.payment_type] || "—"}</td>
<td style={{ padding: "7px 10px", color: "var(--text-muted)" }}>
{t.flow === "invoice" ? "—" : (CATEGORY_LABELS[t.category] || t.category)}
</td>
<td style={{ padding: "7px 10px" }}>
{linkedOrder ? (
<button type="button"
onClick={() => onTabChange?.("Orders", linkedOrder.id)}
style={{ fontSize: 11, fontFamily: "monospace", color: "var(--accent)", background: "none", border: "none", cursor: "pointer", padding: 0, textDecoration: "underline" }}>
{linkedOrder.order_number}
</button>
) : (t.order_ref ? <span style={{ fontSize: 10, color: "var(--text-muted)", fontStyle: "italic" }}>{t.order_ref.slice(0, 8)}</span> : "—")}
</td>
<td style={{ padding: "7px 10px", color: "var(--text-muted)", maxWidth: 160, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{t.note || "—"}</td>
<td style={{ padding: "7px 10px", color: "var(--text-muted)" }}>{t.recorded_by || "—"}</td>
{canEdit && (
<td style={{ padding: "7px 10px", whiteSpace: "nowrap" }}>
<button type="button" onClick={() => { setEditTxnIndex(origIdx); setShowTxnModal(true); }}
style={{ fontSize: 11, padding: "2px 8px", borderRadius: 4, border: "1px solid var(--border-primary)", backgroundColor: "transparent", color: "var(--text-secondary)", cursor: "pointer", marginRight: 4 }}>
Edit
</button>
<button type="button" onClick={() => setDeleteTxnIndex(origIdx)}
style={{ fontSize: 11, padding: "2px 8px", borderRadius: 4, border: "1px solid var(--danger)", backgroundColor: "transparent", color: "var(--danger)", cursor: "pointer" }}>
Delete
</button>
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{showTxnModal && (
<TransactionModal
customerId={customer.id}
orders={orders}
user={user}
editIndex={editTxnIndex}
initialData={editTxnIndex !== null && editTxnIndex !== undefined ? (customer.transaction_history || [])[editTxnIndex] : undefined}
outstandingBalance={outstanding}
onClose={() => { setShowTxnModal(false); setEditTxnIndex(null); }}
onSaved={(updated) => { onCustomerUpdated(updated); onReloadOrders?.(); }}
/>
)}
{deleteTxnIndex !== null && (
<DeleteConfirmModal onConfirm={handleDelete} onCancel={() => setDeleteTxnIndex(null)} deleting={deleting} />
)}
</div>
);
}

View File

@@ -0,0 +1,728 @@
import { useState, useEffect } from "react";
import api from "../../../api/client";
import {
ORDER_STATUS_LABELS,
TIMELINE_TYPE_LABELS, OrderStatusChip, fmtDateFull,
} from "./shared";
const labelStyle = { fontSize: 11, fontWeight: 600, color: "var(--text-muted)", textTransform: "uppercase", letterSpacing: "0.06em" };
const inputStyle = { backgroundColor: "var(--bg-input)", borderColor: "var(--border-primary)", color: "var(--text-primary)", border: "1px solid var(--border-primary)", borderRadius: 6, padding: "6px 10px", fontSize: 13, width: "100%" };
const STATUSES = Object.entries(ORDER_STATUS_LABELS);
const TIMELINE_TYPES = Object.entries(TIMELINE_TYPE_LABELS);
const STATUS_DEFAULT_NOTES = {
negotiating: "Just started Negotiating with the customer on a possible new order",
awaiting_quotation: "We agreed on what the customer needs, and currently drafting a Quote for them",
awaiting_customer_confirmation: "The Quotation has been sent to the Customer. Awaiting their Confirmation",
awaiting_fulfilment: "Customer has accepted the Quotation, and no further action is needed from them. First Chance possible we are going to build their device",
awaiting_payment: "Customer has accepted the Quotation, but a payment|advance is due before we proceed",
manufacturing: "We have begun manufacturing the Customer's Device",
shipped: "The order has been Shipped ! Awaiting Customer Feedback",
installed: "Customer has informed us that the device has been successfully Installed !",
declined: "Customer sadly declined our offer",
complete: "Customer has successfully installed, and operated their product. No further action needed ! The order is complete !",
};
const VERBOSE_DATE_FMT = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "long", year: "numeric" });
const TIME_FMT = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
function fmtVerboseDateTime(iso) {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return `${VERBOSE_DATE_FMT.format(d)}, ${TIME_FMT.format(d).toLowerCase()}`;
}
// ── Delete confirm modal ──────────────────────────────────────────────────────
function DeleteConfirm({ message, onConfirm, onCancel, deleting }) {
return (
<div style={{ position: "fixed", inset: 0, backgroundColor: "rgba(0,0,0,0.6)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1001 }}>
<div style={{ backgroundColor: "var(--bg-card)", borderRadius: 10, padding: 24, width: 340, border: "1px solid var(--border-primary)" }}>
<p style={{ fontSize: 14, color: "var(--text-primary)", marginBottom: 18 }}>{message || "Delete this item? This cannot be undone."}</p>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button type="button" onClick={onCancel} style={{ fontSize: 13, padding: "5px 14px", borderRadius: 6, border: "1px solid var(--border-primary)", backgroundColor: "transparent", color: "var(--text-muted)", cursor: "pointer" }}>Cancel</button>
<button type="button" onClick={onConfirm} disabled={deleting}
style={{ fontSize: 13, fontWeight: 600, padding: "5px 14px", borderRadius: 6, border: "none", backgroundColor: "var(--danger)", color: "#fff", cursor: "pointer", opacity: deleting ? 0.6 : 1 }}>
{deleting ? "Deleting..." : "Delete"}
</button>
</div>
</div>
</div>
);
}
// ── Timeline event row ────────────────────────────────────────────────────────
function TimelineEventRow({ event, index, canEdit, onEdit, onDelete }) {
const [hovered, setHovered] = useState(false);
return (
<div
style={{ display: "flex", gap: 10, paddingBottom: 10, position: "relative" }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", flexShrink: 0 }}>
<div style={{ width: 8, height: 8, borderRadius: "50%", backgroundColor: "var(--accent)", marginTop: 4 }} />
<div style={{ flex: 1, width: 1, backgroundColor: "var(--border-secondary)" }} />
</div>
<div style={{ flex: 1, paddingBottom: 4 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: "var(--text-heading)" }}>
{TIMELINE_TYPE_LABELS[event.type] || event.type}
</div>
{event.note && <div style={{ fontSize: 12, color: "var(--text-primary)", marginTop: 3, whiteSpace: "pre-wrap" }}>{event.note}</div>}
<div style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 3 }}>
{fmtVerboseDateTime(event.date)}
{event.updated_by && <span style={{ opacity: 0.55 }}> · {event.updated_by}</span>}
</div>
</div>
{canEdit && hovered && (
<div style={{ display: "flex", gap: 4, flexShrink: 0, alignSelf: "flex-start" }}>
<button type="button" onClick={() => onEdit(index, event)}
style={{ fontSize: 11, padding: "2px 8px", borderRadius: 4, border: "1px solid var(--border-primary)", backgroundColor: "var(--bg-card)", color: "var(--text-secondary)", cursor: "pointer" }}>
Edit
</button>
<button type="button" onClick={() => onDelete(index)}
style={{ fontSize: 11, padding: "2px 8px", borderRadius: 4, border: "1px solid var(--danger)", backgroundColor: "var(--bg-card)", color: "var(--danger)", cursor: "pointer" }}>
Delete
</button>
</div>
)}
</div>
);
}
// ── Order card ────────────────────────────────────────────────────────────────
function OrderCard({ order, customerId, canEdit, user, onReload, isOpen, onToggle, onDeleteOrder }) {
const [showTimelineForm, setShowTimelineForm] = useState(false);
const [showEditForm, setShowEditForm] = useState(false);
const [showStatusUpdate, setShowStatusUpdate] = useState(false);
const [deleteTlIndex, setDeleteTlIndex] = useState(null);
const [deletingTl, setDeletingTl] = useState(false);
// Timeline edit state
const [editTimelineIndex, setEditTimelineIndex] = useState(null);
const [editTimelineForm, setEditTimelineForm] = useState({ type: "note", note: "", date: "" });
const [timelineForm, setTimelineForm] = useState({ type: "note", note: "", date: new Date().toISOString().slice(0, 16) });
const [editForm, setEditForm] = useState({
order_number: order.order_number || "",
title: order.title || "",
status: order.status || "negotiating",
notes: order.notes || "",
});
const [statusUpdateForm, setStatusUpdateForm] = useState({
newStatus: order.status || "negotiating",
title: order.title || "",
note: "",
datetime: new Date().toISOString().slice(0, 16),
});
const [saving, setSaving] = useState(false);
const [savingTl, setSavingTl] = useState(false);
const [savingStatus, setSavingStatus] = useState(false);
const [savingTlEdit, setSavingTlEdit] = useState(false);
const timeline = [...(order.timeline || [])].sort((a, b) => (b.date || "").localeCompare(a.date || ""));
const handleAddTimeline = async () => {
if (!timelineForm.type) return;
setSavingTl(true);
try {
await api.post(`/crm/customers/${customerId}/orders/${order.id}/timeline`, {
...timelineForm,
date: timelineForm.date ? new Date(timelineForm.date).toISOString() : new Date().toISOString(),
updated_by: user?.name || "Staff",
});
setTimelineForm({ type: "note", note: "", date: new Date().toISOString().slice(0, 16) });
setShowTimelineForm(false);
onReload();
} catch (err) {
alert(err.message);
} finally {
setSavingTl(false);
}
};
const handleSaveEdit = async () => {
setSaving(true);
try {
await api.patch(`/crm/customers/${customerId}/orders/${order.id}`, {
order_number: editForm.order_number || null,
title: editForm.title || null,
status: editForm.status,
status_updated_date: new Date().toISOString(),
status_updated_by: user?.name || "Staff",
notes: editForm.notes || null,
});
setShowEditForm(false);
onReload();
} catch (err) {
alert(err.message);
} finally {
setSaving(false);
}
};
const handleUpdateStatus = async () => {
setSavingStatus(true);
try {
// Step 1: archive current status as a timeline event with the correct type and date
const existingMatch = (order.timeline || []).some(
(e) => e.archived_status === order.status &&
e.date === (order.status_updated_date || order.created_at)
);
if (!existingMatch) {
const statusToType = {
negotiating: "negotiations_started",
awaiting_quotation: "quote_request",
awaiting_customer_confirmation: "quote_sent",
awaiting_fulfilment: "quote_accepted",
awaiting_payment: "invoice_sent",
manufacturing: "mfg_started",
shipped: "order_shipped",
installed: "installed",
declined: "note",
complete: "payment_received",
};
const tlType = statusToType[order.status] || "note";
await api.post(`/crm/customers/${customerId}/orders/${order.id}/timeline`, {
type: tlType,
note: order.notes || "",
date: order.status_updated_date || order.created_at || new Date().toISOString(),
updated_by: order.status_updated_by || order.created_by || "System",
archived_status: order.status,
});
}
// Step 2: update to new status — if note is left empty, save as empty (not the old note)
await api.patch(`/crm/customers/${customerId}/orders/${order.id}`, {
status: statusUpdateForm.newStatus,
title: statusUpdateForm.title || order.title || null,
status_updated_date: new Date(statusUpdateForm.datetime).toISOString(),
status_updated_by: user?.name || "Staff",
notes: statusUpdateForm.note,
});
setShowStatusUpdate(false);
setStatusUpdateForm({ newStatus: order.status || "negotiating", title: order.title || "", note: "", datetime: new Date().toISOString().slice(0, 16) });
onReload();
} catch (err) {
alert(err.message);
} finally {
setSavingStatus(false);
}
};
const startEditTimeline = (index, event) => {
setEditTimelineIndex(index);
setEditTimelineForm({
type: event.type || "note",
note: event.note || "",
date: event.date ? new Date(event.date).toISOString().slice(0, 16) : new Date().toISOString().slice(0, 16),
});
};
const handleSaveTimelineEdit = async () => {
setSavingTlEdit(true);
try {
const origIdx = (order.timeline || []).findIndex((e) =>
e.date === timeline[editTimelineIndex].date && e.note === timeline[editTimelineIndex].note && e.type === timeline[editTimelineIndex].type
);
await api.patch(`/crm/customers/${customerId}/orders/${order.id}/timeline/${origIdx}`, {
type: editTimelineForm.type,
note: editTimelineForm.note,
date: new Date(editTimelineForm.date).toISOString(),
});
setEditTimelineIndex(null);
onReload();
} catch (err) {
alert(err.message);
} finally {
setSavingTlEdit(false);
}
};
const handleDeleteTimeline = async () => {
setDeletingTl(true);
try {
const origIdx = (order.timeline || []).findIndex((e) =>
e.date === timeline[deleteTlIndex].date && e.note === timeline[deleteTlIndex].note && e.type === timeline[deleteTlIndex].type
);
await api.delete(`/crm/customers/${customerId}/orders/${order.id}/timeline/${origIdx}`);
setDeleteTlIndex(null);
onReload();
} catch (err) {
alert(err.message);
} finally {
setDeletingTl(false);
}
};
return (
<div className="ui-section-card mb-3">
{/* Order header — clickable to toggle timeline */}
<div
style={{ display: "flex", alignItems: "flex-start", gap: 12, flexWrap: "wrap", cursor: "pointer" }}
onClick={(e) => {
if (e.target.closest("button")) return;
onToggle(order.id);
}}
>
<div style={{ flex: 1, minWidth: 200 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
<span style={{ fontFamily: "monospace", fontSize: 12, color: "var(--text-muted)" }}>{order.order_number}</span>
<OrderStatusChip status={order.status} />
</div>
<div style={{ fontSize: 14, fontWeight: 600, color: "var(--text-heading)", marginTop: 4 }}>
{order.title || <span style={{ color: "var(--text-muted)", fontStyle: "italic" }}>Untitled order</span>}
</div>
{order.notes && <div style={{ fontSize: 12, color: "var(--text-muted)", marginTop: 4, whiteSpace: "pre-wrap" }}>{order.notes}</div>}
<div style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 4 }}>
Created <strong>{fmtDateFull(order.created_at)}</strong>{order.created_by ? ` by ${order.created_by}` : ""}
{order.status_updated_date && order.status_updated_by && (
<> · Status updated <strong>{fmtDateFull(order.status_updated_date)}</strong> by {order.status_updated_by}</>
)}
</div>
</div>
{/* Actions */}
<div style={{ display: "flex", gap: 6, flexShrink: 0, flexWrap: "wrap" }}>
{/* Update Status button — neutral style */}
{canEdit && (
<button type="button"
onClick={(e) => {
e.stopPropagation();
setShowStatusUpdate((v) => {
if (!v) {
// Reset datetime to NOW and pre-fill default note each time panel opens
setStatusUpdateForm((f) => ({
...f,
datetime: new Date().toISOString().slice(0, 16),
note: STATUS_DEFAULT_NOTES[f.newStatus] || "",
}));
}
return !v;
});
setShowEditForm(false);
}}
style={{
fontSize: 12, padding: "4px 12px", borderRadius: 6,
border: "1px solid var(--border-primary)",
backgroundColor: showStatusUpdate ? "var(--bg-card-hover)" : "transparent",
color: "var(--text-secondary)",
cursor: "pointer",
display: "flex", alignItems: "center", gap: 5,
}}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>
</svg>
Update Status
</button>
)}
<button type="button"
onClick={(e) => { e.stopPropagation(); onToggle(order.id); }}
style={{ fontSize: 12, padding: "4px 12px", borderRadius: 6, border: "1px solid var(--border-primary)", backgroundColor: isOpen ? "var(--bg-card-hover)" : "transparent", color: "var(--text-secondary)", cursor: "pointer" }}>
{isOpen ? "Hide Timeline" : "Timeline"} ({(order.timeline || []).length})
</button>
{canEdit && (
<button type="button"
onClick={(e) => { e.stopPropagation(); setShowEditForm((v) => !v); setShowStatusUpdate(false); }}
style={{ fontSize: 12, padding: "4px 12px", borderRadius: 6, border: "1px solid var(--border-primary)", backgroundColor: showEditForm ? "var(--bg-card-hover)" : "transparent", color: "var(--text-secondary)", cursor: "pointer" }}>
Edit
</button>
)}
{canEdit && (
<button type="button"
onClick={(e) => { e.stopPropagation(); onDeleteOrder(order.id); }}
style={{ fontSize: 12, padding: "4px 8px", borderRadius: 6, border: "1px solid var(--danger)", backgroundColor: "transparent", color: "var(--danger)", cursor: "pointer", display: "flex", alignItems: "center" }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M5.755,20.283,4,8H20L18.245,20.283A2,2,0,0,1,16.265,22H7.735A2,2,0,0,1,5.755,20.283ZM21,4H16V3a1,1,0,0,0-1-1H9A1,1,0,0,0,8,3V4H3A1,1,0,0,0,3,6H21a1,1,0,0,0,0-2Z"/></svg>
</button>
)}
</div>
</div>
{/* Update Status form */}
{showStatusUpdate && (
<div style={{ marginTop: 12, padding: 12, borderRadius: 8, backgroundColor: "var(--bg-primary)", border: "1px solid var(--border-primary)" }}>
<div style={{ fontSize: 12, fontWeight: 600, color: "var(--text-heading)", marginBottom: 10 }}>Update Order Status</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 10 }}>
<div>
<div style={{ ...labelStyle, marginBottom: 4 }}>New Status</div>
<select value={statusUpdateForm.newStatus} onChange={(e) => setStatusUpdateForm((f) => ({ ...f, newStatus: e.target.value, note: STATUS_DEFAULT_NOTES[e.target.value] || "" }))} style={inputStyle}>
{STATUSES.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</div>
<div style={{ display: "flex", gap: 6, alignItems: "flex-end" }}>
<div style={{ flex: 1 }}>
<div style={{ ...labelStyle, marginBottom: 4 }}>Date & Time</div>
<input type="datetime-local" value={statusUpdateForm.datetime} onChange={(e) => setStatusUpdateForm((f) => ({ ...f, datetime: e.target.value }))} style={inputStyle} />
</div>
<button type="button" onClick={() => setStatusUpdateForm((f) => ({ ...f, datetime: new Date().toISOString().slice(0, 16) }))}
style={{ fontSize: 11, padding: "6px 8px", borderRadius: 5, border: "1px solid var(--border-primary)", backgroundColor: "transparent", color: "var(--text-secondary)", cursor: "pointer", whiteSpace: "nowrap", marginBottom: 0 }}>
Now
</button>
</div>
<div style={{ gridColumn: "1 / -1" }}>
<div style={{ ...labelStyle, marginBottom: 4 }}>Order Title</div>
<input type="text" value={statusUpdateForm.title} onChange={(e) => setStatusUpdateForm((f) => ({ ...f, title: e.target.value }))} style={inputStyle} placeholder="Update order title (optional)..." />
</div>
<div style={{ gridColumn: "1 / -1" }}>
<div style={{ ...labelStyle, marginBottom: 4 }}>Note</div>
<textarea rows={2} value={statusUpdateForm.note} onChange={(e) => setStatusUpdateForm((f) => ({ ...f, note: e.target.value }))} style={{ ...inputStyle, resize: "vertical" }} placeholder="Optional note about this status change..." />
</div>
</div>
<div style={{ fontSize: 11, color: "var(--text-muted)", marginBottom: 10, padding: "6px 10px", borderRadius: 6, backgroundColor: "var(--bg-card-hover)" }}>
Current status "<strong>{ORDER_STATUS_LABELS[order.status] || order.status}</strong>" will be archived as a timeline event, then replaced with "{ORDER_STATUS_LABELS[statusUpdateForm.newStatus] || statusUpdateForm.newStatus}".
</div>
<div className="flex gap-2 justify-end">
<button type="button" onClick={() => setShowStatusUpdate(false)}
style={{ fontSize: 12, padding: "4px 12px", borderRadius: 5, border: "1px solid var(--border-primary)", backgroundColor: "transparent", color: "var(--text-muted)", cursor: "pointer" }}>Cancel</button>
<button type="button" onClick={handleUpdateStatus} disabled={savingStatus}
style={{ fontSize: 12, fontWeight: 600, padding: "4px 14px", borderRadius: 5, border: "none", backgroundColor: "var(--btn-primary)", color: "#fff", cursor: "pointer", opacity: savingStatus ? 0.6 : 1 }}>
{savingStatus ? "Updating..." : "Update Status"}
</button>
</div>
</div>
)}
{/* Edit form */}
{showEditForm && (
<div style={{ marginTop: 12, padding: 12, borderRadius: 8, backgroundColor: "var(--bg-primary)", border: "1px solid var(--border-primary)" }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 10 }}>
<div>
<div style={{ ...labelStyle, marginBottom: 4 }}>Order Number</div>
<input type="text" value={editForm.order_number} onChange={(e) => setEditForm((f) => ({ ...f, order_number: e.target.value }))} style={inputStyle} />
</div>
<div>
<div style={{ ...labelStyle, marginBottom: 4 }}>Title</div>
<input type="text" value={editForm.title} onChange={(e) => setEditForm((f) => ({ ...f, title: e.target.value }))} style={inputStyle} />
</div>
<div>
<div style={{ ...labelStyle, marginBottom: 4 }}>Status</div>
<select value={editForm.status} onChange={(e) => setEditForm((f) => ({ ...f, status: e.target.value }))} style={inputStyle}>
{STATUSES.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</div>
<div style={{ gridColumn: "1 / -1" }}>
<div style={{ ...labelStyle, marginBottom: 4 }}>Notes</div>
<textarea rows={2} value={editForm.notes} onChange={(e) => setEditForm((f) => ({ ...f, notes: e.target.value }))} style={{ ...inputStyle, resize: "vertical" }} />
</div>
</div>
<div className="flex gap-2 justify-end">
<button type="button" onClick={() => setShowEditForm(false)}
style={{ fontSize: 12, padding: "4px 12px", borderRadius: 5, border: "1px solid var(--border-primary)", backgroundColor: "transparent", color: "var(--text-muted)", cursor: "pointer" }}>Cancel</button>
<button type="button" onClick={handleSaveEdit} disabled={saving}
style={{ fontSize: 12, fontWeight: 600, padding: "4px 14px", borderRadius: 5, border: "none", backgroundColor: "var(--btn-primary)", color: "#fff", cursor: "pointer", opacity: saving ? 0.6 : 1 }}>
{saving ? "Saving..." : "Save"}
</button>
</div>
</div>
)}
{/* Timeline */}
{isOpen && (
<div style={{ marginTop: 12, paddingTop: 12, borderTop: "1px solid var(--border-secondary)" }}>
{timeline.length === 0 ? (
<p style={{ fontSize: 12, color: "var(--text-muted)", fontStyle: "italic" }}>No timeline events yet.</p>
) : (
<div>
{timeline.map((ev, i) => (
<TimelineEventRow
key={i}
event={ev}
index={i}
canEdit={canEdit}
onEdit={startEditTimeline}
onDelete={(idx) => setDeleteTlIndex(idx)}
/>
))}
</div>
)}
{/* Timeline item edit form */}
{editTimelineIndex !== null && (
<div style={{ marginTop: 8, padding: 12, borderRadius: 8, backgroundColor: "var(--bg-primary)", border: "1px solid var(--accent)44" }}>
<div style={{ fontSize: 12, fontWeight: 600, color: "var(--text-heading)", marginBottom: 8 }}>Edit Timeline Event</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, marginBottom: 8 }}>
<div>
<div style={{ ...labelStyle, marginBottom: 4 }}>Type</div>
<select value={editTimelineForm.type} onChange={(e) => setEditTimelineForm((f) => ({ ...f, type: e.target.value }))} style={{ ...inputStyle, fontSize: 12 }}>
{TIMELINE_TYPES.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</div>
<div style={{ display: "flex", gap: 6, alignItems: "flex-end" }}>
<div style={{ flex: 1 }}>
<div style={{ ...labelStyle, marginBottom: 4 }}>Date & Time</div>
<input type="datetime-local" value={editTimelineForm.date} onChange={(e) => setEditTimelineForm((f) => ({ ...f, date: e.target.value }))} style={{ ...inputStyle, fontSize: 12 }} />
</div>
<button type="button" onClick={() => setEditTimelineForm((f) => ({ ...f, date: new Date().toISOString().slice(0, 16) }))}
style={{ fontSize: 11, padding: "6px 8px", borderRadius: 5, border: "1px solid var(--border-primary)", backgroundColor: "transparent", color: "var(--text-secondary)", cursor: "pointer", whiteSpace: "nowrap" }}>
Now
</button>
</div>
<div style={{ gridColumn: "1 / -1" }}>
<div style={{ ...labelStyle, marginBottom: 4 }}>Note</div>
<textarea rows={2} value={editTimelineForm.note} onChange={(e) => setEditTimelineForm((f) => ({ ...f, note: e.target.value }))} style={{ ...inputStyle, resize: "vertical", fontSize: 12 }} />
</div>
</div>
<div className="flex gap-2 justify-end">
<button type="button" onClick={() => setEditTimelineIndex(null)}
style={{ fontSize: 12, padding: "4px 12px", borderRadius: 5, border: "1px solid var(--border-primary)", backgroundColor: "transparent", color: "var(--text-muted)", cursor: "pointer" }}>Cancel</button>
<button type="button" onClick={handleSaveTimelineEdit} disabled={savingTlEdit}
style={{ fontSize: 12, fontWeight: 600, padding: "4px 14px", borderRadius: 5, border: "none", backgroundColor: "var(--btn-primary)", color: "#fff", cursor: "pointer", opacity: savingTlEdit ? 0.6 : 1 }}>
{savingTlEdit ? "Saving..." : "Save"}
</button>
</div>
</div>
)}
{canEdit && (
<>
{!showTimelineForm ? (
<button type="button" onClick={() => setShowTimelineForm(true)}
style={{ fontSize: 12, padding: "4px 12px", borderRadius: 6, border: "1px solid var(--border-primary)", backgroundColor: "transparent", color: "var(--text-secondary)", cursor: "pointer", marginTop: 6 }}>
+ Add Event
</button>
) : (
<div style={{ marginTop: 8, padding: 10, borderRadius: 8, backgroundColor: "var(--bg-primary)", border: "1px solid var(--border-primary)" }}>
{/* Row 1: Type + Date */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr auto", gap: 8, marginBottom: 8 }}>
<div>
<div style={{ ...labelStyle, marginBottom: 4 }}>Type</div>
<select value={timelineForm.type} onChange={(e) => setTimelineForm((f) => ({ ...f, type: e.target.value }))}
style={{ ...inputStyle, fontSize: 12 }}>
{TIMELINE_TYPES.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</div>
<div>
<div style={{ ...labelStyle, marginBottom: 4 }}>Date & Time</div>
<input type="datetime-local" value={timelineForm.date}
onChange={(e) => setTimelineForm((f) => ({ ...f, date: e.target.value }))}
style={{ ...inputStyle, fontSize: 12 }}
/>
</div>
<div style={{ display: "flex", alignItems: "flex-end" }}>
<button type="button" onClick={() => setTimelineForm((f) => ({ ...f, date: new Date().toISOString().slice(0, 16) }))}
style={{ fontSize: 11, padding: "6px 8px", borderRadius: 5, border: "1px solid var(--border-primary)", backgroundColor: "transparent", color: "var(--text-secondary)", cursor: "pointer", whiteSpace: "nowrap" }}>
Now
</button>
</div>
</div>
{/* Row 2: Note (resizable, 2 lines) + buttons */}
<div style={{ display: "flex", gap: 8, alignItems: "flex-end" }}>
<textarea
rows={2}
placeholder="Note (optional)..."
value={timelineForm.note}
onChange={(e) => setTimelineForm((f) => ({ ...f, note: e.target.value }))}
style={{ ...inputStyle, fontSize: 12, resize: "vertical", flex: 1 }}
/>
<div style={{ display: "flex", gap: 6, flexShrink: 0 }}>
<button type="button" onClick={handleAddTimeline} disabled={savingTl}
style={{ fontSize: 12, fontWeight: 600, padding: "6px 14px", borderRadius: 6, border: "none", backgroundColor: "var(--btn-primary)", color: "#fff", cursor: "pointer", display: "flex", alignItems: "center", gap: 5, opacity: savingTl ? 0.6 : 1 }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
{savingTl ? "..." : "Add"}
</button>
<button type="button" onClick={() => { setShowTimelineForm(false); setTimelineForm({ type: "note", note: "", date: new Date().toISOString().slice(0, 16) }); }}
style={{ fontSize: 12, padding: "6px 12px", borderRadius: 6, border: "1px solid var(--border-primary)", backgroundColor: "transparent", color: "var(--text-muted)", cursor: "pointer", display: "flex", alignItems: "center", gap: 5 }}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
Cancel
</button>
</div>
</div>
</div>
)}
</>
)}
</div>
)}
{deleteTlIndex !== null && (
<DeleteConfirm
message="Delete this timeline event? This cannot be undone."
onConfirm={handleDeleteTimeline}
onCancel={() => setDeleteTlIndex(null)}
deleting={deletingTl}
/>
)}
</div>
);
}
// ── New Order form ────────────────────────────────────────────────────────────
async function setCustomerActive(customerId) {
try {
await api.patch(`/crm/customers/${customerId}/relationship-status`, { status: "active" });
} catch {
// Non-critical — silently ignore
}
}
function NewOrderForm({ customerId, user, onSaved, onCancel, suggestedOrderNumber }) {
const [form, setForm] = useState({
order_number: suggestedOrderNumber || "",
title: "",
status: "negotiating",
notes: "",
date: new Date().toISOString().slice(0, 16),
});
const [saving, setSaving] = useState(false);
const handleSubmit = async () => {
setSaving(true);
try {
await api.post(`/crm/customers/${customerId}/orders`, {
customer_id: customerId,
order_number: form.order_number || undefined,
title: form.title || undefined,
status: form.status,
notes: form.notes || undefined,
created_by: user?.name || "Staff",
status_updated_date: new Date(form.date).toISOString(),
status_updated_by: user?.name || "Staff",
});
await setCustomerActive(customerId);
onSaved();
} catch (err) {
alert(err.message);
} finally {
setSaving(false);
}
};
return (
<div className="ui-section-card mb-3" style={{ border: "1px solid var(--accent)44" }}>
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--text-heading)", marginBottom: 12 }}>New Order</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 10 }}>
<div>
<div style={{ ...labelStyle, marginBottom: 4 }}>Order Number</div>
<input type="text" value={form.order_number} onChange={(e) => setForm((f) => ({ ...f, order_number: e.target.value }))} style={inputStyle} placeholder="Auto-generated if empty" />
</div>
<div>
<div style={{ ...labelStyle, marginBottom: 4 }}>Title</div>
<input type="text" value={form.title} onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))} style={inputStyle} placeholder="Order title..." />
</div>
<div>
<div style={{ ...labelStyle, marginBottom: 4 }}>Starting Status</div>
<select value={form.status} onChange={(e) => setForm((f) => ({ ...f, status: e.target.value }))} style={inputStyle}>
{STATUSES.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</div>
<div>
<div style={{ ...labelStyle, marginBottom: 4 }}>Date</div>
<input type="datetime-local" value={form.date} onChange={(e) => setForm((f) => ({ ...f, date: e.target.value }))} style={inputStyle} />
</div>
<div style={{ gridColumn: "1 / -1" }}>
<div style={{ ...labelStyle, marginBottom: 4 }}>Note</div>
<textarea rows={2} value={form.notes} onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))} style={{ ...inputStyle, resize: "vertical" }} placeholder="Optional note..." />
</div>
</div>
<div className="flex gap-2 justify-end">
<button type="button" onClick={onCancel}
style={{ fontSize: 12, padding: "4px 12px", borderRadius: 5, border: "1px solid var(--border-primary)", backgroundColor: "transparent", color: "var(--text-muted)", cursor: "pointer" }}>Cancel</button>
<button type="button" onClick={handleSubmit} disabled={saving}
style={{ fontSize: 12, fontWeight: 600, padding: "4px 14px", borderRadius: 5, border: "none", backgroundColor: "var(--btn-primary)", color: "#fff", cursor: "pointer", opacity: saving ? 0.6 : 1 }}>
{saving ? "Creating..." : "Create Order"}
</button>
</div>
</div>
);
}
// ── Main export ───────────────────────────────────────────────────────────────
export default function OrdersTab({ customerId, orders, ordersLoading, canEdit, user, onReload, expandOrderId, onExpandOrderIdConsumed }) {
const [expandedOrderId, setExpandedOrderId] = useState(null);
const [showNewOrderForm, setShowNewOrderForm] = useState(false);
const [deleteOrderId, setDeleteOrderId] = useState(null);
const [deletingOrder, setDeletingOrder] = useState(false);
// Suggested order number for the new-order form (fetched lazily)
const [suggestedOrderNumber, setSuggestedOrderNumber] = useState("");
useEffect(() => {
if (expandOrderId) {
setExpandedOrderId(expandOrderId);
onExpandOrderIdConsumed?.();
}
}, [expandOrderId]);
const handleToggle = (orderId) => {
setExpandedOrderId((prev) => (prev === orderId ? null : orderId));
};
const handleDeleteOrder = async () => {
setDeletingOrder(true);
try {
await api.delete(`/crm/customers/${customerId}/orders/${deleteOrderId}`);
setDeleteOrderId(null);
onReload();
} catch (err) {
alert(err.message);
} finally {
setDeletingOrder(false);
}
};
const handleOpenNewOrder = async () => {
try {
const res = await api.get(`/crm/customers/${customerId}/orders/next-order-number`);
setSuggestedOrderNumber(res.order_number || "");
} catch {
setSuggestedOrderNumber("");
}
setShowNewOrderForm(true);
};
return (
<div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
<span style={{ fontSize: 13, color: "var(--text-muted)" }}>
{orders.length} order{orders.length !== 1 ? "s" : ""}
</span>
{canEdit && (
<button type="button" onClick={handleOpenNewOrder}
style={{ fontSize: 12, fontWeight: 600, padding: "5px 14px", borderRadius: 6, border: "1px solid var(--border-primary)", backgroundColor: "transparent", color: "var(--text-secondary)", cursor: "pointer" }}>
+ New Order
</button>
)}
</div>
{showNewOrderForm && canEdit && (
<NewOrderForm
customerId={customerId}
user={user}
suggestedOrderNumber={suggestedOrderNumber}
onSaved={() => { setShowNewOrderForm(false); onReload(); }}
onCancel={() => setShowNewOrderForm(false)}
/>
)}
{ordersLoading ? (
<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 yet. Use "+ New Order" or the "Init Negotiations" button to create the first order.
</div>
) : (
[...orders].sort((a, b) => (b.created_at || "").localeCompare(a.created_at || "")).map((order) => (
<OrderCard
key={order.id}
order={order}
customerId={customerId}
canEdit={canEdit}
user={user}
onReload={onReload}
isOpen={expandedOrderId === order.id}
onToggle={handleToggle}
onDeleteOrder={(id) => setDeleteOrderId(id)}
/>
))
)}
{deleteOrderId && (
<DeleteConfirm
message="Delete this order and all its timeline events? This cannot be undone."
onConfirm={handleDeleteOrder}
onCancel={() => setDeleteOrderId(null)}
deleting={deletingOrder}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,754 @@
import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import api from "../../../api/client";
import { CommTypeIconBadge, CommDirectionIcon } from "../../components/CommIcons";
import { REL_STATUS_LABELS, REL_STATUS_STYLES, OrderStatusChip, fmtDate } from "./shared";
import clientIcon from "../../../assets/customer-status/client.svg?raw";
import inactiveIcon from "../../../assets/customer-status/inactive.svg?raw";
import churnedIcon from "../../../assets/customer-status/churned.svg?raw";
import exclamationIcon from "../../../assets/customer-status/exclamation.svg?raw";
import wrenchIcon from "../../../assets/customer-status/wrench.svg?raw";
import orderIcon from "../../../assets/customer-status/order.svg?raw";
const LANGUAGE_LABELS = {
af:"Afrikaans",sq:"Albanian",am:"Amharic",ar:"Arabic",hy:"Armenian",az:"Azerbaijani",
eu:"Basque",be:"Belarusian",bn:"Bengali",bs:"Bosnian",bg:"Bulgarian",ca:"Catalan",
zh:"Chinese",hr:"Croatian",cs:"Czech",da:"Danish",nl:"Dutch",en:"English",
et:"Estonian",fi:"Finnish",fr:"French",ka:"Georgian",de:"German",el:"Greek",
gu:"Gujarati",he:"Hebrew",hi:"Hindi",hu:"Hungarian",id:"Indonesian",it:"Italian",
ja:"Japanese",kn:"Kannada",kk:"Kazakh",ko:"Korean",lv:"Latvian",lt:"Lithuanian",
mk:"Macedonian",ms:"Malay",ml:"Malayalam",mt:"Maltese",mr:"Marathi",mn:"Mongolian",
ne:"Nepali",no:"Norwegian",fa:"Persian",pl:"Polish",pt:"Portuguese",pa:"Punjabi",
ro:"Romanian",ru:"Russian",sr:"Serbian",si:"Sinhala",sk:"Slovak",sl:"Slovenian",
es:"Spanish",sw:"Swahili",sv:"Swedish",tl:"Tagalog",ta:"Tamil",te:"Telugu",
th:"Thai",tr:"Turkish",uk:"Ukrainian",ur:"Urdu",uz:"Uzbek",vi:"Vietnamese",
cy:"Welsh",yi:"Yiddish",zu:"Zulu",
};
const CONTACT_TYPE_ICONS = { email:"📧", phone:"📞", whatsapp:"💬", other:"🔗" };
const COMM_TYPE_LABELS = {
email:"e-mail", whatsapp:"whatsapp", call:"phonecall", sms:"sms", note:"note", in_person:"in person",
};
const labelStyle = { fontSize: 11, fontWeight: 600, color: "var(--text-muted)", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 4 };
function AddressField({ loc }) {
if (!loc) return null;
const parts = [loc.address, loc.city, loc.postal_code, loc.region, loc.country].filter(Boolean);
if (!parts.length) return null;
return (
<div style={{ minWidth: 0 }}>
<div style={labelStyle}>Address</div>
<div style={{ fontSize: 14, color: "var(--text-primary)", whiteSpace: "nowrap" }}>{parts.join(", ")}</div>
</div>
);
}
function TagsField({ tags }) {
if (!tags || !tags.length) return null;
return (
<div style={{ minWidth: 0 }}>
<div style={labelStyle}>Tags</div>
<div style={{ display: "flex", flexWrap: "nowrap", gap: 4, overflow: "hidden" }}>
{tags.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)", whiteSpace: "nowrap" }}>
{tag}
</span>
))}
</div>
</div>
);
}
// Verbose description per relationship status
const REL_STATUS_DESCRIPTIONS = {
lead: "This contact is a potential new lead. No active engagement yet.",
prospect: "Actively engaged — exploring possibilities before a formal order.",
active: "Active customer with ongoing or recent commercial activity.",
inactive: "No recent engagement. May need a follow-up to re-activate.",
churned: "Customer has disengaged. Orders declined or no activity for a long period.",
};
// Icon per relationship status (same logic as CustomerList resolveStatusIcon base cases)
const REL_STATUS_ICONS = {
lead: clientIcon,
prospect: clientIcon,
active: clientIcon,
inactive: inactiveIcon,
churned: churnedIcon,
};
// Status badge: shows current status + gear icon to open inline change dropdown
function RelStatusSelector({ customer, onUpdated, canEdit }) {
const statuses = ["lead", "prospect", "active", "inactive", "churned"];
const current = customer.relationship_status || "lead";
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const handleClick = async (status) => {
if (!canEdit || status === current) return;
setOpen(false);
try {
const updated = await api.patch(`/crm/customers/${customer.id}/relationship-status`, { status });
onUpdated(updated);
} catch (err) {
alert(err.message);
}
};
const st = REL_STATUS_STYLES[current] || REL_STATUS_STYLES.lead;
const icon = REL_STATUS_ICONS[current];
const description = REL_STATUS_DESCRIPTIONS[current] || "";
ensureShimmer();
const shimmerGradient = `linear-gradient(120deg, ${st.border}44 0%, ${st.border}cc 40%, ${st.border}ff 50%, ${st.border}cc 60%, ${st.border}44 100%)`;
return (
<div style={{ position: "relative", flex: 1, minWidth: 0 }} ref={ref}>
<button
type="button"
onClick={() => canEdit && setOpen((v) => !v)}
className="crm-shimmer-card"
style={{
"--crm-shimmer-gradient": shimmerGradient,
display: "flex", alignItems: "center", gap: 14,
padding: "14px 18px", borderRadius: 10,
border: `1.5px solid ${st.border}`,
backgroundColor: st.bg,
color: st.color,
cursor: canEdit ? "pointer" : "default",
width: "100%",
textAlign: "left",
boxShadow: `0 0 16px ${st.border}33`,
transition: "box-shadow 0.2s",
position: "relative", zIndex: 0,
}}
>
{/* Icon */}
{icon && renderMaskedIconOv(icon, st.color, 28)}
{/* Text block */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 10, fontWeight: 700, opacity: 0.6, textTransform: "uppercase", letterSpacing: "0.08em" }}>Customer Status</span>
</div>
<div style={{ fontSize: 16, fontWeight: 800, marginTop: 1, letterSpacing: "0.01em" }}>{REL_STATUS_LABELS[current] || current}</div>
<div style={{ fontSize: 11, fontWeight: 400, opacity: 0.7, marginTop: 3, lineHeight: 1.4 }}>{description}</div>
</div>
{canEdit && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.5, flexShrink: 0, alignSelf: "flex-start", marginTop: 2 }}>
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
)}
</button>
{open && (
<div style={{
position: "absolute", top: "calc(100% + 6px)", left: 0, zIndex: 30,
backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)",
borderRadius: 8, minWidth: 160, boxShadow: "0 8px 24px rgba(0,0,0,0.18)", overflow: "hidden",
}}>
{statuses.map((s) => {
const sst = REL_STATUS_STYLES[s] || {};
const isActive = s === current;
return (
<button key={s} type="button" onClick={() => handleClick(s)}
style={{
display: "block", width: "100%", textAlign: "left",
padding: "9px 16px", fontSize: 13, fontWeight: isActive ? 700 : 400,
cursor: "pointer", background: "none", border: "none",
color: isActive ? sst.color : "var(--text-primary)",
backgroundColor: isActive ? sst.bg : "transparent",
}}
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"; }}
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.backgroundColor = "transparent"; }}
>
{REL_STATUS_LABELS[s]}
</button>
);
})}
</div>
)}
</div>
);
}
const STAT_CARD_STYLES = {
issue: { bg: "var(--crm-issue-active-bg,rgba(224,53,53,0.12))", color: "var(--crm-issue-active-text)", border: "rgba(224,53,53,0.35)" },
support: { bg: "var(--crm-support-active-bg,rgba(247,103,7,0.12))", color: "var(--crm-support-active-text)", border: "rgba(247,103,7,0.35)" },
order: { bg: "var(--badge-blue-bg,rgba(59,130,246,0.12))", color: "var(--badge-blue-text)", border: "rgba(59,130,246,0.35)" },
};
const STAT_ICONS = {
issue: exclamationIcon,
support: wrenchIcon,
order: orderIcon,
};
const STAT_LABELS = {
issue: "Open Issues",
support: "Support Assists",
order: "Open Orders",
};
// Shared shimmer keyframes injected once
let _shimmerInjected = false;
function ensureShimmer() {
if (_shimmerInjected) return;
_shimmerInjected = true;
const style = document.createElement("style");
style.textContent = `
@keyframes crm-border-shimmer {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.crm-shimmer-card {
position: relative;
border-radius: 10px;
overflow: visible;
}
.crm-shimmer-card::before {
content: "";
position: absolute;
inset: -1.5px;
border-radius: 11px;
padding: 1.5px;
background: var(--crm-shimmer-gradient);
background-size: 200% 200%;
animation: crm-border-shimmer 3s ease infinite;
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
z-index: 0;
}
`;
document.head.appendChild(style);
}
function renderMaskedIconOv(icon, color, size = 16) {
const svgMarkup = icon
.replace(/<\?xml[\s\S]*?\?>/gi, "")
.replace(/<!DOCTYPE[\s\S]*?>/gi, "")
.replace(/<!--[\s\S]*?-->/g, "")
.replace(
/<svg\b([^>]*)>/i,
`<svg$1 width="${size}" height="${size}" style="display:block;width:${size}px;height:${size}px;color:${color};fill:currentColor;">`,
);
return (
<span style={{ width: size, height: size, display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}
dangerouslySetInnerHTML={{ __html: svgMarkup }} />
);
}
// Stat card — mirrors the status hero layout exactly so all cards share the same height
function StatCard({ count, onClick, type }) {
ensureShimmer();
const s = STAT_CARD_STYLES[type] || {};
const icon = STAT_ICONS[type];
const label = STAT_LABELS[type] || type;
const shimmerGradient = `linear-gradient(120deg, ${s.border}44 0%, ${s.border}cc 40%, ${s.border}ff 50%, ${s.border}cc 60%, ${s.border}44 100%)`;
return (
<button
type="button"
onClick={onClick}
className="crm-shimmer-card"
style={{
"--crm-shimmer-gradient": shimmerGradient,
display: "flex", alignItems: "center", gap: 12,
padding: "14px 18px",
border: `1.5px solid ${s.border}`,
borderRadius: 10,
backgroundColor: s.bg,
color: s.color,
cursor: "pointer",
flexShrink: 0,
whiteSpace: "nowrap",
transition: "box-shadow 0.2s",
boxShadow: `0 0 14px ${s.border}33`,
position: "relative", zIndex: 0,
textAlign: "left",
}}
onMouseEnter={(e) => { e.currentTarget.style.boxShadow = `0 0 22px ${s.border}55`; }}
onMouseLeave={(e) => { e.currentTarget.style.boxShadow = `0 0 14px ${s.border}33`; }}
>
{icon && renderMaskedIconOv(icon, s.color, 28)}
<div>
<div style={{ fontSize: 10, fontWeight: 700, opacity: 0.6, textTransform: "uppercase", letterSpacing: "0.08em" }}>{label}</div>
<div style={{ fontSize: 22, fontWeight: 800, lineHeight: 1.1, marginTop: 1 }}>{count}</div>
</div>
</button>
);
}
// Modal to show full note text — also doubles as quick-edit modal
function NoteExpandModal({ note, noteIndex, onClose, canEdit, onSaveEdit, startEditing }) {
const [editing, setEditing] = useState(!!startEditing);
const [editText, setEditText] = useState(note.text);
const [saving, setSaving] = useState(false);
const handleSave = async () => {
if (!editText.trim()) return;
setSaving(true);
try {
await onSaveEdit(noteIndex, editText.trim());
onClose();
} catch (err) {
alert(err.message);
} finally {
setSaving(false);
}
};
return (
<div
style={{ position: "fixed", inset: 0, backgroundColor: "rgba(0,0,0,0.55)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1200 }}
onClick={onClose}
>
<div
style={{ backgroundColor: "var(--bg-card)", borderRadius: 10, padding: 20, maxWidth: 480, width: "90%", border: "1px solid var(--border-primary)", maxHeight: "80vh", overflowY: "auto" }}
onClick={(e) => e.stopPropagation()}
>
{editing ? (
<>
<textarea
autoFocus
rows={8}
value={editText}
onChange={(e) => setEditText(e.target.value)}
style={{ width: "100%", fontSize: 14, backgroundColor: "var(--bg-input)", border: "1px solid var(--border-primary)", borderRadius: 6, padding: "8px 10px", color: "var(--text-primary)", resize: "vertical", lineHeight: 1.6 }}
/>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 12 }}>
<button type="button" onClick={() => { setEditing(false); setEditText(note.text); }}
style={{ fontSize: 13, padding: "5px 14px", borderRadius: 6, border: "1px solid var(--border-primary)", backgroundColor: "transparent", color: "var(--text-muted)", cursor: "pointer" }}>
Cancel
</button>
<button type="button" onClick={handleSave} disabled={saving || !editText.trim()}
style={{ fontSize: 13, fontWeight: 600, padding: "5px 16px", borderRadius: 6, border: "none", backgroundColor: "var(--btn-primary)", color: "#fff", cursor: "pointer", opacity: saving ? 0.6 : 1 }}>
{saving ? "Saving..." : "Save"}
</button>
</div>
</>
) : (
<>
<p style={{ fontSize: 14, color: "var(--text-primary)", whiteSpace: "pre-wrap", lineHeight: 1.6, marginBottom: 12 }}>{note.text}</p>
<p style={{ fontSize: 12, color: "var(--text-muted)" }}>{note.by} · {note.at ? new Date(note.at).toLocaleDateString() : ""}</p>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 14 }}>
{canEdit && (
<button type="button" onClick={() => setEditing(true)}
style={{ fontSize: 13, padding: "5px 14px", borderRadius: 6, border: "1px solid var(--border-primary)", backgroundColor: "transparent", color: "var(--accent)", cursor: "pointer" }}>
Edit
</button>
)}
<button type="button" onClick={onClose}
style={{ fontSize: 13, padding: "5px 16px", borderRadius: 6, border: "1px solid var(--border-primary)", backgroundColor: "transparent", color: "var(--text-secondary)", cursor: "pointer" }}>
Close
</button>
</div>
</>
)}
</div>
</div>
);
}
// Single note card with 3-line clamp, hover edit button, whole-card click to expand
function NoteCard({ note, noteIndex, onExpand, canEdit }) {
const [hovered, setHovered] = useState(false);
const isTruncated = note.text.length > 180 || (note.text.match(/\n/g) || []).length >= 3;
return (
<div
className="px-3 py-2 rounded-md text-sm"
style={{ backgroundColor: "var(--bg-primary)", position: "relative", cursor: "pointer" }}
onClick={() => onExpand(note, noteIndex)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{/* Quick-edit button on hover */}
{canEdit && hovered && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onExpand(note, noteIndex, true); }}
style={{
position: "absolute", top: 6, right: 6,
fontSize: 10, fontWeight: 600,
padding: "2px 8px", borderRadius: 4,
border: "1px solid var(--border-primary)",
backgroundColor: "var(--bg-card)",
color: "var(--accent)",
cursor: "pointer",
}}
>
Edit
</button>
)}
<p style={{
color: "var(--text-primary)",
whiteSpace: "pre-wrap",
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
overflow: "hidden",
paddingRight: canEdit ? 40 : 0,
}}>
{note.text}
</p>
{isTruncated && (
<span style={{ fontSize: 11, color: "var(--accent)", marginTop: 4, display: "block" }}>
(click to expand...)
</span>
)}
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
{note.by} · {note.at ? new Date(note.at).toLocaleDateString() : ""}
</p>
</div>
);
}
export default function OverviewTab({
customer,
orders,
comms,
latestQuotations,
allDevices,
canEdit,
onCustomerUpdated,
onTabChange,
onExpandComm,
user,
}) {
const navigate = useNavigate();
const loc = customer.location || {};
// Responsive notes columns
const [winWidth, setWinWidth] = useState(window.innerWidth);
useEffect(() => {
const handler = () => setWinWidth(window.innerWidth);
window.addEventListener("resize", handler);
return () => window.removeEventListener("resize", handler);
}, []);
const notesCols = winWidth >= 2000 ? 3 : winWidth >= 1100 ? 2 : 1;
// Note expand/edit modal: { note, index, startEditing }
const [expandedNote, setExpandedNote] = useState(null);
// Add-note inline state
const [showAddNote, setShowAddNote] = useState(false);
const [newNoteText, setNewNoteText] = useState("");
const [savingNote, setSavingNote] = useState(false);
// Stat counts
const summary = customer.crm_summary || {};
const openIssues = (summary.active_issues_count || 0);
const supportInquiries = (summary.active_support_count || 0);
const openOrders = orders.filter((o) => !["declined", "complete"].includes(o.status)).length;
const handleAddNote = async () => {
if (!newNoteText.trim()) return;
setSavingNote(true);
try {
const newNote = {
text: newNoteText.trim(),
by: user?.name || "Staff",
at: new Date().toISOString(),
};
const updated = await api.put(`/crm/customers/${customer.id}`, {
notes: [...(customer.notes || []), newNote],
});
onCustomerUpdated(updated);
setNewNoteText("");
setShowAddNote(false);
} catch (err) {
alert(err.message);
} finally {
setSavingNote(false);
}
};
const handleEditNote = async (index, newText) => {
const updatedNotes = (customer.notes || []).map((n, i) =>
i === index ? { ...n, text: newText, by: user?.name || n.by, at: new Date().toISOString() } : n
);
const updated = await api.put(`/crm/customers/${customer.id}`, { notes: updatedNotes });
onCustomerUpdated(updated);
};
const notes = customer.notes || [];
return (
<div>
{/* Main hero info card */}
<div className="ui-section-card mb-4">
{/* Hero row: status (flex-grow) + stat cards (shrink-to-fit), all on one line */}
<div style={{ display: "flex", alignItems: "stretch", gap: 8, flexWrap: "wrap", marginBottom: 20 }}>
<RelStatusSelector customer={customer} onUpdated={onCustomerUpdated} canEdit={canEdit} />
{openIssues > 0 && (
<StatCard count={openIssues} onClick={() => onTabChange("Support")} type="issue" />
)}
{supportInquiries > 0 && (
<StatCard count={supportInquiries} onClick={() => onTabChange("Support")} type="support" />
)}
{openOrders > 0 && (
<StatCard count={openOrders} onClick={() => onTabChange("Orders")} type="order" />
)}
</div>
{/* Separator between rows */}
<div style={{ borderTop: "1px solid var(--border-secondary)", marginBottom: 16 }} />
{/* Row 2: info fields ← adjust gap here: "gap-row gap-col" */}
<div style={{ display: "flex", flexWrap: "wrap", gap: "16px 70px", alignItems: "start" }}>
{(LANGUAGE_LABELS[customer.language] || customer.language) ? (
<div>
<div style={labelStyle}>Language</div>
<div style={{ fontSize: 14, color: "var(--text-primary)" }}>{LANGUAGE_LABELS[customer.language] || customer.language}</div>
</div>
) : null}
{customer.religion ? (
<div>
<div style={labelStyle}>Religion</div>
<div style={{ fontSize: 14, color: "var(--text-primary)" }}>{customer.religion}</div>
</div>
) : null}
<AddressField loc={loc} />
<TagsField tags={customer.tags} />
</div>
{/* Contacts */}
{(customer.contacts || []).length > 0 && (
<div className="mt-4 pt-4 border-t" style={{ borderColor: "var(--border-secondary)" }}>
<div style={{ ...labelStyle, marginBottom: 8 }}>Contacts</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "4px 0" }}>
{customer.contacts.map((c, i) => (
<div key={i} style={{ display: "flex", alignItems: "center", flexShrink: 0 }}>
<div className="flex items-center gap-1.5 text-sm" style={{ padding: "2px 12px 2px 0" }}>
<span className="w-4 flex-shrink-0 text-center">{CONTACT_TYPE_ICONS[c.type] || "🔗"}</span>
<span style={{ color: "var(--text-muted)", flexShrink: 0, fontSize: 11 }}>{c.type}{c.label ? ` (${c.label})` : ""}</span>
<span style={{ color: "var(--text-primary)" }}>{c.value}</span>
{c.primary && (
<span className="px-1.5 py-0.5 text-xs rounded-full flex-shrink-0"
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>
Primary
</span>
)}
</div>
{i < customer.contacts.length - 1 && (
<span style={{ color: "var(--border-primary)", paddingRight: 12, fontSize: 16, lineHeight: 1 }}>|</span>
)}
</div>
))}
</div>
</div>
)}
{/* Notes — CSS columns masonry (browser packs items into shortest column natively) */}
<div className="mt-4 pt-4 border-t" style={{ borderColor: "var(--border-secondary)" }}>
<div style={{ ...labelStyle, marginBottom: 8 }}>Notes</div>
<div style={{
columns: notesCols,
columnGap: 8,
}}>
{notes.map((note, idx) => (
<div key={idx} style={{ breakInside: "avoid", marginBottom: 8 }}>
<NoteCard
note={note}
noteIndex={idx}
canEdit={canEdit}
onExpand={(n, i, startEditing) => setExpandedNote({ note: n, index: i, startEditing: !!startEditing })}
/>
</div>
))}
{canEdit && (
<div style={{ breakInside: "avoid", marginBottom: 8 }}>
{showAddNote ? (
<div className="px-3 py-2 rounded-md text-sm" style={{ backgroundColor: "var(--bg-primary)", border: "1px solid var(--border-primary)" }}>
<textarea
autoFocus
rows={3}
value={newNoteText}
onChange={(e) => setNewNoteText(e.target.value)}
placeholder="Write a note..."
style={{ width: "100%", fontSize: 13, backgroundColor: "var(--bg-input)", border: "1px solid var(--border-primary)", borderRadius: 5, padding: "6px 8px", color: "var(--text-primary)", resize: "vertical" }}
/>
<div style={{ display: "flex", gap: 6, marginTop: 6, justifyContent: "flex-end" }}>
<button type="button" onClick={() => { setShowAddNote(false); setNewNoteText(""); }}
style={{ fontSize: 11, padding: "3px 10px", borderRadius: 5, border: "1px solid var(--border-primary)", backgroundColor: "transparent", color: "var(--text-muted)", cursor: "pointer" }}>
Cancel
</button>
<button type="button" onClick={handleAddNote} disabled={savingNote || !newNoteText.trim()}
style={{ fontSize: 11, fontWeight: 600, padding: "3px 10px", borderRadius: 5, border: "none", backgroundColor: "var(--btn-primary)", color: "#fff", cursor: "pointer", opacity: savingNote ? 0.6 : 1 }}>
{savingNote ? "Saving..." : "Add"}
</button>
</div>
</div>
) : (
<button type="button" onClick={() => setShowAddNote(true)}
className="px-3 py-2 rounded-md text-sm"
style={{
width: "100%", backgroundColor: "var(--bg-primary)", border: "1px dashed var(--border-primary)",
color: "var(--text-muted)", cursor: "pointer", textAlign: "left",
fontSize: 12, fontStyle: "italic",
}}>
+ add new note
</button>
)}
</div>
)}
</div>
</div>
</div>
{/* Latest Orders — only shown if orders exist */}
{orders.length > 0 && (
<div className="ui-section-card mb-4">
<div className="flex items-center justify-between mb-3">
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--text-heading)" }}>Latest Orders</div>
<button type="button" onClick={() => onTabChange("Orders")}
style={{ fontSize: 12, color: "var(--accent)", background: "none", border: "none", cursor: "pointer" }}>
View all
</button>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{[...orders].sort((a, b) => new Date(b.created_at) - new Date(a.created_at)).slice(0, 3).map((o) => (
<div key={o.id}
className="flex items-center gap-3 text-sm py-2 border-b last:border-0"
style={{ borderColor: "var(--border-secondary)", cursor: "pointer" }}
onClick={() => onTabChange("Orders", o.id)}
onMouseEnter={(e) => e.currentTarget.style.opacity = "0.75"}
onMouseLeave={(e) => e.currentTarget.style.opacity = "1"}
>
<span className="font-mono text-xs" style={{ color: "var(--text-heading)", minWidth: 110 }}>
{o.order_number || o.id.slice(0, 8)}
</span>
{o.title && <span style={{ color: "var(--text-primary)", flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{o.title}</span>}
<OrderStatusChip status={o.status} />
<span className="ml-auto text-xs" style={{ color: "var(--text-muted)", flexShrink: 0 }}>{fmtDate(o.created_at)}</span>
</div>
))}
</div>
</div>
)}
{/* Latest Communications — only shown if comms exist */}
{comms.length > 0 && (
<div className="ui-section-card mb-4">
<div className="flex items-center justify-between mb-3">
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--text-heading)" }}>Latest Communications</div>
<button type="button" onClick={() => onTabChange("Communication")}
style={{ fontSize: 12, color: "var(--accent)", background: "none", border: "none", cursor: "pointer" }}>
View all
</button>
</div>
<div style={{ display: "flex", flexDirection: "column" }}>
{[...comms].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;
return tb - ta;
}).slice(0, 5).map((entry) => (
<div
key={entry.id}
className="flex items-center gap-2 text-sm py-2 border-b last:border-0 cursor-pointer hover:opacity-80"
style={{ borderColor: "var(--border-secondary)" }}
onClick={() => { onTabChange("Communication"); setTimeout(() => onExpandComm(entry.id), 50); }}
>
<CommTypeIconBadge type={entry.type} />
<CommDirectionIcon direction={entry.direction} />
<div style={{ flex: 1, minWidth: 0 }}>
<p className="font-medium" style={{ color: "var(--text-primary)" }}>
{entry.subject || <span style={{ color: "var(--text-muted)", fontStyle: "italic" }}>{COMM_TYPE_LABELS[entry.type] || entry.type}</span>}
</p>
{entry.body && (
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)", display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden" }}>{entry.body}</p>
)}
</div>
<span className="text-xs" style={{ color: "var(--text-muted)", flexShrink: 0, marginLeft: 16, whiteSpace: "nowrap" }}>
{fmtDate(entry.occurred_at)}
</span>
</div>
))}
</div>
</div>
)}
{/* Latest Quotations — only shown if quotations exist */}
{latestQuotations.length > 0 && (
<div className="ui-section-card mb-4">
<div className="flex items-center justify-between mb-3">
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--text-heading)" }}>Latest Quotations</div>
<button type="button" onClick={() => onTabChange("Quotations")}
style={{ fontSize: 12, color: "var(--accent)", background: "none", border: "none", cursor: "pointer" }}>
View all
</button>
</div>
<div>
{latestQuotations.map((q) => (
<div key={q.id} className="flex items-center gap-2 text-sm py-1.5 border-b last:border-0 cursor-pointer hover:opacity-80"
style={{ borderColor: "var(--border-secondary)" }}
onClick={() => onTabChange("Quotations")}>
<span className="font-mono text-xs" style={{ color: "var(--text-heading)" }}>{q.quotation_number}</span>
<span className="px-1.5 py-0.5 text-xs rounded-full capitalize" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}>{q.status}</span>
<span className="ml-auto" style={{ color: "var(--text-primary)" }}>{Number(q.final_total || 0).toFixed(2)}</span>
</div>
))}
</div>
</div>
)}
{/* Devices — only shown if owned items exist */}
{(customer.owned_items || []).length > 0 && (
<div className="ui-section-card mb-4">
<div className="flex items-center justify-between mb-3">
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--text-heading)" }}>Devices</div>
<button type="button" onClick={() => onTabChange("Devices")}
style={{ fontSize: 12, color: "var(--accent)", background: "none", border: "none", cursor: "pointer" }}>
View all
</button>
</div>
<div>
{(customer.owned_items || []).map((item, i) => {
const matchedDevice = item.type === "console_device"
? allDevices.find((d) => (d.device_id || d.id) === item.device_id)
: null;
return (
<div key={i} className="flex items-center gap-2 text-sm py-1.5 border-b last:border-0"
style={{ borderColor: "var(--border-secondary)", cursor: item.type === "console_device" && matchedDevice ? "pointer" : "default" }}
onClick={() => { if (item.type === "console_device" && matchedDevice) navigate(`/devices/${matchedDevice.id || matchedDevice.device_id}`); }}>
{item.type === "console_device" && <>
<span style={{ color: "var(--text-primary)", fontWeight: 500 }}>{item.label || matchedDevice?.device_name || item.device_id}</span>
{matchedDevice?.device_type && <span style={{ color: "var(--text-muted)" }}>· {matchedDevice.device_type}</span>}
</>}
{item.type === "product" && <>
<span style={{ color: "var(--text-primary)" }}>{item.product_name || item.product_id}</span>
<span className="text-xs ml-auto" style={{ color: "var(--text-muted)" }}>× {item.quantity || 1}</span>
</>}
{item.type === "freetext" && <span style={{ color: "var(--text-primary)" }}>{item.description}</span>}
</div>
);
})}
</div>
</div>
)}
{expandedNote && (
<NoteExpandModal
note={expandedNote.note}
noteIndex={expandedNote.index}
canEdit={canEdit}
onSaveEdit={handleEditNote}
onClose={() => setExpandedNote(null)}
startEditing={expandedNote.startEditing}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,243 @@
import { useState } from "react";
import api from "../../../api/client";
import { FLOW_LABELS, PAYMENT_TYPE_LABELS, CATEGORY_LABELS } from "./shared";
const labelStyle = { fontSize: 11, fontWeight: 600, color: "var(--text-muted)", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 4 };
const inputStyle = { backgroundColor: "var(--bg-input)", color: "var(--text-primary)", border: "1px solid var(--border-primary)", borderRadius: 6, padding: "6px 10px", fontSize: 13, width: "100%" };
function ModalShell({ title, onClose, children, saving, onConfirm, confirmLabel = "Confirm" }) {
return (
<div style={{ position: "fixed", inset: 0, backgroundColor: "rgba(0,0,0,0.6)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1000 }}>
<div style={{ backgroundColor: "var(--bg-card)", borderRadius: 12, padding: 24, width: "100%", maxWidth: 460, border: "1px solid var(--border-primary)" }}
onKeyDown={(e) => e.key === "Escape" && onClose()}>
<h3 style={{ fontSize: 15, fontWeight: 700, color: "var(--text-heading)", marginBottom: 18 }}>{title}</h3>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{children}
</div>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 20 }}>
<button type="button" onClick={onClose}
style={{ fontSize: 13, padding: "6px 16px", borderRadius: 6, border: "1px solid var(--border-primary)", backgroundColor: "transparent", color: "var(--text-muted)", cursor: "pointer" }}>
Cancel
</button>
<button type="button" onClick={onConfirm} disabled={saving}
style={{ fontSize: 13, fontWeight: 600, padding: "6px 18px", borderRadius: 6, border: "none", backgroundColor: "var(--btn-primary)", color: "#fff", cursor: "pointer", opacity: saving ? 0.6 : 1 }}>
{saving ? "Saving..." : confirmLabel}
</button>
</div>
</div>
</div>
);
}
// ── Init Negotiations Modal ────────────────────────────────────────────────────
export function InitNegotiationsModal({ customerId, user, onClose, onSuccess }) {
const [form, setForm] = useState({
date: new Date().toISOString().slice(0, 16),
title: "",
note: "",
});
const [saving, setSaving] = useState(false);
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }));
const handleConfirm = async () => {
if (!form.title.trim()) { alert("Title is required."); return; }
setSaving(true);
try {
await api.post(`/crm/customers/${customerId}/orders/init-negotiations`, {
title: form.title.trim(),
note: form.note.trim(),
date: form.date ? new Date(form.date).toISOString() : new Date().toISOString(),
created_by: user?.name || "Staff",
});
// Auto-set customer to Active when a new order is initiated
try {
await api.patch(`/crm/customers/${customerId}/relationship-status`, { status: "active" });
} catch { /* non-critical */ }
onSuccess();
onClose();
} catch (err) {
alert(err.message);
} finally {
setSaving(false);
}
};
return (
<ModalShell title="Init Negotiations" onClose={onClose} saving={saving} onConfirm={handleConfirm} confirmLabel="Create Order">
<div>
<div style={labelStyle}>Date</div>
<input type="datetime-local" value={form.date} onChange={(e) => set("date", e.target.value)} style={inputStyle} />
</div>
<div>
<div style={labelStyle}>Title <span style={{ color: "var(--danger)" }}>*</span></div>
<input type="text" value={form.title} onChange={(e) => set("title", e.target.value)} style={inputStyle} placeholder="e.g. 3x Wall Mount Units — Athens Office" autoFocus />
</div>
<div>
<div style={labelStyle}>Note (optional)</div>
<textarea rows={3} value={form.note} onChange={(e) => set("note", e.target.value)} style={{ ...inputStyle, resize: "vertical" }} placeholder="Initial note or context..." />
</div>
</ModalShell>
);
}
// ── Record Issue / Support Modal ──────────────────────────────────────────────
export function RecordIssueModal({ customerId, user, onClose, onSuccess }) {
const [type, setType] = useState("issue"); // "issue" | "support"
const [form, setForm] = useState({
date: new Date().toISOString().slice(0, 16),
note: "",
});
const [saving, setSaving] = useState(false);
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }));
const handleConfirm = async () => {
if (!form.note.trim()) { alert("Note is required."); return; }
setSaving(true);
const endpoint = type === "issue" ? "technical-issues" : "install-support";
try {
await api.post(`/crm/customers/${customerId}/${endpoint}`, {
note: form.note.trim(),
opened_by: user?.name || "Staff",
date: form.date ? new Date(form.date).toISOString() : new Date().toISOString(),
});
onSuccess();
onClose();
} catch (err) {
alert(err.message);
} finally {
setSaving(false);
}
};
return (
<ModalShell title="Record Issue / Support" onClose={onClose} saving={saving} onConfirm={handleConfirm} confirmLabel="Submit">
{/* Type toggle */}
<div style={{ display: "flex", gap: 0, borderRadius: 8, overflow: "hidden", border: "1px solid var(--border-primary)" }}>
{[["issue", "Technical Issue"], ["support", "Install Support"]].map(([v, l]) => (
<button
key={v}
type="button"
onClick={() => setType(v)}
style={{
flex: 1, padding: "8px 0", fontSize: 13, fontWeight: 600, cursor: "pointer", border: "none",
backgroundColor: type === v ? (v === "issue" ? "var(--crm-issue-active-bg)" : "var(--crm-support-active-bg)") : "transparent",
color: type === v ? (v === "issue" ? "var(--crm-issue-active-text)" : "var(--crm-support-active-text)") : "var(--text-muted)",
transition: "all 0.15s",
}}
>
{l}
</button>
))}
</div>
<div>
<div style={labelStyle}>Date</div>
<input type="datetime-local" value={form.date} onChange={(e) => set("date", e.target.value)} style={inputStyle} />
</div>
<div>
<div style={labelStyle}>Note <span style={{ color: "var(--danger)" }}>*</span></div>
<textarea rows={3} value={form.note} onChange={(e) => set("note", e.target.value)} style={{ ...inputStyle, resize: "vertical" }} placeholder="Describe the issue or support request..." autoFocus />
</div>
</ModalShell>
);
}
// ── Record Payment Modal ──────────────────────────────────────────────────────
export function RecordPaymentModal({ customerId, user, orders, onClose, onSuccess }) {
const [form, setForm] = useState({
date: new Date().toISOString().slice(0, 16),
flow: "payment",
payment_type: "cash",
category: "full_payment",
amount: "",
currency: "EUR",
invoice_ref: "",
order_ref: "",
recorded_by: user?.name || "",
note: "",
});
const [saving, setSaving] = useState(false);
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }));
const handleConfirm = async () => {
if (!form.amount) { alert("Amount is required."); return; }
setSaving(true);
try {
await api.post(`/crm/customers/${customerId}/transactions`, {
...form,
amount: parseFloat(form.amount) || 0,
date: form.date ? new Date(form.date).toISOString() : new Date().toISOString(),
invoice_ref: form.invoice_ref || null,
order_ref: form.order_ref || null,
payment_type: form.flow === "invoice" ? null : (form.payment_type || null),
note: form.note || "",
});
onSuccess();
onClose();
} catch (err) {
alert(err.message);
} finally {
setSaving(false);
}
};
return (
<ModalShell title="Record Payment" onClose={onClose} saving={saving} onConfirm={handleConfirm} confirmLabel="Record">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
<div>
<div style={labelStyle}>Date</div>
<input type="datetime-local" value={form.date} onChange={(e) => set("date", e.target.value)} style={inputStyle} />
</div>
<div>
<div style={labelStyle}>Flow</div>
<select value={form.flow} onChange={(e) => set("flow", e.target.value)} style={inputStyle}>
{Object.entries(FLOW_LABELS).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</div>
{form.flow !== "invoice" && (
<div>
<div style={labelStyle}>Payment Type</div>
<select value={form.payment_type || ""} onChange={(e) => set("payment_type", e.target.value)} style={inputStyle}>
{Object.entries(PAYMENT_TYPE_LABELS).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</div>
)}
<div>
<div style={labelStyle}>Category</div>
<select value={form.category} onChange={(e) => set("category", e.target.value)} style={inputStyle}>
{Object.entries(CATEGORY_LABELS).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</div>
<div>
<div style={labelStyle}>Amount <span style={{ color: "var(--danger)" }}>*</span></div>
<input type="number" min="0" step="0.01" value={form.amount} onChange={(e) => set("amount", e.target.value)} style={inputStyle} placeholder="0.00" autoFocus />
</div>
<div>
<div style={labelStyle}>Currency</div>
<select value={form.currency} onChange={(e) => set("currency", e.target.value)} style={inputStyle}>
{["EUR", "USD", "GBP"].map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<div style={labelStyle}>Invoice Ref</div>
<input type="text" value={form.invoice_ref} onChange={(e) => set("invoice_ref", e.target.value)} style={inputStyle} placeholder="e.g. INV-2026-001" />
</div>
<div>
<div style={labelStyle}>Order Ref</div>
<select value={form.order_ref} onChange={(e) => set("order_ref", e.target.value)} style={inputStyle}>
<option value=""> None </option>
{(orders || []).map((o) => (
<option key={o.id} value={o.id}>{o.order_number || o.id.slice(0, 8)}{o.title ? `${o.title}` : ""}</option>
))}
</select>
</div>
<div style={{ gridColumn: "1 / -1" }}>
<div style={labelStyle}>Note</div>
<textarea rows={2} value={form.note} onChange={(e) => set("note", e.target.value)} style={{ ...inputStyle, resize: "vertical" }} />
</div>
</div>
</ModalShell>
);
}

View File

@@ -0,0 +1,315 @@
import { useState } from "react";
import api from "../../../api/client";
import { fmtDate } from "./shared";
function fmtDateTimeGR(isoLocal) {
// isoLocal is "YYYY-MM-DDTHH:MM" (value from datetime-local input)
if (!isoLocal) return "";
const d = new Date(isoLocal);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleString("el-GR", {
day: "2-digit", month: "2-digit", year: "numeric",
hour: "2-digit", minute: "2-digit", hour12: false,
});
}
function DateTimeInput({ value, onChange, style }) {
return (
<div style={{ position: "relative" }}>
<input type="datetime-local" value={value} onChange={onChange} style={{ ...style, color: "transparent", caretColor: "var(--text-primary)" }} />
<div style={{
position: "absolute", inset: 0, pointerEvents: "none",
display: "flex", alignItems: "center", paddingLeft: 10,
fontSize: 13, color: "var(--text-primary)",
}}>
{fmtDateTimeGR(value)}
</div>
</div>
);
}
const labelStyle = { fontSize: 11, fontWeight: 600, color: "var(--text-muted)", textTransform: "uppercase", letterSpacing: "0.06em" };
const inputStyle = { backgroundColor: "var(--bg-input)", borderColor: "var(--border-primary)", color: "var(--text-primary)", border: "1px solid var(--border-primary)", borderRadius: 6, padding: "6px 10px", fontSize: 13, width: "100%" };
function DeleteConfirm({ onConfirm, onCancel, deleting }) {
return (
<div style={{ position: "fixed", inset: 0, backgroundColor: "rgba(0,0,0,0.6)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1001 }}>
<div style={{ backgroundColor: "var(--bg-card)", borderRadius: 10, padding: 24, width: 340, border: "1px solid var(--border-primary)" }}>
<p style={{ fontSize: 14, color: "var(--text-primary)", marginBottom: 18 }}>Delete this entry? This cannot be undone.</p>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button type="button" onClick={onCancel} style={{ fontSize: 13, padding: "5px 14px", borderRadius: 6, border: "1px solid var(--border-primary)", backgroundColor: "transparent", color: "var(--text-muted)", cursor: "pointer" }}>Cancel</button>
<button type="button" onClick={onConfirm} disabled={deleting}
style={{ fontSize: 13, fontWeight: 600, padding: "5px 14px", borderRadius: 6, border: "none", backgroundColor: "var(--danger)", color: "#fff", cursor: "pointer", opacity: deleting ? 0.6 : 1 }}>
{deleting ? "Deleting..." : "Delete"}
</button>
</div>
</div>
</div>
);
}
function IssueRow({ item, index, type, onResolve, onEdit, onDelete, canEdit }) {
const [hovered, setHovered] = useState(false);
const dotColor = item.active
? (type === "issue" ? "var(--crm-issue-active-text)" : "var(--crm-support-active-text)")
: "var(--crm-resolved-text)";
const rowBg = item.active
? (type === "issue" ? "var(--crm-issue-active-bg)" : "var(--crm-support-active-bg)")
: "transparent";
return (
<div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: "grid",
gridTemplateColumns: "16px 1fr auto",
gap: 12,
alignItems: "start",
padding: "10px 12px",
borderRadius: 6,
backgroundColor: rowBg,
border: "1px solid",
borderColor: item.active ? dotColor + "44" : "var(--border-secondary)",
marginBottom: 6,
}}>
<div style={{ marginTop: 3, width: 10, height: 10, borderRadius: "50%", backgroundColor: dotColor, flexShrink: 0 }} />
<div>
<p style={{ fontSize: 13, color: "var(--text-primary)", marginBottom: 3, whiteSpace: "pre-wrap" }}>{item.note}</p>
<p style={{ fontSize: 11, color: "var(--text-muted)" }}>
Opened {fmtDate(item.opened_date)} by {item.opened_by}
{!item.active && item.resolved_date && (
<> · Resolved {fmtDate(item.resolved_date)}{item.resolved_by ? ` by ${item.resolved_by}` : ""}</>
)}
</p>
</div>
{canEdit && (
<div style={{ display: "flex", gap: 4, flexShrink: 0, visibility: hovered ? "visible" : "hidden" }}>
<button type="button" onClick={() => onEdit(index, item)}
style={{ fontSize: 11, fontWeight: 600, padding: "3px 8px", borderRadius: 5, border: "1px solid var(--border-primary)", backgroundColor: "transparent", color: "var(--text-secondary)", cursor: "pointer" }}>
Edit
</button>
{item.active && (
<button type="button" onClick={() => onResolve(index)}
style={{ fontSize: 11, fontWeight: 600, padding: "3px 8px", borderRadius: 5, border: "1px solid var(--border-primary)", backgroundColor: "transparent", color: "var(--text-secondary)", cursor: "pointer" }}>
Resolve
</button>
)}
<button type="button" onClick={() => onDelete(index)}
style={{ fontSize: 11, fontWeight: 600, padding: "3px 8px", borderRadius: 5, border: "1px solid var(--danger)", backgroundColor: "transparent", color: "var(--danger)", cursor: "pointer" }}>
Delete
</button>
</div>
)}
</div>
);
}
function IssueCard({ title, type, items, customerId, onUpdated, canEdit, user }) {
const [showForm, setShowForm] = useState(false);
const [note, setNote] = useState("");
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 16));
const [saving, setSaving] = useState(false);
const [resolving, setResolving] = useState(null);
// Edit state
const [editIndex, setEditIndex] = useState(null);
const [editNote, setEditNote] = useState("");
const [editDate, setEditDate] = useState("");
const [editSaving, setEditSaving] = useState(false);
// Delete state
const [deleteIndex, setDeleteIndex] = useState(null);
const [deleting, setDeleting] = useState(false);
const activeCount = (items || []).filter((i) => i.active).length;
const sorted = [...(items || [])].sort((a, b) => (b.opened_date || "").localeCompare(a.opened_date || ""));
const endpoint = type === "issue" ? "technical-issues" : "install-support";
const handleSubmit = async () => {
if (!note.trim()) return;
setSaving(true);
try {
const updated = await api.post(`/crm/customers/${customerId}/${endpoint}`, {
note: note.trim(),
opened_by: user?.name || "Staff",
date: new Date(date).toISOString(),
});
onUpdated(updated);
setNote("");
setDate(new Date().toISOString().slice(0, 16));
setShowForm(false);
} catch (err) {
alert(err.message);
} finally {
setSaving(false);
}
};
const handleResolve = async (index) => {
setResolving(index);
try {
const updated = await api.patch(`/crm/customers/${customerId}/${endpoint}/${index}/resolve`, {
resolved_by: user?.name || "Staff",
});
onUpdated(updated);
} catch (err) {
alert(err.message);
} finally {
setResolving(null);
}
};
const startEdit = (index, item) => {
setEditIndex(index);
setEditNote(item.note || "");
setEditDate(item.opened_date ? new Date(item.opened_date).toISOString().slice(0, 16) : new Date().toISOString().slice(0, 16));
};
const handleEdit = async () => {
if (!editNote.trim()) return;
setEditSaving(true);
try {
const updated = await api.patch(`/crm/customers/${customerId}/${endpoint}/${editIndex}`, {
note: editNote.trim(),
opened_date: new Date(editDate).toISOString(),
});
onUpdated(updated);
setEditIndex(null);
} catch (err) {
alert(err.message);
} finally {
setEditSaving(false);
}
};
const handleDelete = async () => {
setDeleting(true);
try {
const updated = await api.delete(`/crm/customers/${customerId}/${endpoint}/${deleteIndex}`);
onUpdated(updated);
setDeleteIndex(null);
} catch (err) {
alert(err.message);
} finally {
setDeleting(false);
}
};
return (
<div className="ui-section-card mb-4">
<div className="flex items-center justify-between mb-4">
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ fontSize: 14, fontWeight: 600, color: "var(--text-heading)" }}>{title}</span>
{activeCount > 0 && (
<span style={{
fontSize: 11, fontWeight: 700, padding: "1px 8px", borderRadius: 10,
backgroundColor: type === "issue" ? "var(--crm-issue-active-bg)" : "var(--crm-support-active-bg)",
color: type === "issue" ? "var(--crm-issue-active-text)" : "var(--crm-support-active-text)",
}}>
{activeCount} active
</span>
)}
</div>
{canEdit && (
<button type="button" onClick={() => setShowForm((v) => !v)}
style={{ fontSize: 12, fontWeight: 600, padding: "4px 12px", borderRadius: 6, border: "1px solid var(--border-primary)", backgroundColor: showForm ? "var(--bg-card-hover)" : "transparent", color: "var(--text-secondary)", cursor: "pointer" }}>
+ Report New
</button>
)}
</div>
{/* New entry form */}
{showForm && (
<div style={{ padding: 12, borderRadius: 8, marginBottom: 12, backgroundColor: "var(--bg-primary)", border: "1px solid var(--border-primary)" }}>
<div style={{ display: "flex", gap: 8, alignItems: "center", marginBottom: 8 }}>
<div style={{ flex: 1 }}>
<div style={{ ...labelStyle, marginBottom: 4 }}>Date</div>
<DateTimeInput value={date} onChange={(e) => setDate(e.target.value)} style={inputStyle} />
</div>
<button type="button" onClick={() => setDate(new Date().toISOString().slice(0, 16))}
style={{ marginTop: 20, fontSize: 11, padding: "6px 10px", borderRadius: 5, border: "1px solid var(--border-primary)", backgroundColor: "transparent", color: "var(--text-secondary)", cursor: "pointer", whiteSpace: "nowrap", flexShrink: 0 }}>
Set Now
</button>
</div>
<textarea value={note} onChange={(e) => setNote(e.target.value)} rows={3} placeholder="Describe the issue..."
style={{ width: "100%", padding: "8px 10px", borderRadius: 6, fontSize: 13, backgroundColor: "var(--bg-input)", borderColor: "var(--border-primary)", color: "var(--text-primary)", border: "1px solid var(--border-primary)", resize: "vertical" }} />
<div className="flex gap-2 mt-2 justify-end">
<button type="button" onClick={() => { setShowForm(false); setNote(""); setDate(new Date().toISOString().slice(0, 16)); }}
style={{ fontSize: 12, padding: "4px 12px", borderRadius: 5, border: "1px solid var(--border-primary)", backgroundColor: "transparent", color: "var(--text-muted)", cursor: "pointer" }}>
Cancel
</button>
<button type="button" onClick={handleSubmit} disabled={saving || !note.trim()}
style={{ fontSize: 12, fontWeight: 600, padding: "4px 14px", borderRadius: 5, border: "none", backgroundColor: "var(--btn-primary)", color: "#fff", cursor: "pointer", opacity: saving ? 0.6 : 1 }}>
{saving ? "Saving..." : "Submit"}
</button>
</div>
</div>
)}
{/* Edit form */}
{editIndex !== null && (
<div style={{ padding: 12, borderRadius: 8, marginBottom: 12, backgroundColor: "var(--bg-primary)", border: "1px solid var(--accent)44" }}>
<div style={{ fontSize: 12, fontWeight: 600, color: "var(--text-heading)", marginBottom: 8 }}>Edit Entry</div>
<div style={{ display: "flex", gap: 8, alignItems: "center", marginBottom: 8 }}>
<div style={{ flex: 1 }}>
<div style={{ ...labelStyle, marginBottom: 4 }}>Date</div>
<DateTimeInput value={editDate} onChange={(e) => setEditDate(e.target.value)} style={inputStyle} />
</div>
<button type="button" onClick={() => setEditDate(new Date().toISOString().slice(0, 16))}
style={{ marginTop: 20, fontSize: 11, padding: "6px 10px", borderRadius: 5, border: "1px solid var(--border-primary)", backgroundColor: "transparent", color: "var(--text-secondary)", cursor: "pointer", whiteSpace: "nowrap", flexShrink: 0 }}>
Set Now
</button>
</div>
<textarea value={editNote} onChange={(e) => setEditNote(e.target.value)} rows={3}
style={{ width: "100%", padding: "8px 10px", borderRadius: 6, fontSize: 13, backgroundColor: "var(--bg-input)", color: "var(--text-primary)", border: "1px solid var(--border-primary)", resize: "vertical" }} />
<div className="flex gap-2 mt-2 justify-end">
<button type="button" onClick={() => setEditIndex(null)}
style={{ fontSize: 12, padding: "4px 12px", borderRadius: 5, border: "1px solid var(--border-primary)", backgroundColor: "transparent", color: "var(--text-muted)", cursor: "pointer" }}>
Cancel
</button>
<button type="button" onClick={handleEdit} disabled={editSaving || !editNote.trim()}
style={{ fontSize: 12, fontWeight: 600, padding: "4px 14px", borderRadius: 5, border: "none", backgroundColor: "var(--btn-primary)", color: "#fff", cursor: "pointer", opacity: editSaving ? 0.6 : 1 }}>
{editSaving ? "Saving..." : "Save"}
</button>
</div>
</div>
)}
{sorted.length === 0 ? (
<p style={{ fontSize: 13, color: "var(--text-muted)", fontStyle: "italic" }}>No entries.</p>
) : (
<div style={{ maxHeight: sorted.length > 5 ? 360 : "none", overflowY: sorted.length > 5 ? "auto" : "visible" }}>
{sorted.map((item, i) => {
const origIdx = (items || []).findIndex((x) => x === item || (x.opened_date === item.opened_date && x.note === item.note));
return (
<IssueRow
key={i}
item={item}
index={origIdx}
type={type}
onResolve={handleResolve}
onEdit={startEdit}
onDelete={(idx) => setDeleteIndex(idx)}
canEdit={canEdit}
/>
);
})}
</div>
)}
{deleteIndex !== null && (
<DeleteConfirm onConfirm={handleDelete} onCancel={() => setDeleteIndex(null)} deleting={deleting} />
)}
</div>
);
}
export default function SupportTab({ customer, canEdit, onCustomerUpdated, user }) {
return (
<div>
<IssueCard title="Technical Issues" type="issue" items={customer.technical_issues || []} customerId={customer.id} onUpdated={onCustomerUpdated} canEdit={canEdit} user={user} />
<IssueCard title="Install Support" type="support" items={customer.install_support || []} customerId={customer.id} onUpdated={onCustomerUpdated} canEdit={canEdit} user={user} />
</div>
);
}

View File

@@ -0,0 +1,137 @@
// Shared constants and helpers for CustomerDetail sub-components
export const ORDER_STATUS_LABELS = {
negotiating: "Negotiating",
awaiting_quotation: "Awaiting Quotation",
awaiting_customer_confirmation: "Awaiting Confirmation",
awaiting_fulfilment: "Awaiting Fulfilment",
awaiting_payment: "Awaiting Payment",
manufacturing: "Manufacturing",
shipped: "Shipped",
installed: "Installed",
declined: "Declined",
complete: "Complete",
};
export const ORDER_STATUS_STYLES = {
negotiating: { bg: "var(--crm-ord-negotiating-bg)", color: "var(--crm-ord-negotiating-text)" },
awaiting_quotation: { bg: "var(--crm-ord-awaiting_quotation-bg)",color: "var(--crm-ord-awaiting_quotation-text)" },
awaiting_customer_confirmation: { bg: "var(--crm-ord-awaiting_customer_confirmation-bg)", color: "var(--crm-ord-awaiting_customer_confirmation-text)" },
awaiting_fulfilment: { bg: "var(--crm-ord-awaiting_fulfilment-bg)",color: "var(--crm-ord-awaiting_fulfilment-text)" },
awaiting_payment: { bg: "var(--crm-ord-awaiting_payment-bg)", color: "var(--crm-ord-awaiting_payment-text)" },
manufacturing: { bg: "var(--crm-ord-manufacturing-bg)", color: "var(--crm-ord-manufacturing-text)" },
shipped: { bg: "var(--crm-ord-shipped-bg)", color: "var(--crm-ord-shipped-text)" },
installed: { bg: "var(--crm-ord-installed-bg)", color: "var(--crm-ord-installed-text)" },
declined: { bg: "var(--crm-ord-declined-bg)", color: "var(--crm-ord-declined-text)" },
complete: { bg: "var(--crm-ord-complete-bg)", color: "var(--crm-ord-complete-text)" },
};
export const REL_STATUS_LABELS = {
lead: "Lead",
prospect: "Prospect",
active: "Active",
inactive: "Inactive",
churned: "Churned",
};
export const REL_STATUS_STYLES = {
lead: { bg: "var(--crm-rel-lead-bg)", color: "var(--crm-rel-lead-text)", border: "var(--crm-rel-lead-border)" },
prospect: { bg: "var(--crm-rel-prospect-bg)", color: "var(--crm-rel-prospect-text)", border: "var(--crm-rel-prospect-border)" },
active: { bg: "var(--crm-rel-active-bg)", color: "var(--crm-rel-active-text)", border: "var(--crm-rel-active-border)" },
inactive: { bg: "var(--crm-rel-inactive-bg)", color: "var(--crm-rel-inactive-text)", border: "var(--crm-rel-inactive-border)" },
churned: { bg: "var(--crm-rel-churned-bg)", color: "var(--crm-rel-churned-text)", border: "var(--crm-rel-churned-border)" },
};
export const TIMELINE_TYPE_LABELS = {
negotiations_started: "Started Negotiations",
quote_request: "Quote Requested",
quote_sent: "Quote Sent",
quote_accepted: "Quote Accepted",
quote_declined: "Quote Declined",
mfg_started: "Manufacturing Started",
mfg_complete: "Manufacturing Complete",
order_shipped: "Order Shipped",
installed: "Installed",
payment_received: "Payment Received",
invoice_sent: "Invoice Sent",
note: "Note",
};
export const PAYMENT_TYPE_LABELS = {
cash: "Cash",
bank_transfer: "Bank Transfer",
card: "Card",
paypal: "PayPal",
};
export const FLOW_LABELS = {
invoice: "Invoice",
payment: "Payment",
refund: "Refund",
credit: "Credit",
};
export const CATEGORY_LABELS = {
full_payment: "Full Payment",
advance: "Advance",
installment: "Installment",
};
export function fmtDate(iso) {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "2-digit" });
}
export function fmtDateTime(iso) {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "2-digit" })
+ " " + d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
}
export function fmtDateFull(iso) {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleDateString("en-GB", { day: "2-digit", month: "long", year: "numeric" });
}
export function OrderStatusChip({ status, style }) {
const s = ORDER_STATUS_STYLES[status] || { bg: "var(--bg-card-hover)", color: "var(--text-secondary)" };
return (
<span style={{
display: "inline-block",
padding: "2px 8px",
borderRadius: 12,
fontSize: 11,
fontWeight: 600,
backgroundColor: s.bg,
color: s.color,
...style,
}}>
{ORDER_STATUS_LABELS[status] || status}
</span>
);
}
export function RelStatusChip({ status, style }) {
const s = REL_STATUS_STYLES[status] || { bg: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "var(--border-primary)" };
return (
<span style={{
display: "inline-block",
padding: "2px 10px",
borderRadius: 12,
fontSize: 11,
fontWeight: 600,
backgroundColor: s.bg,
color: s.color,
border: `1px solid ${s.border}`,
...style,
}}>
{REL_STATUS_LABELS[status] || status}
</span>
);
}

View File

@@ -26,6 +26,13 @@ const TITLES = [
{ value: "Prof.", label: "Professor" }, { value: "Prof.", label: "Professor" },
]; ];
const PRESET_TAGS = ["church", "monastery", "municipality", "school", "repeat-customer", "vip", "pending", "inactive"]; const PRESET_TAGS = ["church", "monastery", "municipality", "school", "repeat-customer", "vip", "pending", "inactive"];
const REL_STATUS_OPTIONS = [
{ value: "lead", label: "Lead" },
{ value: "prospect", label: "Prospect" },
{ value: "active", label: "Active" },
{ value: "inactive", label: "Inactive" },
{ value: "churned", label: "Churned" },
];
const CONTACT_TYPE_ICONS = { const CONTACT_TYPE_ICONS = {
email: "📧", email: "📧",
@@ -85,6 +92,7 @@ export default function CustomerForm() {
organization: "", organization: "",
religion: "", religion: "",
language: "el", language: "el",
relationship_status: "lead",
tags: [], tags: [],
folder_id: "", folder_id: "",
location: { address: "", city: "", postal_code: "", region: "", country: "" }, location: { address: "", city: "", postal_code: "", region: "", country: "" },
@@ -117,6 +125,7 @@ export default function CustomerForm() {
organization: data.organization || "", organization: data.organization || "",
religion: data.religion || "", religion: data.religion || "",
language: data.language || "el", language: data.language || "el",
relationship_status: data.relationship_status || "lead",
tags: data.tags || [], tags: data.tags || [],
folder_id: data.folder_id || "", folder_id: data.folder_id || "",
location: { location: {
@@ -201,6 +210,7 @@ export default function CustomerForm() {
organization: form.organization.trim() || null, organization: form.organization.trim() || null,
religion: form.religion.trim() || null, religion: form.religion.trim() || null,
language: form.language, language: form.language,
relationship_status: form.relationship_status || "lead",
tags: form.tags, tags: form.tags,
...(!isEdit && { folder_id: form.folder_id.trim().toLowerCase() }), ...(!isEdit && { folder_id: form.folder_id.trim().toLowerCase() }),
location: { location: {
@@ -328,8 +338,14 @@ export default function CustomerForm() {
</Field> </Field>
</div> </div>
{/* Row 2: Organization, Religion, Language, Folder ID */} {/* Row 2: Relationship Status + Organization + Religion + Language + Folder ID */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr 1fr", gap: 16, marginBottom: 16 }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr 1fr 1fr", gap: 16, marginBottom: 16 }}>
<Field label="Relationship Status">
<select className={inputClass} style={inputStyle} value={form.relationship_status}
onChange={(e) => set("relationship_status", e.target.value)}>
{REL_STATUS_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</Field>
<Field label="Organization"> <Field label="Organization">
<input className={inputClass} style={inputStyle} value={form.organization} <input className={inputClass} style={inputStyle} value={form.organization}
onChange={(e) => set("organization", e.target.value)} placeholder="Church, organization, etc." /> onChange={(e) => set("organization", e.target.value)} placeholder="Church, organization, etc." />

View File

@@ -3,6 +3,23 @@ import { useNavigate } from "react-router-dom";
import api from "../../api/client"; import api from "../../api/client";
import { useAuth } from "../../auth/AuthContext"; import { useAuth } from "../../auth/AuthContext";
// ── Customer-status SVG icon imports ─────────────────────────────────────────
import clientIcon from "../../assets/customer-status/client.svg?raw";
import negotiatingIcon from "../../assets/customer-status/negotiating.svg?raw";
import awaitingQuotationIcon from "../../assets/customer-status/awating-quotation.svg?raw";
import awaitingConfirmIcon from "../../assets/customer-status/awaiting-confirmation.svg?raw";
import quotationAcceptedIcon from "../../assets/customer-status/quotation-accepted.svg?raw";
import startedMfgIcon from "../../assets/customer-status/started-mfg.svg?raw";
import awaitingPaymentIcon from "../../assets/customer-status/awaiting-payment.svg?raw";
import shippedIcon from "../../assets/customer-status/shipped.svg?raw";
import inactiveIcon from "../../assets/customer-status/inactive.svg?raw";
import declinedIcon from "../../assets/customer-status/declined.svg?raw";
import churnedIcon from "../../assets/customer-status/churned.svg?raw";
import orderIcon from "../../assets/customer-status/order.svg?raw";
import exclamationIcon from "../../assets/customer-status/exclamation.svg?raw";
import wrenchIcon from "../../assets/customer-status/wrench.svg?raw";
const inputStyle = { const inputStyle = {
backgroundColor: "var(--bg-input)", backgroundColor: "var(--bg-input)",
borderColor: "var(--border-primary)", borderColor: "var(--border-primary)",
@@ -46,6 +63,7 @@ function resolveLanguage(val) {
const ALL_COLUMNS = [ const ALL_COLUMNS = [
{ id: "name", label: "Name", default: true, locked: true }, { id: "name", label: "Name", default: true, locked: true },
{ id: "status", label: "Status", default: true }, { id: "status", label: "Status", default: true },
{ id: "support", label: "Support", default: true },
{ id: "organization", label: "Organization", default: true }, { id: "organization", label: "Organization", default: true },
{ id: "address", label: "Full Address", default: true }, { id: "address", label: "Full Address", default: true },
{ id: "location", label: "Location", default: true }, { id: "location", label: "Location", default: true },
@@ -95,35 +113,6 @@ function saveColumnPrefs(visible, orderedIds) {
localStorage.setItem(COL_ORDER_KEY, JSON.stringify(orderedIds)); localStorage.setItem(COL_ORDER_KEY, JSON.stringify(orderedIds));
} }
// ── Inline SVG icon components (currentColor, no hardcoded fills) ──────────
function IconNegotiations({ style }) {
return (
<svg style={style} viewBox="0 -8 72 72" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
<path d="M64,12.78v17s-3.63.71-4.38.81-3.08.85-4.78-.78C52.22,27.25,42.93,18,42.93,18a3.54,3.54,0,0,0-4.18-.21c-2.36,1.24-5.87,3.07-7.33,3.78a3.37,3.37,0,0,1-5.06-2.64,3.44,3.44,0,0,1,2.1-3c3.33-2,10.36-6,13.29-7.52,1.78-1,3.06-1,5.51,1C50.27,12,53,14.27,53,14.27a2.75,2.75,0,0,0,2.26.43C58.63,14,64,12.78,64,12.78ZM27,41.5a3,3,0,0,0-3.55-4.09,3.07,3.07,0,0,0-.64-3,3.13,3.13,0,0,0-3-.75,3.07,3.07,0,0,0-.65-3,3.38,3.38,0,0,0-4.72.13c-1.38,1.32-2.27,3.72-1,5.14s2.64.55,3.72.3c-.3,1.07-1.2,2.07-.09,3.47s2.64.55,3.72.3c-.3,1.07-1.16,2.16-.1,3.46s2.84.61,4,.25c-.45,1.15-1.41,2.39-.18,3.79s4.08.75,5.47-.58a3.32,3.32,0,0,0,.3-4.68A3.18,3.18,0,0,0,27,41.5Zm25.35-8.82L41.62,22a3.53,3.53,0,0,0-3.77-.68c-1.5.66-3.43,1.56-4.89,2.24a8.15,8.15,0,0,1-3.29,1.1,5.59,5.59,0,0,1-3-10.34C29,12.73,34.09,10,34.09,10a6.46,6.46,0,0,0-5-2C25.67,8,18.51,12.7,18.51,12.7a5.61,5.61,0,0,1-4.93.13L8,10.89v19.4s1.59.46,3,1a6.33,6.33,0,0,1,1.56-2.47,6.17,6.17,0,0,1,8.48-.06,5.4,5.4,0,0,1,1.34,2.37,5.49,5.49,0,0,1,2.29,1.4A5.4,5.4,0,0,1,26,34.94a5.47,5.47,0,0,1,3.71,4,5.38,5.38,0,0,1,2.39,1.43,5.65,5.65,0,0,1,1.48,4.89,0,0,0,0,1,0,0s.8.9,1.29,1.39a2.46,2.46,0,0,0,3.48-3.48s2,2.48,4.28,1c2-1.4,1.69-3.06.74-4a3.19,3.19,0,0,0,4.77.13,2.45,2.45,0,0,0,.13-3.3s1.33,1.81,4,.12c1.89-1.6,1-3.43,0-4.39Z"/>
</svg>
);
}
function IconIssues({ style }) {
return (
<svg style={style} viewBox="0 0 283.722 283.722" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
<path d="M184.721,128.156c4.398-14.805,7.516-29.864,8.885-43.783c0.06-0.607-0.276-1.159-0.835-1.373l-70.484-26.932c-0.152-0.058-0.312-0.088-0.475-0.088c-0.163,0-0.322,0.03-0.474,0.088L50.851,83c-0.551,0.21-0.894,0.775-0.835,1.373c2.922,29.705,13.73,64.62,28.206,91.12c14.162,25.923,30.457,41.4,43.589,41.4c8.439,0,18.183-6.4,27.828-17.846l-16.375-16.375c-14.645-14.645-14.645-38.389,0-53.033C147.396,115.509,169.996,115.017,184.721,128.156z"/>
<path d="M121.812,236.893c-46.932,0-85.544-87.976-91.7-150.562c-0.94-9.56,4.627-18.585,13.601-22.013l70.486-26.933c2.451-0.937,5.032-1.405,7.613-1.405c2.581,0,5.162,0.468,7.614,1.405l70.484,26.932c8.987,3.434,14.542,12.439,13.6,22.013c-1.773,18.028-6.244,38.161-12.826,57.693l11.068,11.068l17.865-17.866c6.907-20.991,11.737-42.285,13.845-61.972c1.322-12.347-5.53-24.102-16.934-29.017l-93.512-40.3c-7.152-3.082-15.257-3.082-22.409,0l-93.512,40.3C5.705,51.147-1.159,62.922,0.162,75.255c8.765,81.851,64.476,191.512,121.65,191.512c0.356,0,0.712-0.023,1.068-0.032c-1.932-10.793,0.888-22.262,8.456-31.06C128.205,236.465,125.029,236.893,121.812,236.893z"/>
<path d="M240.037,208.125c7.327-7.326,30.419-30.419,37.827-37.827c7.81-7.811,7.81-20.475,0-28.285c-7.811-7.811-20.475-7.811-28.285,0c-7.41,7.41-30.5,30.5-37.827,37.827l-37.827-37.827c-7.81-7.811-20.475-7.811-28.285,0c-7.811,7.811-7.811,20.475,0,28.285l37.827,37.827c-7.326,7.326-30.419,30.419-37.827,37.827c-7.811,7.811-7.811,20.475,0,28.285c7.809,7.809,20.474,7.811,28.285,0c7.41-7.41,30.5-30.499,37.827-37.827l37.827,37.827c7.809,7.809,20.474,7.811,28.285,0c7.81-7.81,7.81-20.475,0-28.285L240.037,208.125z"/>
</svg>
);
}
function IconImportant({ style, className }) {
return (
<svg style={style} className={className} viewBox="0 0 299.467 299.467" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
<path d="M293.588,219.182L195.377,32.308c-8.939-17.009-26.429-27.575-45.644-27.575s-36.704,10.566-45.644,27.575L5.879,219.182c-8.349,15.887-7.77,35.295,1.509,50.647c9.277,15.36,26.189,24.903,44.135,24.903h196.422c17.943,0,34.855-9.542,44.133-24.899C301.357,254.477,301.936,235.069,293.588,219.182z M266.4,254.319c-3.881,6.424-10.953,10.414-18.456,10.414H51.522c-7.505,0-14.576-3.99-18.457-10.417c-3.88-6.419-4.121-14.534-0.63-21.177l98.211-186.876c3.737-7.112,11.052-11.531,19.087-11.531s15.35,4.418,19.087,11.531l98.211,186.876C270.522,239.782,270.281,247.897,266.4,254.319z"/>
<polygon points="144.037,201.424 155.429,201.424 166.545,87.288 132.92,87.288"/>
<path d="M149.733,212.021c-8.98,0-16.251,7.272-16.251,16.252c0,8.971,7.271,16.251,16.251,16.251c8.979,0,16.251-7.28,16.251-16.251C165.984,219.294,158.713,212.021,149.733,212.021z"/>
</svg>
);
}
// ── Status icons helpers ───────────────────────────────────────────────────── // ── Status icons helpers ─────────────────────────────────────────────────────
@@ -136,46 +125,205 @@ function statusColors(direction) {
return { negColor, issColor, pendingOurReply }; return { negColor, issColor, pendingOurReply };
} }
// ── Quick mode status cell ─────────────────────────────────────────────────── // ── Customer status icon resolver ─────────────────────────────────────────────
// Icon sizes — edit these to adjust icon dimensions per mode: // Pre-manufacturing statuses: customer can still go silent/churn
// Quick mode icons: QUICK_ICON_SIZE (negotiations slightly larger than issues) const PRE_MFG_STATUSES = new Set([
const QUICK_NEG_ICON_SIZE = 25; // px — negotiations icon in Quick mode "negotiating", "awaiting_quotation", "awaiting_customer_confirmation",
const QUICK_ISS_ICON_SIZE = 20; // px — issues icon in Quick mode "awaiting_fulfilment", "awaiting_payment",
const QUICK_IMP_ICON_SIZE = 17; // px — exclamation icon in Quick mode ]);
// Expanded sub-row icons:
const EXP_NEG_ICON_SIZE = 22; // px — negotiations icon in Expanded sub-rows
const EXP_ISS_ICON_SIZE = 16; // px — issues icon in Expanded sub-rows
const EXP_IMP_ICON_SIZE = 12; // px — exclamation icon in Expanded sub-rows
function StatusIconsCell({ customer, direction }) { // Returns { icon, color, title } for a customer based on their status + orders
const hasNeg = customer.negotiating; function resolveStatusIcon(customer) {
const hasIssue = customer.has_problem; const status = customer.relationship_status || "lead";
if (!hasNeg && !hasIssue) return <td className="px-3 py-3" />; const summary = customer.crm_summary || {};
const allOrders = summary.all_orders_statuses || [];
const { negColor, issColor, pendingOurReply } = statusColors(direction); // ── Churned ────────────────────────────────────────────────────────────────
if (status === "churned") {
return { icon: churnedIcon, color: "var(--crm-customer-icon-muted)", title: "Churned" };
}
// ── Lead / Prospect ────────────────────────────────────────────────────────
if (status === "lead") {
return { icon: clientIcon, color: "var(--crm-customer-icon-lead)", title: "Lead" };
}
if (status === "prospect") {
return { icon: clientIcon, color: "var(--crm-customer-icon-info)", title: "Prospect" };
}
// ── Inactive ───────────────────────────────────────────────────────────────
// Always show inactive icon; backend polling corrects wrongly-inactive records.
if (status === "inactive") {
return { icon: inactiveIcon, color: "var(--crm-customer-icon-muted)", title: "Inactive" };
}
// ── Active ─────────────────────────────────────────────────────────────────
if (status === "active") {
const activeOrderStatus = summary.active_order_status;
const orderIconMap = {
negotiating: { icon: negotiatingIcon, color: "var(--crm-customer-icon-passive)", title: "Negotiating" },
awaiting_quotation: { icon: awaitingQuotationIcon, color: "var(--crm-customer-icon-passive)", title: "Awaiting Quotation" },
awaiting_customer_confirmation: { icon: awaitingConfirmIcon, color: "var(--crm-customer-icon-passive)", title: "Awaiting Confirmation" },
awaiting_fulfilment: { icon: quotationAcceptedIcon, color: "var(--crm-customer-icon-info)", title: "Awaiting Fulfilment" },
awaiting_payment: { icon: awaitingPaymentIcon, color: "var(--crm-customer-icon-payment)", title: "Awaiting Payment" },
manufacturing: { icon: startedMfgIcon, color: "var(--crm-customer-icon-positive)", title: "Manufacturing" },
shipped: { icon: shippedIcon, color: "var(--crm-customer-icon-positive)", title: "Shipped" },
installed: { icon: inactiveIcon, color: "var(--crm-customer-icon-positive)", title: "Installed" },
};
// 1. There is an open order → show its icon.
// active_order_status is only set for non-terminal (non-declined, non-complete) orders.
if (activeOrderStatus && orderIconMap[activeOrderStatus]) {
return orderIconMap[activeOrderStatus];
}
// From here: no open orders. Determine why via all_orders_statuses.
// Note: all_orders_statuses may be absent on older records not yet re-summarised.
const allDeclined = allOrders.length > 0 && allOrders.every((s) => s === "declined");
const allComplete = allOrders.length > 0 && allOrders.every((s) => s === "complete");
// 2. All orders declined → show declined icon; staff decides next step.
if (allDeclined) {
return { icon: declinedIcon, color: "var(--crm-customer-icon-declined)", title: "All orders declined" };
}
// 3. All orders complete → should have auto-flipped to inactive already.
if (allComplete) {
return { icon: inactiveIcon, color: "var(--crm-customer-icon-positive)", title: "All orders complete" };
}
// 4. No orders at all (edge case: newly active, or old record without summary).
return { icon: inactiveIcon, color: "var(--crm-customer-icon-info)", title: "Active, no orders" };
}
return { icon: clientIcon, color: "var(--crm-customer-icon-muted)", title: status };
}
// ── Status icon size ─────────────────────────────────────────────────────────
// Status icon box size (px) — same for all status icons so layout is consistent
const STATUS_ICON_SIZE = 22;
const REL_STATUS_STYLES = {
lead: { bg: "var(--crm-rel-lead-bg)", color: "var(--crm-rel-lead-text)", border: "var(--crm-rel-lead-border)" },
prospect: { bg: "var(--crm-rel-prospect-bg)", color: "var(--crm-rel-prospect-text)", border: "var(--crm-rel-prospect-border)" },
active: { bg: "var(--crm-rel-active-bg)", color: "var(--crm-rel-active-text)", border: "var(--crm-rel-active-border)" },
inactive: { bg: "var(--crm-rel-inactive-bg)", color: "var(--crm-rel-inactive-text)", border: "var(--crm-rel-inactive-border)" },
churned: { bg: "var(--crm-rel-churned-bg)", color: "var(--crm-rel-churned-text)", border: "var(--crm-rel-churned-border)" },
};
const REL_STATUS_LABELS = { lead:"Lead", prospect:"Prospect", active:"Active", inactive:"Inactive", churned:"Churned" };
const ORDER_STATUS_LABELS = {
negotiating:"Negotiating", awaiting_quotation:"Awaiting Quotation",
awaiting_customer_confirmation:"Awaiting Confirmation", awaiting_fulfilment:"Awaiting Fulfilment",
awaiting_payment:"Awaiting Payment", manufacturing:"Manufacturing", shipped:"Shipped",
installed:"Installed", declined:"Declined", complete:"Complete",
};
function renderMaskedIcon(icon, color, title, size = STATUS_ICON_SIZE) {
const svgMarkup = icon
.replace(/<\?xml[\s\S]*?\?>/gi, "")
.replace(/<!DOCTYPE[\s\S]*?>/gi, "")
.replace(/<!--[\s\S]*?-->/g, "")
.replace(
/<svg\b([^>]*)>/i,
`<svg$1 width="${size}" height="${size}" aria-label="${title}" role="img" focusable="false" style="display:block;width:${size}px;height:${size}px;color:${color};fill:currentColor;">`,
);
return (
<span
title={title}
aria-label={title}
role="img"
style={{
width: size,
height: size,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
verticalAlign: "middle",
flexShrink: 0,
lineHeight: 0,
}}
dangerouslySetInnerHTML={{ __html: svgMarkup }}
/>
);
}
function StatusCell({ customer, lastCommDate, onChurnUpdate }) {
const { icon, color, title } = resolveStatusIcon(customer);
// Auto-churn: active + has an open pre-mfg order + 12+ months since last comm
useEffect(() => {
if ((customer.relationship_status || "lead") !== "active") return;
if (!lastCommDate) return;
const allOrders = (customer.crm_summary?.all_orders_statuses) || [];
if (!allOrders.some((s) => PRE_MFG_STATUSES.has(s))) return;
const days = Math.floor((Date.now() - new Date(lastCommDate).getTime()) / 86400000);
if (days < 365) return;
onChurnUpdate?.(customer.id);
}, [customer.id, customer.relationship_status, lastCommDate]);
return ( return (
<td className="px-3 py-3" style={{ textAlign: "center" }}> <td className="px-3 py-3" style={{ textAlign: "center" }}>
<div style={{ display: "inline-flex", alignItems: "center", gap: 6 }}> {renderMaskedIcon(icon, color, title)}
{hasNeg && ( </td>
<span );
title={pendingOurReply ? "Negotiating — client awaiting our reply" : "Negotiating — we sent last"} }
style={{ color: negColor, display: "inline-flex" }}
> // ── Icon color filter system ──────────────────────────────────────────────────
<IconNegotiations style={{ width: QUICK_NEG_ICON_SIZE, height: QUICK_NEG_ICON_SIZE, display: "inline-block", flexShrink: 0 }} /> // CSS filters starting from a black SVG source.
</span> // ── Icon tinting ──────────────────────────────────────────────────────────────
)} // Maps every color token used in resolveStatusIcon to a pre-computed CSS filter.
// To change a color: update BOTH the color value in resolveStatusIcon AND add/update
// its entry here. Use https://codepen.io/sosuke/pen/Pjoqqp to generate the filter.
//
// All filters start with brightness(0) saturate(100%) to zero out the source black,
// then the remaining steps shift to the target color.
const ICON_FILTER_MAP = {
// lead — near-white beige #f5f0e8
"#f5f0e8": "brightness(0) saturate(100%) invert(96%) sepia(10%) saturate(200%) hue-rotate(330deg) brightness(103%)",
// prospect / active / manufacturing / shipped / installed — green, var(--crm-rel-active-text) ≈ #22c55e
"var(--crm-rel-active-text)": "brightness(0) saturate(100%) invert(69%) sepia(48%) saturate(500%) hue-rotate(95deg) brightness(95%)",
"var(--crm-rel-prospect-text)": "brightness(0) saturate(100%) invert(69%) sepia(48%) saturate(500%) hue-rotate(95deg) brightness(95%)",
// inactive / churned / all-declined / silent — mid grey, var(--text-muted) ≈ #737373
"var(--text-muted)": "brightness(0) saturate(100%) invert(48%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(95%)",
// negotiating / awaiting_quotation / awaiting_confirmation — bright grey, var(--text-primary) ≈ #d4d4d4
"var(--text-primary)": "brightness(0) saturate(100%) invert(87%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(100%)",
// awaiting_fulfilment — light blue #7ab4e0
"#7ab4e0": "brightness(0) saturate(100%) invert(67%) sepia(35%) saturate(500%) hue-rotate(182deg) brightness(105%)",
// awaiting_payment — yellow #e8c040
"#e8c040": "brightness(0) saturate(100%) invert(80%) sepia(55%) saturate(700%) hue-rotate(8deg) brightness(102%)",
// declined — soft red #e07070
"#e07070": "brightness(0) saturate(100%) invert(52%) sepia(40%) saturate(700%) hue-rotate(314deg) brightness(108%)",
};
function buildIconFilter(colorToken) {
if (!colorToken) return "";
return ICON_FILTER_MAP[colorToken] || "";
}
function SupportCell({ customer }) {
const summary = customer.crm_summary || {};
const hasIssue = (summary.active_issues_count || 0) > 0;
const hasSupport = (summary.active_support_count || 0) > 0;
if (!hasIssue && !hasSupport) return <td className="px-3 py-3" />;
return (
<td className="px-3 py-3">
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 4 }}>
{hasIssue && ( {hasIssue && (
<span <span title={`${summary.active_issues_count} active technical issue(s)`}
title={pendingOurReply ? "Open issue — client awaiting our reply" : "Open issue — we last contacted them"} style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
style={{ color: issColor, display: "inline-flex" }} <span style={{ width: 10, height: 10, borderRadius: "50%", backgroundColor: "var(--crm-issue-active-text)", display: "inline-block", flexShrink: 0 }} />
> <span style={{ fontSize: 11, color: "var(--crm-issue-active-text)", fontWeight: 700 }}>{summary.active_issues_count}</span>
<IconIssues style={{ width: QUICK_ISS_ICON_SIZE, height: QUICK_ISS_ICON_SIZE, display: "inline-block", flexShrink: 0 }} />
</span> </span>
)} )}
{(hasNeg || hasIssue) && pendingOurReply && ( {hasSupport && (
<span title="Awaiting our reply" style={{ color: "var(--crm-status-alert)", display: "inline-flex" }}> <span title={`${summary.active_support_count} active support item(s)`}
<IconImportant style={{ width: QUICK_IMP_ICON_SIZE, height: QUICK_IMP_ICON_SIZE, display: "inline-block", flexShrink: 0 }} className="crm-icon-breathe" /> style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
<span style={{ width: 10, height: 10, borderRadius: "50%", backgroundColor: "var(--crm-support-active-text)", display: "inline-block", flexShrink: 0 }} />
<span style={{ fontSize: 11, color: "var(--crm-support-active-text)", fontWeight: 700 }}>{summary.active_support_count}</span>
</span> </span>
)} )}
</div> </div>
@@ -183,18 +331,6 @@ function StatusIconsCell({ customer, direction }) {
); );
} }
// ── Original inline icons (small, in name cell) ──────────────────────────────
function relDays(dateStr) {
if (!dateStr) return null;
const d = new Date(dateStr);
if (isNaN(d)) return null;
const days = Math.floor((Date.now() - d.getTime()) / 86400000);
if (days === 0) return "today";
if (days === 1) return "yesterday";
return `${days} days ago`;
}
// ── Column toggle ──────────────────────────────────────────────────────────── // ── Column toggle ────────────────────────────────────────────────────────────
function ColumnToggle({ visible, orderedIds, onChange, onReorder }) { function ColumnToggle({ visible, orderedIds, onChange, onReorder }) {
@@ -284,8 +420,13 @@ function ColumnToggle({ visible, orderedIds, onChange, onReorder }) {
// ── Filter dropdown ────────────────────────────────────────────────────────── // ── Filter dropdown ──────────────────────────────────────────────────────────
const FILTER_OPTIONS = [ const FILTER_OPTIONS = [
{ value: "negotiating", label: "Negotiating" }, { value: "lead", label: "Lead" },
{ value: "has_problem", label: "Has Open Issue" }, { value: "prospect", label: "Prospect" },
{ value: "active", label: "Active" },
{ value: "inactive", label: "Inactive" },
{ value: "churned", label: "Churned" },
{ value: "has_issue", label: "Has Open Issue" },
{ value: "has_support", label: "Has Open Support" },
]; ];
function FilterDropdown({ active, onChange }) { function FilterDropdown({ active, onChange }) {
@@ -470,105 +611,19 @@ function primaryContact(customer, type) {
return primary?.value || contacts.find((c) => c.type === type)?.value || null; return primary?.value || contacts.find((c) => c.type === type)?.value || null;
} }
function ActionsDropdown({ customer, onUpdate }) { function ActionsDropdown({ customer }) {
const [open, setOpen] = useState(false); const navigate = useNavigate();
const [menuPos, setMenuPos] = useState({ top: 0, right: 0 });
const [loading, setLoading] = useState(null);
const btnRef = useRef(null);
const menuRef = useRef(null);
useEffect(() => {
const handler = (e) => {
if (
btnRef.current && !btnRef.current.contains(e.target) &&
menuRef.current && !menuRef.current.contains(e.target)
) setOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const handleOpen = (e) => {
e.stopPropagation();
if (open) { setOpen(false); return; }
const rect = btnRef.current.getBoundingClientRect();
setMenuPos({ top: rect.bottom + 4, right: window.innerWidth - rect.right });
setOpen(true);
};
const toggle = async (type, e) => {
e.stopPropagation();
setLoading(type);
try {
const endpoint = type === "negotiating"
? `/crm/customers/${customer.id}/toggle-negotiating`
: `/crm/customers/${customer.id}/toggle-problem`;
const updated = await api.post(endpoint);
onUpdate(updated);
} catch {
alert("Failed to update status");
} finally {
setLoading(null);
setOpen(false);
}
};
return ( return (
<div onClick={e => e.stopPropagation()}> <button
<button onClick={(e) => { e.stopPropagation(); navigate(`/crm/customers/${customer.id}`); }}
ref={btnRef} style={{
onClick={handleOpen} padding: "4px 10px", fontSize: 11, fontWeight: 600, borderRadius: 5,
style={{ border: "1px solid var(--border-primary)", cursor: "pointer",
padding: "4px 10px", fontSize: 11, fontWeight: 600, borderRadius: 5, backgroundColor: "transparent", color: "var(--text-secondary)",
border: "1px solid var(--border-primary)", cursor: "pointer", }}
backgroundColor: "transparent", color: "var(--text-secondary)", >
}} Open
> </button>
Actions
</button>
{open && (
<div
ref={menuRef}
onClick={e => e.stopPropagation()}
style={{
position: "fixed", top: menuPos.top, right: menuPos.right, zIndex: 9999,
backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)",
borderRadius: 8, minWidth: 180, boxShadow: "0 8px 24px rgba(0,0,0,0.18)",
overflow: "hidden",
}}
>
<button
onClick={(e) => toggle("negotiating", e)}
disabled={loading === "negotiating"}
style={{
display: "block", width: "100%", textAlign: "left",
padding: "9px 14px", fontSize: 12, fontWeight: 500, cursor: "pointer",
background: "none", border: "none",
color: customer.negotiating ? "#a16207" : "var(--text-primary)",
borderBottom: "1px solid var(--border-secondary)",
}}
onMouseEnter={e => e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"}
onMouseLeave={e => e.currentTarget.style.backgroundColor = ""}
>
{loading === "negotiating" ? "..." : customer.negotiating ? "End Negotiations" : "Start Negotiating"}
</button>
<button
onClick={(e) => toggle("problem", e)}
disabled={loading === "problem"}
style={{
display: "block", width: "100%", textAlign: "left",
padding: "9px 14px", fontSize: 12, fontWeight: 500, cursor: "pointer",
background: "none", border: "none",
color: customer.has_problem ? "#b91c1c" : "var(--text-primary)",
}}
onMouseEnter={e => e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"}
onMouseLeave={e => e.currentTarget.style.backgroundColor = ""}
>
{loading === "problem" ? "..." : customer.has_problem ? "Resolve Issue" : "Has Problem"}
</button>
</div>
)}
</div>
); );
} }
@@ -621,7 +676,13 @@ export default function CustomerList() {
}; };
const fetchDirections = async (list) => { const fetchDirections = async (list) => {
const flagged = list.filter(c => c.negotiating || c.has_problem); // Fetch last-comm for: active customers (for status icon + churn detection)
// and customers with active issues/support (for sub-row context)
const flagged = list.filter(c => {
const s = c.crm_summary || {};
const rel = c.relationship_status || "lead";
return rel === "active" || (s.active_issues_count || 0) > 0 || (s.active_support_count || 0) > 0;
});
if (!flagged.length) return; if (!flagged.length) return;
const results = await Promise.allSettled( const results = await Promise.allSettled(
flagged.map(c => flagged.map(c =>
@@ -643,6 +704,20 @@ export default function CustomerList() {
setLastCommDates(prev => ({ ...prev, ...dateMap })); setLastCommDates(prev => ({ ...prev, ...dateMap }));
}; };
const handleChurnUpdate = async (customerId) => {
// Idempotent: only patch if still active
setCustomers(prev => {
const c = prev.find(x => x.id === customerId);
if (!c || c.relationship_status !== "active") return prev;
return prev.map(x => x.id === customerId ? { ...x, relationship_status: "churned" } : x);
});
try {
await api.patch(`/crm/customers/${customerId}/relationship-status`, { status: "churned" });
} catch {
// silently ignore — local state already updated
}
};
useEffect(() => { fetchCustomers(); }, [search, sort]); useEffect(() => { fetchCustomers(); }, [search, sort]);
const updateColVisible = (id, vis) => { const updateColVisible = (id, vis) => {
@@ -662,10 +737,15 @@ export default function CustomerList() {
.map((id) => ALL_COLUMNS.find((c) => c.id === id)) .map((id) => ALL_COLUMNS.find((c) => c.id === id))
.filter((c) => c && colPrefs.visible[c.id]); .filter((c) => c && colPrefs.visible[c.id]);
const filteredCustomers = activeFilters.size === 0 ? customers : customers.filter(c => const filteredCustomers = activeFilters.size === 0 ? customers : customers.filter(c => {
(!activeFilters.has("negotiating") || c.negotiating) && const summary = c.crm_summary || {};
(!activeFilters.has("has_problem") || c.has_problem) const relStatus = c.relationship_status || "lead";
); const relFilters = ["lead", "prospect", "active", "inactive", "churned"].filter(v => activeFilters.has(v));
if (relFilters.length > 0 && !relFilters.includes(relStatus)) return false;
if (activeFilters.has("has_issue") && !(summary.active_issues_count > 0)) return false;
if (activeFilters.has("has_support") && !(summary.active_support_count > 0)) return false;
return true;
});
const totalPages = pageSize > 0 ? Math.ceil(filteredCustomers.length / pageSize) : 1; const totalPages = pageSize > 0 ? Math.ceil(filteredCustomers.length / pageSize) : 1;
const safePage = Math.min(page, Math.max(1, totalPages)); const safePage = Math.min(page, Math.max(1, totalPages));
@@ -675,18 +755,9 @@ export default function CustomerList() {
const handleCustomerUpdate = (updated) => { const handleCustomerUpdate = (updated) => {
setCustomers(prev => prev.map(c => c.id === updated.id ? updated : c)); setCustomers(prev => prev.map(c => c.id === updated.id ? updated : c));
// Refresh direction for this customer if it now has/lost a flag
if (updated.negotiating || updated.has_problem) {
api.get(`/crm/customers/${updated.id}/last-comm-direction`)
.then(r => {
setCommDirections(prev => ({ ...prev, [updated.id]: r.direction }));
if (r.occurred_at || r.date) setLastCommDates(prev => ({ ...prev, [updated.id]: r.occurred_at || r.date }));
})
.catch(() => {});
}
}; };
const renderCell = (col, c, direction) => { const renderCell = (col, c, direction, lastCommDate) => {
const loc = c.location || {}; const loc = c.location || {};
switch (col.id) { switch (col.id) {
case "name": case "name":
@@ -696,7 +767,9 @@ export default function CustomerList() {
</td> </td>
); );
case "status": case "status":
return <StatusIconsCell key={col.id} customer={c} direction={direction} />; return <StatusCell key={col.id} customer={c} lastCommDate={lastCommDate} onChurnUpdate={handleChurnUpdate} />;
case "support":
return <SupportCell key={col.id} customer={c} />;
case "organization": case "organization":
return <td key={col.id} className="px-4 py-3" style={{ color: "var(--text-primary)" }}>{c.organization || "—"}</td>; return <td key={col.id} className="px-4 py-3" style={{ color: "var(--text-primary)" }}>{c.organization || "—"}</td>;
case "address": { case "address": {
@@ -704,8 +777,8 @@ export default function CustomerList() {
return <td key={col.id} className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>{parts.join(", ") || "—"}</td>; return <td key={col.id} className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>{parts.join(", ") || "—"}</td>;
} }
case "location": { case "location": {
const cityCountry = [loc.city, loc.country].filter(Boolean).join(", "); const locationDisplay = loc.city || loc.country || "";
return <td key={col.id} className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)", whiteSpace: "nowrap" }}>{cityCountry || "—"}</td>; return <td key={col.id} className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)", whiteSpace: "nowrap" }}>{locationDisplay || "—"}</td>;
} }
case "email": case "email":
return <td key={col.id} className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>{primaryContact(c, "email") || "—"}</td>; return <td key={col.id} className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>{primaryContact(c, "email") || "—"}</td>;
@@ -736,23 +809,23 @@ export default function CustomerList() {
} }
}; };
// In expanded mode, hide the status column — info is shown as sub-rows instead const visibleColsForMode = visibleCols;
const visibleColsForMode = notesMode === "expanded"
? visibleCols.filter(c => c.id !== "status")
: visibleCols;
// Total column count for colSpan on expanded sub-rows // Total column count for colSpan on expanded sub-rows
const totalCols = visibleColsForMode.length + (canEdit ? 1 : 0); const totalCols = visibleColsForMode.length + (canEdit ? 1 : 0);
// Row gradient background for customers with active status flags // Index of the "name" column among visible columns (sub-rows align under it)
function rowGradient(customer, direction) { const nameColIndex = visibleColsForMode.findIndex(c => c.id === "name");
const hasNeg = customer.negotiating;
const hasIssue = customer.has_problem; // Row gradient background for customers with active issues or support items
if (!hasNeg && !hasIssue) return undefined; function rowGradient(customer) {
const pendingOurReply = direction === "inbound"; const summary = customer.crm_summary || {};
const hasIssue = (summary.active_issues_count || 0) > 0;
const hasSupport = (summary.active_support_count || 0) > 0;
if (!hasIssue && !hasSupport) return undefined;
const color = hasIssue const color = hasIssue
? (pendingOurReply ? "rgba(224, 53, 53, 0.07)" : "rgba(224, 53, 53, 0.05)") ? "rgba(224, 53, 53, 0.05)"
: (pendingOurReply ? "rgba(247, 103, 7, 0.07)" : "rgba(232, 165, 4, 0.05)"); : "rgba(247, 103, 7, 0.05)";
return `linear-gradient(to right, ${color} 0%, transparent 70%)`; return `linear-gradient(to right, ${color} 0%, transparent 70%)`;
} }
@@ -824,7 +897,7 @@ export default function CustomerList() {
<thead> <thead>
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}> <tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
{visibleColsForMode.map((col) => ( {visibleColsForMode.map((col) => (
<th key={col.id} className="px-4 py-3 font-medium" style={{ color: "var(--text-secondary)", textAlign: col.id === "status" ? "center" : "left", ...(col.id === "status" ? { width: 90 } : {}) }}> <th key={col.id} className="px-4 py-3 font-medium" style={{ color: "var(--text-secondary)", textAlign: (col.id === "status" || col.id === "support") ? "center" : "left", ...(col.id === "status" ? { width: 90 } : {}), ...(col.id === "support" ? { width: 60 } : {}) }}>
{col.label} {col.label}
</th> </th>
))} ))}
@@ -835,21 +908,24 @@ export default function CustomerList() {
{pagedCustomers.map((c, index) => { {pagedCustomers.map((c, index) => {
const direction = commDirections[c.id] ?? null; const direction = commDirections[c.id] ?? null;
const lastDate = lastCommDates[c.id] ?? null; const lastDate = lastCommDates[c.id] ?? null;
const hasStatus = c.negotiating || c.has_problem; const summary = c.crm_summary || {};
const hasStatus = (summary.active_issues_count || 0) > 0 || (summary.active_support_count || 0) > 0;
const isLast = index === pagedCustomers.length - 1; const isLast = index === pagedCustomers.length - 1;
const gradient = rowGradient(c, direction); const gradient = rowGradient(c);
const rowBg = hoveredRow === c.id ? "var(--bg-card-hover)" : undefined; const rowBg = hoveredRow === c.id ? "var(--bg-card-hover)" : undefined;
const zebraBase = index % 2 === 1 ? "var(--bg-row-alt)" : "transparent"; const zebraBase = index % 2 === 1 ? "var(--bg-row-alt)" : "transparent";
const rowBackground = gradient const rowBackground = gradient
? `${gradient}, ${zebraBase}` ? `${gradient}, ${zebraBase}`
: zebraBase; : zebraBase;
const rowStyle = { const rowStyle = {
borderBottom: (!isLast && !(notesMode === "expanded" && hasStatus)) borderBottom: (!isLast && notesMode !== "expanded")
? "1px solid var(--border-secondary)" ? "1px solid var(--border-secondary)"
: "none", : "none",
background: rowBg ? rowBg : rowBackground, background: rowBg ? rowBg : rowBackground,
}; };
// In expanded mode, hue overlay is applied on sub-rows (computed there)
const mainRow = ( const mainRow = (
<tr <tr
key={`${c.id}-main`} key={`${c.id}-main`}
@@ -859,7 +935,7 @@ export default function CustomerList() {
onMouseEnter={() => setHoveredRow(c.id)} onMouseEnter={() => setHoveredRow(c.id)}
onMouseLeave={() => setHoveredRow(null)} onMouseLeave={() => setHoveredRow(null)}
> >
{visibleColsForMode.map((col) => renderCell(col, c, direction))} {visibleColsForMode.map((col) => renderCell(col, c, direction, lastDate))}
{canEdit && ( {canEdit && (
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
<ActionsDropdown customer={c} onUpdate={handleCustomerUpdate} /> <ActionsDropdown customer={c} onUpdate={handleCustomerUpdate} />
@@ -868,92 +944,106 @@ export default function CustomerList() {
</tr> </tr>
); );
if (notesMode === "expanded" && hasStatus) { if (notesMode === "expanded") {
const { negColor, issColor, pendingOurReply } = statusColors(direction);
const when = relDays(lastDate);
const subRowBg = hoveredRow === c.id ? "var(--bg-card-hover)" : undefined; const subRowBg = hoveredRow === c.id ? "var(--bg-card-hover)" : undefined;
const issueCount = summary.active_issues_count || 0;
const supportCount = summary.active_support_count || 0;
const activeOrderStatus = summary.active_order_status;
const activeOrderNumber = summary.active_order_number;
const activeOrderTitle = summary.active_order_title;
const subRows = []; // Sub-rows alternate tint relative to main row
const subRowLines = [];
if (activeOrderStatus) subRowLines.push("order");
if (issueCount > 0) subRowLines.push("issue");
if (supportCount > 0) subRowLines.push("support");
if (c.negotiating) { // Icon box size — fixed so text aligns regardless of icon
let text; const SUB_ICON_BOX = 40;
if (pendingOurReply) {
text = when // Hue tint for the whole customer block when issues/support exist
? `Undergoing negotiations — client last contacted us ${when}. Reply needed.` const hueGradient = issueCount > 0
: "Undergoing negotiations — client is awaiting our reply."; ? "linear-gradient(to right, rgba(224, 53, 53, 0.07) 0%, transparent 70%)"
} else { : supportCount > 0
text = when ? "linear-gradient(to right, rgba(247, 103, 7, 0.07) 0%, transparent 70%)"
? `Undergoing negotiations — we last reached out ${when}.` : null;
: "Undergoing negotiations.";
} // All rows in this customer's block share the same zebra+hue tint
subRows.push( const sharedBg = subRowBg
<tr ? subRowBg
key={`${c.id}-neg`} : hueGradient
className="cursor-pointer" ? `${hueGradient}, ${zebraBase}`
onClick={() => navigate(`/crm/customers/${c.id}`)} : zebraBase;
style={{
borderBottom: "none", // Columns before "name" get empty cells; content spans from name onward
background: subRowBg ? subRowBg : rowBackground, const colsBeforeName = nameColIndex > 0 ? nameColIndex : 0;
}} const colsFromName = totalCols - colsBeforeName;
onMouseEnter={() => setHoveredRow(c.id)}
onMouseLeave={() => setHoveredRow(null)} const makeSubRow = (key, content, isLastSubRow = false) => (
> <tr
<td colSpan={totalCols} style={{ padding: "0px 16px 5px 16px" }}> key={key}
<div style={{ display: "flex", alignItems: "center", gap: 7 }}> className="cursor-pointer"
<span style={{ color: negColor, display: "inline-flex" }}> onClick={() => navigate(`/crm/customers/${c.id}`)}
<IconNegotiations style={{ width: EXP_NEG_ICON_SIZE, height: EXP_NEG_ICON_SIZE, flexShrink: 0 }} /> style={{ borderBottom: "none", background: sharedBg }}
</span> onMouseEnter={() => setHoveredRow(c.id)}
{pendingOurReply && ( onMouseLeave={() => setHoveredRow(null)}
<span style={{ color: "var(--crm-status-alert)", display: "inline-flex" }}> >
<IconImportant style={{ width: EXP_IMP_ICON_SIZE, height: EXP_IMP_ICON_SIZE, flexShrink: 0 }} className="crm-icon-breathe" /> {colsBeforeName > 0 && (
</span> <td colSpan={colsBeforeName} style={{ padding: 0 }} />
)} )}
<span style={{ fontSize: 11.5, color: negColor, fontWeight: 500 }}>{text}</span> <td colSpan={colsFromName} style={{ padding: isLastSubRow ? "4px 14px 14px 0" : "4px 16px 4px 0" }}>
{content}
</td>
</tr>
);
const { color: statusColor } = resolveStatusIcon(c);
const subRows = subRowLines.map((type, idx) => {
const isLastSubRow = idx === subRowLines.length - 1;
if (type === "issue") {
const label = `${issueCount} active technical issue${issueCount > 1 ? "s" : ""}`;
return makeSubRow(`${c.id}-iss`, (
<div style={{ display: "flex", alignItems: "center" }}>
<div style={{ width: SUB_ICON_BOX, flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
{renderMaskedIcon(exclamationIcon, "var(--crm-customer-icon-declined)", "Issue", 15)}
</div> </div>
</td> <span style={{ fontSize: 11.5, color: "var(--crm-issue-active-text)", fontWeight: 500 }}>{label}</span>
</tr> </div>
); ), isLastSubRow);
}
if (c.has_problem) {
let text;
if (pendingOurReply) {
text = when
? `Open issue — client reached out ${when} and is awaiting our response.`
: "Open issue — client is awaiting our response.";
} else {
text = when
? `Open issue — we last contacted the client ${when}.`
: "Open issue — under investigation.";
} }
subRows.push( if (type === "support") {
<tr const label = `${supportCount} active support ticket${supportCount > 1 ? "s" : ""}`;
key={`${c.id}-iss`} return makeSubRow(`${c.id}-sup`, (
className="cursor-pointer" <div style={{ display: "flex", alignItems: "center" }}>
onClick={() => navigate(`/crm/customers/${c.id}`)} <div style={{ width: SUB_ICON_BOX, flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
style={{ {renderMaskedIcon(wrenchIcon, "var(--crm-support-active-text)", "Support", 15)}
borderBottom: "none",
background: subRowBg ? subRowBg : rowBackground,
}}
onMouseEnter={() => setHoveredRow(c.id)}
onMouseLeave={() => setHoveredRow(null)}
>
<td colSpan={totalCols} style={{ padding: "0px 16px 5px 16px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
<span style={{ color: issColor, display: "inline-flex" }}>
<IconIssues style={{ width: EXP_ISS_ICON_SIZE, height: EXP_ISS_ICON_SIZE, flexShrink: 0 }} />
</span>
{pendingOurReply && (
<span style={{ color: "var(--crm-status-danger)", display: "inline-flex" }}>
<IconImportant style={{ width: EXP_IMP_ICON_SIZE, height: EXP_IMP_ICON_SIZE, flexShrink: 0 }} className="crm-icon-breathe" />
</span>
)}
<span style={{ fontSize: 11.5, color: issColor, fontWeight: 500 }}>{text}</span>
</div> </div>
</td> <span style={{ fontSize: 11.5, color: "var(--crm-support-active-text)", fontWeight: 500 }}>{label}</span>
</tr> </div>
); ), isLastSubRow);
} }
if (type === "order") {
const orderLabel = ORDER_STATUS_LABELS[activeOrderStatus] || activeOrderStatus;
const parts = ["Order", activeOrderNumber, orderLabel].filter(Boolean);
return makeSubRow(`${c.id}-ord`, (
<div style={{ display: "flex", alignItems: "center" }}>
<div style={{ width: SUB_ICON_BOX, flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
{renderMaskedIcon(orderIcon, statusColor, orderLabel, 18)}
</div>
<span style={{ fontSize: 11.5, color: statusColor, fontWeight: 500 }}>
{parts.map((p, i) => (
<span key={i}>{i > 0 && <span style={{ margin: "0 5px", color: "var(--text-muted)" }}>·</span>}{p}</span>
))}
</span>
{activeOrderTitle && (
<span style={{ fontSize: 11, color: "var(--text-muted)", marginLeft: 6 }}>· {activeOrderTitle}</span>
)}
</div>
), isLastSubRow);
}
return null;
}).filter(Boolean);
if (!isLast) { if (!isLast) {
subRows.push( subRows.push(
@@ -980,7 +1070,7 @@ export default function CustomerList() {
onMouseEnter={() => setHoveredRow(c.id)} onMouseEnter={() => setHoveredRow(c.id)}
onMouseLeave={() => setHoveredRow(null)} onMouseLeave={() => setHoveredRow(null)}
> >
{visibleColsForMode.map((col) => renderCell(col, c, direction))} {visibleColsForMode.map((col) => renderCell(col, c, direction, lastDate))}
{canEdit && ( {canEdit && (
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
<ActionsDropdown customer={c} onUpdate={handleCustomerUpdate} /> <ActionsDropdown customer={c} onUpdate={handleCustomerUpdate} />

View File

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

View File

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

View File

@@ -1,22 +1,21 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import api from "../../api/client"; import api from "../../api/client";
import { useAuth } from "../../auth/AuthContext";
const STATUS_COLORS = { const ORDER_STATUS_LABELS = {
draft: { bg: "var(--bg-card-hover)", color: "var(--text-secondary)" }, negotiating: "Negotiating",
confirmed: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }, awaiting_quotation: "Awaiting Quotation",
in_production: { bg: "#fff7ed", color: "#9a3412" }, awaiting_customer_confirmation: "Awaiting Confirmation",
shipped: { bg: "#f5f3ff", color: "#6d28d9" }, awaiting_fulfilment: "Awaiting Fulfilment",
delivered: { bg: "var(--success-bg)", color: "var(--success-text)" }, awaiting_payment: "Awaiting Payment",
cancelled: { bg: "var(--danger-bg)", color: "var(--danger-text)" }, manufacturing: "Manufacturing",
shipped: "Shipped",
installed: "Installed",
declined: "Declined",
complete: "Complete",
}; };
const PAYMENT_COLORS = { const ORDER_STATUSES = Object.keys(ORDER_STATUS_LABELS);
pending: { bg: "#fef9c3", color: "#854d0e" },
partial: { bg: "#fff7ed", color: "#9a3412" },
paid: { bg: "var(--success-bg)", color: "var(--success-text)" },
};
const inputStyle = { const inputStyle = {
backgroundColor: "var(--bg-input)", backgroundColor: "var(--bg-input)",
@@ -24,30 +23,13 @@ const inputStyle = {
color: "var(--text-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() { export default function OrderList() {
const [orders, setOrders] = useState([]); const [orders, setOrders] = useState([]);
const [customerMap, setCustomerMap] = useState({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [statusFilter, setStatusFilter] = useState(""); const [statusFilter, setStatusFilter] = useState("");
const [paymentFilter, setPaymentFilter] = useState("");
const [hoveredRow, setHoveredRow] = useState(null); const [hoveredRow, setHoveredRow] = useState(null);
const navigate = useNavigate(); 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 () => { const fetchOrders = async () => {
setLoading(true); setLoading(true);
@@ -55,7 +37,6 @@ export default function OrderList() {
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (statusFilter) params.set("status", statusFilter); if (statusFilter) params.set("status", statusFilter);
if (paymentFilter) params.set("payment_status", paymentFilter);
const qs = params.toString(); const qs = params.toString();
const data = await api.get(`/crm/orders${qs ? `?${qs}` : ""}`); const data = await api.get(`/crm/orders${qs ? `?${qs}` : ""}`);
setOrders(data.orders || []); setOrders(data.orders || []);
@@ -66,23 +47,12 @@ export default function OrderList() {
} }
}; };
useEffect(() => { useEffect(() => { fetchOrders(); }, [statusFilter]);
fetchOrders();
}, [statusFilter, paymentFilter]);
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Orders Manager</h1> <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>
<div className="flex gap-3 mb-4"> <div className="flex gap-3 mb-4">
@@ -94,27 +64,14 @@ export default function OrderList() {
> >
<option value="">All Statuses</option> <option value="">All Statuses</option>
{ORDER_STATUSES.map((s) => ( {ORDER_STATUSES.map((s) => (
<option key={s} value={s}>{s.replace("_", " ")}</option> <option key={s} value={s}>{ORDER_STATUS_LABELS[s]}</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> </select>
</div> </div>
{error && ( {error && (
<div <div className="text-sm rounded-md p-3 mb-4 border"
className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
>
{error} {error}
</div> </div>
)} )}
@@ -122,44 +79,33 @@ export default function OrderList() {
{loading ? ( {loading ? (
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div> <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>
) : orders.length === 0 ? ( ) : orders.length === 0 ? (
<div <div className="rounded-lg p-8 text-center text-sm border"
className="rounded-lg p-8 text-center text-sm border" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}>
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}
>
No orders found. No orders found.
</div> </div>
) : ( ) : (
<div <div className="rounded-lg overflow-hidden border"
className="rounded-lg overflow-hidden border" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}> <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)" }}>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)" }}>Customer</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Title</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)" }}>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> <th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Date</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{orders.map((o, index) => { {orders.map((o, index) => {
const statusStyle = STATUS_COLORS[o.status] || STATUS_COLORS.draft; const statusLabel = ORDER_STATUS_LABELS[o.status] || o.status || "—";
const payStyle = PAYMENT_COLORS[o.payment_status] || PAYMENT_COLORS.pending; const customerName = o.customer_name || o.customer_id || "—";
const customer = customerMap[o.customer_id];
const customerName = customer
? customer.organization
? `${customer.name} / ${customer.organization}`
: customer.name
: o.customer_id || "—";
return ( return (
<tr <tr
key={o.id} key={o.id}
onClick={() => navigate(`/crm/orders/${o.id}`)} onClick={() => navigate(`/crm/customers/${o.customer_id}?tab=Orders`)}
className="cursor-pointer" className="cursor-pointer"
style={{ style={{
borderBottom: index < orders.length - 1 ? "1px solid var(--border-secondary)" : "none", borderBottom: index < orders.length - 1 ? "1px solid var(--border-secondary)" : "none",
@@ -169,26 +115,19 @@ export default function OrderList() {
onMouseLeave={() => setHoveredRow(null)} onMouseLeave={() => setHoveredRow(null)}
> >
<td className="px-4 py-3 font-mono text-xs font-medium" style={{ color: "var(--text-heading)" }}> <td className="px-4 py-3 font-mono text-xs font-medium" style={{ color: "var(--text-heading)" }}>
{o.order_number} {o.order_number || "—"}
</td> </td>
<td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>{customerName}</td> <td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>{customerName}</td>
<td className="px-4 py-3"> <td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
<span {o.title || <span style={{ fontStyle: "italic" }}>Untitled</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>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span <span className="px-2 py-0.5 text-xs rounded-full"
className="px-2 py-0.5 text-xs rounded-full capitalize" style={{
style={{ backgroundColor: payStyle.bg, color: payStyle.color }} backgroundColor: `var(--crm-ord-${o.status}-bg, var(--bg-card-hover))`,
> color: `var(--crm-ord-${o.status}-text, var(--text-secondary))`,
{o.payment_status || "pending"} }}>
{statusLabel}
</span> </span>
</td> </td>
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}> <td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>

View File

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

View File

@@ -368,11 +368,11 @@ function LocationModal({ open, onClose, onSaved, device, coords, id }) {
setSaving(true); setSaving(true);
const latNum = parseFloat(lat); const latNum = parseFloat(lat);
const lngNum = parseFloat(lng); const lngNum = parseFloat(lng);
const coordStr = (!isNaN(latNum) && !isNaN(lngNum)) const coordPayload = (!isNaN(latNum) && !isNaN(lngNum))
? `${Math.abs(latNum).toFixed(7)}° ${latNum >= 0 ? "N" : "S"}, ${Math.abs(lngNum).toFixed(7)}° ${lngNum >= 0 ? "E" : "W"}` ? { lat: latNum, lng: lngNum }
: device?.device_location_coordinates || ""; : (device?.device_location_coordinates || null);
try { try {
await api.put(`/devices/${id}`, { device_location: locName, device_location_coordinates: coordStr }); await api.put(`/devices/${id}`, { device_location: locName, device_location_coordinates: coordPayload });
await onSaved(); await onSaved();
onClose(); onClose();
} finally { setSaving(false); } } finally { setSaving(false); }
@@ -1408,11 +1408,21 @@ function playbackPlaceholderForId(seedValue) {
function parseCoordinates(coordStr) { function parseCoordinates(coordStr) {
if (!coordStr) return null; if (!coordStr) return null;
// Support plain { lat, lng } objects (from API returning GeoPoint as dict)
if (typeof coordStr === "object" && coordStr !== null) {
const lat = parseFloat(coordStr.lat ?? coordStr.latitude);
const lng = parseFloat(coordStr.lng ?? coordStr.longitude);
if (!isNaN(lat) && !isNaN(lng)) return { lat, lng };
return null;
}
const numbers = coordStr.match(/-?\d+(?:\.\d+)?/g); const numbers = coordStr.match(/-?\d+(?:\.\d+)?/g);
if (numbers && numbers.length >= 2) { if (numbers && numbers.length >= 2) {
const lat = parseFloat(numbers[0]); let lat = parseFloat(numbers[0]);
const lng = parseFloat(numbers[1]); let lng = parseFloat(numbers[1]);
if (!isNaN(lat) && !isNaN(lng)) { if (!isNaN(lat) && !isNaN(lng)) {
// Restore sign from direction letters (legacy string format)
if (/\bS\b/.test(coordStr)) lat = -Math.abs(lat);
if (/\bW\b/.test(coordStr)) lng = -Math.abs(lng);
return { lat, lng }; return { lat, lng };
} }
} }

View File

@@ -2,6 +2,14 @@
/* BellSystems Dark Theme - Custom Properties */ /* BellSystems Dark Theme - Custom Properties */
:root { :root {
--crm-customer-icon-lead: #7a6f5e;
--crm-customer-icon-passive: #9fa6b4;
--crm-customer-icon-info: #3dabff;
--crm-customer-icon-positive: #36d346;
--crm-customer-icon-payment: #e8c040;
--crm-customer-icon-declined: #e07070;
--crm-customer-icon-muted: #4f5258;
--bg-primary: #111827; --bg-primary: #111827;
--bg-secondary: rgba(31, 41, 55, 0.959); --bg-secondary: rgba(31, 41, 55, 0.959);
--bg-card: #1f2937; --bg-card: #1f2937;
@@ -55,6 +63,53 @@
--crm-status-alert: #f76707; /* orange — client sent last */ --crm-status-alert: #f76707; /* orange — client sent last */
--crm-status-danger: #f34b4b; /* red — issue, client sent */ --crm-status-danger: #f34b4b; /* red — issue, client sent */
/* ── CRM relationship status ── */
--crm-rel-lead-bg: #2a2d35;
--crm-rel-lead-text: #9ca3af;
--crm-rel-lead-border: #4b5563;
--crm-rel-prospect-bg: #1e3a5f;
--crm-rel-prospect-text: #63b3ed;
--crm-rel-prospect-border: #2d5a8f;
--crm-rel-active-bg: #14351a;
--crm-rel-active-text: #6ee07a;
--crm-rel-active-border: #1e5c28;
--crm-rel-inactive-bg: #3d2a00;
--crm-rel-inactive-text: #fbbf24;
--crm-rel-inactive-border: #6b4800;
--crm-rel-churned-bg: #3b1a1a;
--crm-rel-churned-text: #f87171;
--crm-rel-churned-border: #6b2121;
/* ── CRM order status ── */
--crm-ord-negotiating-bg: #1e3a5f;
--crm-ord-negotiating-text: #63b3ed;
--crm-ord-awaiting_quotation-bg: #2d1b5e;
--crm-ord-awaiting_quotation-text: #b794f4;
--crm-ord-awaiting_customer_confirmation-bg: #1e1b5e;
--crm-ord-awaiting_customer_confirmation-text: #818cf8;
--crm-ord-awaiting_fulfilment-bg: #3d2a00;
--crm-ord-awaiting_fulfilment-text: #fbbf24;
--crm-ord-awaiting_payment-bg: #3d1f00;
--crm-ord-awaiting_payment-text: #fb923c;
--crm-ord-manufacturing-bg: #003d3d;
--crm-ord-manufacturing-text: #67e8f9;
--crm-ord-shipped-bg: #003d35;
--crm-ord-shipped-text: #2dd4bf;
--crm-ord-installed-bg: #14351a;
--crm-ord-installed-text: #6ee07a;
--crm-ord-declined-bg: #3b1a1a;
--crm-ord-declined-text: #f87171;
--crm-ord-complete-bg: #0044ff;
--crm-ord-complete-text: #9ca3af;
/* ── CRM issue/support flags ── */
--crm-issue-active-bg: #3b1a1a;
--crm-issue-active-text: #f87171;
--crm-support-active-bg: #3d2a00;
--crm-support-active-text: #fbbf24;
--crm-resolved-bg: #2a2d35;
--crm-resolved-text: #9ca3af;
/* ── Spacing tokens ── */ /* ── Spacing tokens ── */
--section-padding: 2.25rem 2.5rem 2.25rem; --section-padding: 2.25rem 2.5rem 2.25rem;
--section-padding-compact: 1.25rem 1.5rem; --section-padding-compact: 1.25rem 1.5rem;

View File

@@ -147,6 +147,7 @@ const navSections = [
const settingsChildren = [ const settingsChildren = [
{ to: "/settings/staff", label: "Staff Management", icon: StaffIcon }, { to: "/settings/staff", label: "Staff Management", icon: StaffIcon },
{ to: "/settings/public-features", label: "Public Features", icon: SettingsIcon }, { to: "/settings/public-features", label: "Public Features", icon: SettingsIcon },
{ to: "/settings/serial-logs", label: "Log Viewer", icon: BlackBoxIcon },
{ to: "/settings/pages", label: "Page Settings", icon: PlaceholderIcon, placeholder: true }, { to: "/settings/pages", label: "Page Settings", icon: PlaceholderIcon, placeholder: true },
]; ];

View File

@@ -373,6 +373,161 @@ function NoteModal({ hwType, currentNote, onClose, onSaved }) {
); );
} }
// ── Add Bespoke Board Modal ───────────────────────────────────────────────────
function AddBespokeModal({ existingUids, onClose, onSaved }) {
const [uid, setUid] = useState("");
const [bootFile, setBootFile] = useState(null);
const [partFile, setPartFile] = useState(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState("");
const bootRef = useRef(null);
const partRef = useRef(null);
const uidSlug = uid.trim().toLowerCase().replace(/\s+/g, "-");
const isValidUid = /^[a-z0-9][a-z0-9._-]{0,126}$/.test(uidSlug);
const isKnown = KNOWN_BOARD_TYPES.some((b) => b.value === uidSlug);
const isDuplicate = existingUids.includes(uidSlug);
const handleSubmit = async (e) => {
e.preventDefault();
if (!isValidUid) { setError("UID must be lowercase alphanumeric with hyphens/dots only."); return; }
if (isKnown) { setError("That name matches a standard board type. Use a unique bespoke UID."); return; }
if (isDuplicate) { setError("A bespoke board with that UID already exists."); return; }
if (!bootFile && !partFile) { setError("Upload at least one file."); return; }
setError(""); setUploading(true);
const token = localStorage.getItem("access_token");
try {
for (const [asset, file] of [["bootloader.bin", bootFile], ["partitions.bin", partFile]]) {
if (!file) continue;
const fd = new FormData();
fd.append("file", file);
const res = await fetch(`/api/manufacturing/flash-assets/${uidSlug}/${asset}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: fd,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || `Failed to upload ${asset}`);
}
}
onSaved();
} catch (err) {
setError(err.message);
} finally {
setUploading(false);
}
};
const FilePicker = ({ label, file, setFile, inputRef }) => (
<div>
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>{label}</label>
<div
onClick={() => inputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const f = e.dataTransfer.files[0];
if (f && f.name.endsWith(".bin")) setFile(f);
else if (f) setError("Only .bin files are accepted.");
}}
style={{
display: "flex", alignItems: "center", gap: "0.5rem",
padding: "0.5rem 0.75rem",
border: `2px dashed ${file ? "var(--btn-primary)" : "var(--border-input)"}`,
borderRadius: "0.5rem",
backgroundColor: file ? "var(--badge-blue-bg)" : "var(--bg-input)",
cursor: "pointer", transition: "all 0.15s ease",
}}
>
<input ref={inputRef} type="file" accept=".bin" style={{ display: "none" }}
onChange={(e) => {
const f = e.target.files[0];
if (f && !f.name.endsWith(".bin")) { setError("Only .bin files are accepted."); return; }
setFile(f || null); setError("");
}}
/>
<span style={{ color: file ? "var(--btn-primary)" : "var(--text-muted)", flexShrink: 0 }}>
{file ? <IconCheck /> : <IconUpload />}
</span>
<span className="font-mono" style={{ fontSize: "0.72rem", color: file ? "var(--badge-blue-text)" : "var(--text-muted)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{file ? `${file.name} (${formatBytes(file.size)})` : `click or drop ${label}`}
</span>
</div>
</div>
);
return (
<div
className="fixed inset-0 flex items-center justify-center z-50"
style={{ backgroundColor: "rgba(0,0,0,0.6)" }}
onClick={onClose}
>
<div
className="rounded-lg border w-full mx-4 flex flex-col"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", maxWidth: "480px" }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 pt-4 pb-3" style={{ borderBottom: "1px solid var(--border-secondary)" }}>
<div>
<h3 className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>Add Bespoke Board</h3>
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
Create a new bespoke flash asset set with a unique UID.
</p>
</div>
<button onClick={onClose} className="text-lg leading-none hover:opacity-70 cursor-pointer" style={{ color: "var(--text-muted)" }}></button>
</div>
<form onSubmit={handleSubmit}>
<div className="px-5 py-4" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
{error && (
<div className="text-xs rounded-md p-3 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
)}
<div>
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>
Bespoke UID <span style={{ color: "var(--danger-text)" }}>*</span>
</label>
<input
type="text"
value={uid}
onChange={(e) => { setUid(e.target.value); setError(""); }}
placeholder="e.g. client-athens-v1"
required
className="w-full px-3 py-2 rounded-md text-sm border font-mono"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
/>
{uidSlug && (
<p className="text-xs mt-1 font-mono" style={{ color: isValidUid && !isKnown && !isDuplicate ? "var(--text-muted)" : "var(--danger-text)" }}>
{isDuplicate ? "Already exists" : isKnown ? "Matches a standard board type" : !isValidUid ? "Invalid format" : `${uidSlug}`}
</p>
)}
</div>
<FilePicker label="bootloader.bin" file={bootFile} setFile={setBootFile} inputRef={bootRef} />
<FilePicker label="partitions.bin" file={partFile} setFile={setPartFile} inputRef={partRef} />
</div>
<div className="flex items-center justify-end gap-3 px-5 py-3" style={{ borderTop: "1px solid var(--border-secondary)" }}>
<button type="button" onClick={onClose} 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>
<button
type="submit"
disabled={uploading || !uidSlug}
className="px-5 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…" : "Add Board"}
</button>
</div>
</form>
</div>
</div>
);
}
// ── Delete Confirm Modal ────────────────────────────────────────────────────── // ── Delete Confirm Modal ──────────────────────────────────────────────────────
function DeleteConfirmModal({ hwType, assetName, onClose, onConfirmed }) { function DeleteConfirmModal({ hwType, assetName, onClose, onConfirmed }) {
@@ -548,9 +703,11 @@ export default function FlashAssetManager({ onClose }) {
const [filter, setFilter] = useState("all"); // "all" | "ready" | "partial" | "missing" const [filter, setFilter] = useState("all"); // "all" | "ready" | "partial" | "missing"
// Modal state // Modal state
const [uploadTarget, setUploadTarget] = useState(null); // { hwType, assetName } const [uploadTarget, setUploadTarget] = useState(null); // { hwType, assetName }
const [deleteTarget, setDeleteTarget] = useState(null); // { hwType, assetName } const [deleteTarget, setDeleteTarget] = useState(null); // { hwType, assetName }
const [noteTarget, setNoteTarget] = useState(null); // entry const [noteTarget, setNoteTarget] = useState(null); // entry
const [showAddBespoke, setShowAddBespoke] = useState(false);
const canAdd = hasPermission("manufacturing", "add");
const fetchAssets = useCallback(async () => { const fetchAssets = useCallback(async () => {
setLoading(true); setError(""); setLoading(true); setError("");
@@ -653,6 +810,15 @@ export default function FlashAssetManager({ onClose }) {
<IconRefresh /> <IconRefresh />
Refresh Refresh
</button> </button>
{canAdd && (
<button
onClick={() => setShowAddBespoke(true)}
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-md font-medium cursor-pointer hover:opacity-90 transition-opacity"
style={{ backgroundColor: "#2a0a3a", color: "#a855f7", border: "1px solid #a855f7" }}
>
+ Bespoke Board
</button>
)}
<button <button
onClick={onClose} onClick={onClose}
className="text-lg leading-none hover:opacity-70 cursor-pointer" className="text-lg leading-none hover:opacity-70 cursor-pointer"
@@ -788,6 +954,14 @@ export default function FlashAssetManager({ onClose }) {
}} }}
/> />
)} )}
{showAddBespoke && (
<AddBespokeModal
existingUids={assets.filter((e) => e.is_bespoke).map((e) => e.hw_type)}
onClose={() => setShowAddBespoke(false)}
onSaved={() => { setShowAddBespoke(false); fetchAssets(); }}
/>
)}
</div> </div>
); );
} }

View File

@@ -851,6 +851,7 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
const [serial, setSerial] = useState([]); const [serial, setSerial] = useState([]);
const [serialAutoScroll, setSerialAutoScroll] = useState(true); const [serialAutoScroll, setSerialAutoScroll] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [nvsSchema, setNvsSchema] = useState("new");
const loaderRef = useRef(null); const loaderRef = useRef(null);
const portRef = useRef(null); const portRef = useRef(null);
@@ -993,9 +994,10 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
const partUrl = bespokeOverride const partUrl = bespokeOverride
? `/api/manufacturing/devices/${sn}/partitions.bin?hw_type_override=${bespokeOverride.hwFamily}` ? `/api/manufacturing/devices/${sn}/partitions.bin?hw_type_override=${bespokeOverride.hwFamily}`
: `/api/manufacturing/devices/${sn}/partitions.bin`; : `/api/manufacturing/devices/${sn}/partitions.bin`;
const nvsSchemaParam = nvsSchema === "legacy" ? "&nvs_schema=legacy" : "";
const nvsUrl = bespokeOverride const nvsUrl = bespokeOverride
? `/api/manufacturing/devices/${sn}/nvs.bin?hw_type_override=${bespokeOverride.hwFamily}&hw_revision_override=1.0` ? `/api/manufacturing/devices/${sn}/nvs.bin?hw_type_override=${bespokeOverride.hwFamily}&hw_revision_override=1.0${nvsSchemaParam}`
: `/api/manufacturing/devices/${sn}/nvs.bin`; : `/api/manufacturing/devices/${sn}/nvs.bin${nvsSchema === "legacy" ? "?nvs_schema=legacy" : ""}`;
appendLog("Fetching bootloader binary…"); appendLog("Fetching bootloader binary…");
const blBuffer = await fetchBinary(blUrl); const blBuffer = await fetchBinary(blUrl);
@@ -1232,16 +1234,32 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
</button> </button>
)} )}
{portConnected && !done && ( {portConnected && !done && (
<button <>
onClick={handleStartFlash} <select
className="flex items-center gap-2 px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer" value={nvsSchema}
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }} onChange={(e) => setNvsSchema(e.target.value)}
> disabled={flashing}
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> className="px-3 py-2 text-sm rounded-md border cursor-pointer font-medium"
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /> style={{
</svg> backgroundColor: "var(--bg-input)",
Start Flashing borderColor: "var(--border-primary)",
</button> color: "var(--text-secondary)",
}}
>
<option value="new">NVS: Current Gen</option>
<option value="legacy">NVS: Legacy Gen</option>
</select>
<button
onClick={handleStartFlash}
className="flex items-center gap-2 px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Start Flashing
</button>
</>
)} )}
</div> </div>
)} )}

View File

@@ -0,0 +1,242 @@
import { useState, useEffect } from "react";
function formatDate(isoString) {
try {
return new Date(isoString).toLocaleString();
} catch {
return isoString;
}
}
function LevelBadge({ line }) {
const text = line.toUpperCase();
if (text.includes("ERROR") || text.includes("ERR") || text.includes("[E]")) {
return <span className="text-xs font-bold" style={{ color: "var(--danger)" }}>ERR</span>;
}
if (text.includes("WARN") || text.includes("[W]")) {
return <span className="text-xs font-bold" style={{ color: "#fb923c" }}>WRN</span>;
}
return null;
}
function lineColor(text) {
const t = text.toUpperCase();
if (t.includes("ERROR") || t.includes("ERR]") || t.includes("[E]")) return "var(--danger-text)";
if (t.includes("WARN") || t.includes("[W]")) return "#fb923c";
return "#7ec87e";
}
export default function SerialLogViewer() {
const [sessions, setSessions] = useState([]);
const [expanded, setExpanded] = useState(null);
const [search, setSearch] = useState("");
useEffect(() => {
const raw = localStorage.getItem("bellsystems_serial_logs");
if (raw) {
try { setSessions(JSON.parse(raw)); } catch (_) {}
}
}, []);
const handleDelete = (id) => {
const updated = sessions.filter((s) => s.id !== id);
setSessions(updated);
localStorage.setItem("bellsystems_serial_logs", JSON.stringify(updated));
if (expanded === id) setExpanded(null);
};
const handleExport = (session) => {
const text = session.lines.map((l) => `[${l.ts}] ${l.text}`).join("\n");
const blob = new Blob([text], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `serial-log-${session.label}.txt`;
a.click();
URL.revokeObjectURL(url);
};
const filteredSessions = sessions.filter((s) => {
if (!search) return true;
const q = search.toLowerCase();
return (
s.label.toLowerCase().includes(q) ||
s.lines?.some((l) => l.text.toLowerCase().includes(q))
);
});
return (
<div>
{/* Page header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-bold" style={{ color: "var(--text-heading)" }}>Serial Log Viewer</h1>
<p className="text-sm mt-0.5" style={{ color: "var(--text-muted)" }}>
Saved sessions from the Serial Monitor page.
</p>
</div>
<span className="text-sm" style={{ color: "var(--text-muted)" }}>
{sessions.length} session{sessions.length !== 1 ? "s" : ""} stored
</span>
</div>
{/* Search */}
{sessions.length > 0 && (
<div className="mb-4">
<input
type="text"
placeholder="Search sessions or log content…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full px-3 py-2 rounded-md text-sm"
style={{
backgroundColor: "var(--bg-card)",
border: "1px solid var(--border-input)",
color: "var(--text-primary)",
outline: "none",
}}
/>
</div>
)}
{/* Empty state */}
{sessions.length === 0 && (
<div
className="rounded-lg border p-10 text-center"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<svg className="w-10 h-10 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"
style={{ color: "var(--text-muted)" }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>No saved logs yet</p>
<p className="text-xs" style={{ color: "var(--text-muted)" }}>
Use the Serial Monitor page to connect and save sessions.
</p>
</div>
)}
{/* Session list */}
<div className="space-y-2">
{filteredSessions.map((session) => {
const isOpen = expanded === session.id;
// If searching, filter lines too
const displayLines = search
? session.lines?.filter((l) => l.text.toLowerCase().includes(search.toLowerCase()))
: session.lines;
return (
<div
key={session.id}
className="rounded-lg border overflow-hidden"
style={{ backgroundColor: "var(--bg-card)", borderColor: isOpen ? "var(--accent)" : "var(--border-primary)" }}
>
{/* Session header row */}
<button
type="button"
onClick={() => setExpanded(isOpen ? null : session.id)}
className="w-full flex items-center justify-between px-4 py-3 text-left cursor-pointer hover:bg-[var(--bg-card-hover)] transition-colors"
>
<div className="flex items-center gap-3">
{/* Chevron */}
<svg
className={`w-4 h-4 flex-shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`}
fill="none" stroke="currentColor" viewBox="0 0 24 24"
style={{ color: "var(--text-muted)" }}
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
{/* Icon */}
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"
style={{ color: isOpen ? "var(--accent)" : "var(--text-muted)" }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8}
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<div>
<span className="text-sm font-medium font-mono" style={{ color: "var(--text-heading)" }}>
{session.label}
</span>
<span className="text-xs ml-3" style={{ color: "var(--text-muted)" }}>
{formatDate(session.savedAt)}
</span>
</div>
</div>
<div className="flex items-center gap-3">
<span
className="text-xs px-2 py-0.5 rounded-full font-medium"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}
>
{session.lineCount} lines
</span>
</div>
</button>
{/* Expanded log panel */}
{isOpen && (
<div style={{ borderTop: "1px solid var(--border-primary)" }}>
{/* Toolbar */}
<div
className="flex items-center justify-between px-4 py-2"
style={{ backgroundColor: "var(--bg-secondary)", borderBottom: "1px solid var(--border-primary)" }}
>
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
{search ? `${displayLines?.length ?? 0} of ${session.lineCount} lines match` : `${session.lineCount} lines`}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => handleExport(session)}
className="px-3 py-1 rounded text-xs font-medium cursor-pointer hover:opacity-80 transition-opacity flex items-center gap-1"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Export .txt
</button>
<button
onClick={() => handleDelete(session.id)}
className="px-3 py-1 rounded text-xs font-medium cursor-pointer hover:opacity-80 transition-opacity"
style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)", border: "1px solid var(--danger)" }}
>
Delete
</button>
</div>
</div>
{/* Log content */}
<div
className="overflow-y-auto font-mono text-xs"
style={{
backgroundColor: "#070d07",
maxHeight: 500,
padding: "10px 14px",
}}
>
{(!displayLines || displayLines.length === 0) ? (
<span style={{ color: "#3a5c3a" }}>No lines to display.</span>
) : (
displayLines.map((l, i) => (
<div key={i} className="leading-relaxed flex items-start gap-2">
<span className="flex-shrink-0" style={{ color: "#3a6b3a", userSelect: "none" }}>
{l.ts}
</span>
<span style={{ color: lineColor(l.text) }}>{l.text}</span>
</div>
))
)}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,340 @@
import { useState, useRef, useEffect, useCallback } from "react";
// ─── helpers ──────────────────────────────────────────────────────────────────
function formatTimestamp(date) {
return date.toISOString().replace("T", " ").replace("Z", "");
}
function saveLogsToFile(lines, sessionLabel) {
const text = lines.map((l) => `[${l.ts}] ${l.text}`).join("\n");
const blob = new Blob([text], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `serial-log-${sessionLabel}.txt`;
a.click();
URL.revokeObjectURL(url);
}
function persistSession(lines) {
const saved = JSON.parse(localStorage.getItem("bellsystems_serial_logs") || "[]");
const label = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const session = {
id: Date.now(),
label,
savedAt: new Date().toISOString(),
lineCount: lines.length,
lines,
};
saved.unshift(session);
// Keep last 20 sessions
localStorage.setItem("bellsystems_serial_logs", JSON.stringify(saved.slice(0, 20)));
return label;
}
// ─── sub-components ───────────────────────────────────────────────────────────
function InfoBox({ type = "info", children }) {
const styles = {
info: { bg: "var(--badge-blue-bg)", border: "#1e3a5f", color: "var(--badge-blue-text)" },
warning: { bg: "#2e1a00", border: "#7c4a00", color: "#fb923c" },
error: { bg: "var(--danger-bg)", border: "var(--danger)", color: "var(--danger-text)" },
success: { bg: "var(--success-bg)", border: "var(--success)", color: "var(--success-text)" },
};
const s = styles[type] || styles.info;
return (
<div
className="text-sm rounded-md p-3 border"
style={{ backgroundColor: s.bg, borderColor: s.border, color: s.color }}
>
{children}
</div>
);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function SerialMonitorPage() {
const [connected, setConnected] = useState(false);
const [connecting, setConnecting] = useState(false);
const [portName, setPortName] = useState("");
const [lines, setLines] = useState([]);
const [error, setError] = useState("");
const [saved, setSaved] = useState(false);
const [savedLabel, setSavedLabel] = useState("");
const portRef = useRef(null);
const readerRef = useRef(null);
const readingRef = useRef(false);
const logEndRef = useRef(null);
const linesRef = useRef([]); // mirror for use inside async loop
const webSerialAvailable = "serial" in navigator;
// Keep linesRef in sync
useEffect(() => {
linesRef.current = lines;
}, [lines]);
// Auto-scroll
useEffect(() => {
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [lines]);
const appendLine = useCallback((text) => {
const entry = { ts: formatTimestamp(new Date()), text };
setLines((prev) => [...prev, entry]);
}, []);
const startReading = useCallback(async (port) => {
const decoder = new TextDecoderStream();
port.readable.pipeTo(decoder.writable).catch(() => {});
const reader = decoder.readable.getReader();
readerRef.current = reader;
readingRef.current = true;
let buffer = "";
try {
while (readingRef.current) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
const parts = buffer.split("\n");
buffer = parts.pop(); // keep incomplete line
for (const part of parts) {
const trimmed = part.replace(/\r$/, "");
if (trimmed) appendLine(trimmed);
}
}
} catch (err) {
if (readingRef.current) {
setError("Serial connection lost: " + (err.message || String(err)));
setConnected(false);
}
}
}, [appendLine]);
const handleConnect = async () => {
setError("");
setSaved(false);
setConnecting(true);
try {
const port = await navigator.serial.requestPort();
await port.open({ baudRate: 115200 });
const info = port.getInfo?.() || {};
const label = info.usbVendorId
? `USB ${info.usbVendorId.toString(16).toUpperCase()}:${(info.usbProductId || 0).toString(16).toUpperCase()}`
: "Serial Port";
portRef.current = port;
setPortName(label);
setConnected(true);
setLines([]);
appendLine("--- Serial monitor connected ---");
startReading(port);
} catch (err) {
if (err.name !== "NotFoundError") {
setError(err.message || "Failed to open port.");
}
} finally {
setConnecting(false);
}
};
const handleDisconnect = async () => {
readingRef.current = false;
try { readerRef.current?.cancel(); } catch (_) {}
try { await portRef.current?.close(); } catch (_) {}
portRef.current = null;
readerRef.current = null;
setConnected(false);
appendLine("--- Disconnected ---");
};
const handleSave = () => {
if (lines.length === 0) return;
const label = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
// Save to localStorage for the Log Viewer page
persistSession(lines);
// Also download as a .txt file
saveLogsToFile(lines, label);
setSaved(true);
setSavedLabel(label);
};
const handleClear = () => {
setLines([]);
setSaved(false);
};
return (
<div
className="min-h-screen flex flex-col"
style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-primary)" }}
>
{/* ── Top bar ── */}
<div
className="flex items-center justify-between px-6 py-4 border-b"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div className="flex items-center gap-3">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: "var(--accent)" }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8}
d="M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2V9M9 21H5a2 2 0 01-2-2V9m0 0h18" />
</svg>
<span className="font-bold text-base" style={{ color: "var(--text-heading)" }}>
BellSystems Serial Monitor
</span>
</div>
<div className="flex items-center gap-2">
{/* Connection indicator */}
<span
className="inline-block w-2 h-2 rounded-full"
style={{ backgroundColor: connected ? "#22c55e" : "var(--border-primary)" }}
/>
<span className="text-sm" style={{ color: connected ? "var(--text-heading)" : "var(--text-muted)" }}>
{connected ? portName || "Connected" : "Not connected"}
</span>
</div>
</div>
{/* ── Body ── */}
<div className="flex-1 flex flex-col max-w-4xl w-full mx-auto px-6 py-6 gap-4">
{/* Browser support warning */}
{!webSerialAvailable && (
<InfoBox type="warning">
<strong>Browser not supported.</strong> Serial Monitor requires Google Chrome or Microsoft Edge on a desktop.
Safari, Firefox, and mobile browsers are not supported.
</InfoBox>
)}
{/* Error */}
{error && <InfoBox type="error">{error}</InfoBox>}
{/* Success feedback */}
{saved && (
<InfoBox type="success">
Logs saved! Downloaded as <strong>serial-log-{savedLabel}.txt</strong> and stored in Log Viewer.
</InfoBox>
)}
{/* Controls */}
<div
className="rounded-lg border p-4 flex flex-wrap items-center justify-between gap-3"
style={{ backgroundColor: "var(--bg-card)", borderColor: connected ? "var(--accent)" : "var(--border-primary)" }}
>
<div className="flex items-center gap-3">
{!connected ? (
<button
onClick={handleConnect}
disabled={!webSerialAvailable || connecting}
className="px-5 py-2 rounded-md text-sm font-semibold cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90 transition-opacity"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{connecting ? "Waiting…" : "Connect to Port"}
</button>
) : (
<button
onClick={handleDisconnect}
className="px-5 py-2 rounded-md text-sm font-medium cursor-pointer hover:opacity-80 transition-opacity"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
>
Disconnect
</button>
)}
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
{lines.length > 0 ? `${lines.length} lines` : "No output yet"}
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleClear}
disabled={lines.length === 0}
className="px-4 py-2 rounded-md text-sm font-medium cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-80 transition-opacity"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)", border: "1px solid var(--border-primary)" }}
>
Clear
</button>
<button
onClick={handleSave}
disabled={lines.length === 0}
className="px-5 py-2 rounded-md text-sm font-semibold cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90 transition-opacity flex items-center gap-2"
style={{ backgroundColor: "var(--accent)", color: "var(--bg-primary)" }}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
</svg>
Save Logs
</button>
</div>
</div>
{/* How-to hint when not connected */}
{!connected && lines.length === 0 && (
<div
className="rounded-lg border p-4 space-y-2"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-secondary)" }}
>
<p className="text-xs font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
How to use
</p>
{[
"Connect your BellSystems device via USB cable.",
"Click \"Connect to Port\" — a browser popup will appear.",
"Select the COM port for your device (look for CP210x, CH340, or USB Serial).",
"Serial output will appear live at 115200 baud.",
"When finished, click \"Save Logs\" to download a .txt file and store it in Log Viewer.",
].map((step, i) => (
<div key={i} className="flex items-start gap-2">
<span className="text-xs font-bold flex-shrink-0 mt-0.5" style={{ color: "var(--accent)" }}>{i + 1}.</span>
<span className="text-xs" style={{ color: "var(--text-muted)" }}>{step}</span>
</div>
))}
</div>
)}
{/* Log output */}
<div
className="rounded-md border overflow-y-auto font-mono text-xs flex-1"
style={{
backgroundColor: "#070d07",
borderColor: "var(--border-secondary)",
color: "#7ec87e",
minHeight: 400,
maxHeight: "calc(100vh - 340px)",
padding: "12px 14px",
}}
>
{lines.length === 0 ? (
<span style={{ color: "#3a5c3a" }}>Waiting for serial output</span>
) : (
lines.map((l, i) => (
<div key={i} className="leading-relaxed">
<span style={{ color: "#3a6b3a", userSelect: "none" }}>{l.ts} </span>
<span>{l.text}</span>
</div>
))
)}
<div ref={logEndRef} />
</div>
</div>
{/* ── Footer ── */}
<div
className="border-t px-6 py-3 text-center"
style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-card)" }}
>
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
BellSystems Serial Monitor · 115200 baud · Chrome / Edge only
</span>
</div>
</div>
);
}