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>
This commit is contained in:
2026-03-25 10:39:38 +02:00
parent fee686a9f3
commit 5d8ef96d4c
33 changed files with 3699 additions and 1455 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

@@ -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 QuotationList from "../quotations/QuotationList";
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
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", "Support", "Financials", "Orders", "Quotations", "Communication", "Files & Media", "Devices"];
const LANGUAGE_LABELS = {
el: "Greek",
@@ -452,17 +457,22 @@ export default function CustomerDetail() {
const [error, setError] = useState("");
const [activeTab, setActiveTab] = useState(() => {
const tab = searchParams.get("tab");
const TABS = ["Overview", "Orders", "Quotations", "Communication", "Files & Media", "Devices"];
const TABS = ["Overview", "Support", "Financials", "Orders", "Quotations", "Communication", "Files & Media", "Devices"];
return TABS.includes(tab) ? tab : "Overview";
});
// Status toggles
// Comm direction (used by other parts)
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
const [orders, setOrders] = useState([]);
const [ordersLoading, setOrdersLoading] = useState(false);
const [expandOrderId, setExpandOrderId] = useState(null);
// Quotations (overview preview)
const [latestQuotations, setLatestQuotations] = useState([]);
@@ -564,6 +574,9 @@ export default function CustomerDetail() {
useEffect(() => {
const handler = (e) => {
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 (mediaFilterModalOpen) { setMediaFilterModalOpen(false); return; }
if (showUpload) { setShowUpload(false); return; }
@@ -578,7 +591,7 @@ export default function CustomerDetail() {
};
window.addEventListener("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(() => {
setLoading(true);
@@ -601,7 +614,7 @@ export default function CustomerDetail() {
const loadOrders = useCallback(() => {
setOrdersLoading(true);
api.get(`/crm/orders?customer_id=${id}`)
api.get(`/crm/customers/${id}/orders`)
.then((data) => setOrders(data.orders || []))
.catch(() => setOrders([]))
.finally(() => setOrdersLoading(false));
@@ -668,6 +681,8 @@ export default function CustomerDetail() {
useEffect(() => {
if (activeTab === "Overview") { loadOrders(); loadComms(); loadDevicesAndProducts(); loadLatestQuotations(); }
if (activeTab === "Support") { /* customer data already loaded */ }
if (activeTab === "Financials") { loadOrders(); }
if (activeTab === "Orders") loadOrders();
if (activeTab === "Communication") loadComms();
if (activeTab === "Files & Media") { setNcThumbMapState(null); loadMedia(); browseNextcloud(); }
@@ -685,6 +700,13 @@ export default function CustomerDetail() {
fetch(url).then(r => r.text()).then(t => setTxtContent(t)).catch(() => setTxtContent("(failed to load)"));
}, [previewFile]);
const handleTabChange = useCallback((tab, extraArg) => {
setActiveTab(tab);
if (tab === "Orders" && extraArg) {
setExpandOrderId(extraArg);
}
}, []);
// Early returns after all hooks
if (loading) {
return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
@@ -698,37 +720,6 @@ export default function CustomerDetail() {
}
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 () => {
setCommsSaving(true);
try {
@@ -1062,32 +1053,105 @@ export default function CustomerDetail() {
return (
<div>
{/* Header */}
<div className="flex items-start justify-between mb-6">
<div className="flex items-start justify-between mb-5">
<div>
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
{[customer.title, customer.name, customer.surname].filter(Boolean).join(" ")}
</h1>
{customer.organization && (
<p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>{customer.organization}</p>
)}
{locationStr && (
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>{locationStr}</p>
)}
<p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>
{[customer.organization, loc.city].filter(Boolean).join(" · ")}
</p>
</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
type="button"
onClick={() => navigate(`/crm/customers/${id}/edit`)}
className="px-4 py-2 text-sm rounded-md border cursor-pointer hover:opacity-80"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
style={{
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
</button>
)}
</div>
</div>
{/* Divider after header */}
<div style={{ borderTop: "1px solid var(--border-primary)", marginBottom: 20 }} />
{/* Tabs */}
<div className="flex gap-1 mb-5 border-b" style={{ borderColor: "var(--border-primary)" }}>
{TABS.map((tab) => (
@@ -1110,6 +1174,22 @@ export default function CustomerDetail() {
{/* Overview Tab */}
{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>
{/* Hero: Basic Info card — 75/25 split */}
<div className="ui-section-card mb-4" style={{ display: "grid", gridTemplateColumns: "8fr 2fr", gap: 40 }}>
@@ -1401,65 +1481,41 @@ export default function CustomerDetail() {
</div>
)}
{/* Support Tab */}
{activeTab === "Support" && (
<SupportTab
customer={customer}
canEdit={canEdit}
onCustomerUpdated={setCustomer}
user={user}
/>
)}
{/* Financials Tab */}
{activeTab === "Financials" && (
<FinancialsTab
customer={customer}
orders={orders}
canEdit={canEdit}
onCustomerUpdated={setCustomer}
onReloadOrders={loadOrders}
user={user}
onTabChange={handleTabChange}
/>
)}
{/* Orders Tab */}
{activeTab === "Orders" && (
<div>
<div className="flex items-center justify-between mb-4">
<span className="text-sm" style={{ color: "var(--text-muted)" }}>{orders.length} order{orders.length !== 1 ? "s" : ""}</span>
{canEdit && (
<button
onClick={() => navigate(`/crm/orders/new?customer_id=${id}`)}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-90"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
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>
<OrdersTab
customerId={id}
orders={orders}
ordersLoading={ordersLoading}
canEdit={canEdit}
user={user}
onReload={loadOrders}
expandOrderId={expandOrderId}
onExpandOrderIdConsumed={() => setExpandOrderId(null)}
/>
)}
{/* Quotations Tab */}
@@ -3644,6 +3700,33 @@ export default function CustomerDetail() {
</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>
);
}

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,680 @@
import { useState, useEffect } from "react";
import api from "../../../api/client";
import {
ORDER_STATUS_LABELS,
TIMELINE_TYPE_LABELS, OrderStatusChip, fmtDateTime, 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);
// ── 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-muted)", marginTop: 2, whiteSpace: "pre-wrap" }}>{event.note}</div>}
<div style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 2 }}>{fmtDateTime(event.date)} · {event.updated_by}</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" as the first archived entry → "Started Negotiations"
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 (and optionally title)
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 || order.notes || null,
});
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) => !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 }))} 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 ────────────────────────────────────────────────────────────
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",
});
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,671 @@
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";
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>
);
}
// Status badge: shows current status + gear icon to open inline change dropdown
function RelStatusSelector({ customer, onUpdated, canEdit, compact }) {
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;
if (compact) {
return (
<div style={{ position: "relative", display: "flex" }} ref={ref}>
<button
type="button"
onClick={() => canEdit && setOpen((v) => !v)}
style={{
display: "flex", alignItems: "center", gap: 8,
padding: "7px 14px", borderRadius: 8,
border: `1px solid ${st.border}`,
backgroundColor: st.bg,
color: st.color,
cursor: canEdit ? "pointer" : "default",
fontSize: 13, fontWeight: 700,
width: "100%",
}}
>
<span style={{ fontSize: 10, fontWeight: 600, opacity: 0.65, textTransform: "uppercase", letterSpacing: "0.06em" }}>Status</span>
<span style={{ width: 1, height: 14, backgroundColor: "currentColor", opacity: 0.3, flexShrink: 0 }} />
<span>{REL_STATUS_LABELS[current] || current}</span>
{canEdit && (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.7, flexShrink: 0 }}>
<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>
);
}
return (
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
{statuses.map((s) => {
const sst = REL_STATUS_STYLES[s] || {};
const isActive = s === current;
return (
<button
key={s}
type="button"
onClick={() => handleClick(s)}
disabled={!canEdit}
style={{
padding: "5px 14px",
borderRadius: 20,
fontSize: 12,
fontWeight: 600,
cursor: canEdit ? "pointer" : "default",
border: `1px solid ${isActive ? sst.border : "var(--border-primary)"}`,
backgroundColor: isActive ? sst.bg : "transparent",
color: isActive ? sst.color : "var(--text-muted)",
boxShadow: isActive ? `0 0 8px ${sst.bg}` : "none",
transition: "all 0.15s ease",
}}
>
{REL_STATUS_LABELS[s]}
</button>
);
})}
</div>
);
}
// bg/color/border per chip type
const CHIP_STYLES = {
issue: { bg: "var(--crm-issue-active-bg,rgba(224,53,53,0.12))", color: "var(--crm-issue-active-text)", border: "var(--crm-issue-active-text)" },
support: { bg: "var(--crm-support-active-bg,rgba(247,103,7,0.12))", color: "var(--crm-support-active-text)", border: "var(--crm-support-active-text)" },
order: { bg: "var(--badge-blue-bg,rgba(59,130,246,0.12))", color: "var(--badge-blue-text)", border: "var(--badge-blue-text)" },
};
function StatChip({ count, label, onClick, type }) {
const s = CHIP_STYLES[type] || {};
return (
<button
type="button"
onClick={onClick}
style={{
display: "flex", alignItems: "center", gap: 7,
padding: "7px 14px", borderRadius: 8, fontSize: 13,
border: `1px solid ${s.border || "var(--border-primary)"}`,
backgroundColor: s.bg || "var(--bg-primary)",
color: s.color || "var(--text-secondary)",
cursor: "pointer",
whiteSpace: "nowrap",
transition: "opacity 0.15s",
fontWeight: 600,
}}
onMouseEnter={(e) => e.currentTarget.style.opacity = "0.75"}
onMouseLeave={(e) => e.currentTarget.style.opacity = "1"}
>
<span style={{ fontSize: 13, fontWeight: 700 }}>{count}</span>
<span style={{ fontSize: 10, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.06em", opacity: 0.8 }}>{label}</span>
</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">
{/* Row 1: Status badge + stat chips */}
<div style={{ display: "flex", alignItems: "stretch", gap: 18, flexWrap: "wrap", marginBottom: 30 }}>
{/* Status badge — includes inline change dropdown via gear */}
<RelStatusSelector customer={customer} onUpdated={onCustomerUpdated} canEdit={canEdit} compact />
{/* Stat chips — only shown when count > 0 */}
{openIssues > 0 && (
<StatChip count={openIssues} label={`Issue${openIssues !== 1 ? "s" : ""}`} onClick={() => onTabChange("Support")} type="issue" />
)}
{supportInquiries > 0 && (
<StatChip count={supportInquiries} label={`Support${supportInquiries !== 1 ? " Tickets" : " Ticket"}`} onClick={() => onTabChange("Support")} type="support" />
)}
{openOrders > 0 && (
<StatChip count={openOrders} label={`Order${openOrders !== 1 ? "s" : ""}`} 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,239 @@
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",
});
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" },
];
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 = {
email: "📧",
@@ -85,6 +92,7 @@ export default function CustomerForm() {
organization: "",
religion: "",
language: "el",
relationship_status: "lead",
tags: [],
folder_id: "",
location: { address: "", city: "", postal_code: "", region: "", country: "" },
@@ -117,6 +125,7 @@ export default function CustomerForm() {
organization: data.organization || "",
religion: data.religion || "",
language: data.language || "el",
relationship_status: data.relationship_status || "lead",
tags: data.tags || [],
folder_id: data.folder_id || "",
location: {
@@ -201,6 +210,7 @@ export default function CustomerForm() {
organization: form.organization.trim() || null,
religion: form.religion.trim() || null,
language: form.language,
relationship_status: form.relationship_status || "lead",
tags: form.tags,
...(!isEdit && { folder_id: form.folder_id.trim().toLowerCase() }),
location: {
@@ -328,8 +338,14 @@ export default function CustomerForm() {
</Field>
</div>
{/* Row 2: Organization, Religion, Language, Folder ID */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr 1fr", gap: 16, marginBottom: 16 }}>
{/* Row 2: Relationship Status + Organization + Religion + Language + Folder ID */}
<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">
<input className={inputClass} style={inputStyle} value={form.organization}
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 { 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 = {
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-primary)",
@@ -46,6 +63,7 @@ function resolveLanguage(val) {
const ALL_COLUMNS = [
{ id: "name", label: "Name", default: true, locked: true },
{ id: "status", label: "Status", default: true },
{ id: "support", label: "Support", default: true },
{ id: "organization", label: "Organization", default: true },
{ id: "address", label: "Full Address", default: true },
{ id: "location", label: "Location", default: true },
@@ -95,35 +113,6 @@ function saveColumnPrefs(visible, 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 ─────────────────────────────────────────────────────
@@ -136,46 +125,205 @@ function statusColors(direction) {
return { negColor, issColor, pendingOurReply };
}
// ── Quick mode status cell ───────────────────────────────────────────────────
// Icon sizes — edit these to adjust icon dimensions per mode:
// Quick mode icons: QUICK_ICON_SIZE (negotiations slightly larger than issues)
const QUICK_NEG_ICON_SIZE = 25; // px — negotiations icon in Quick mode
const QUICK_ISS_ICON_SIZE = 20; // px — issues icon in Quick mode
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
// ── Customer status icon resolver ─────────────────────────────────────────────
// Pre-manufacturing statuses: customer can still go silent/churn
const PRE_MFG_STATUSES = new Set([
"negotiating", "awaiting_quotation", "awaiting_customer_confirmation",
"awaiting_fulfilment", "awaiting_payment",
]);
function StatusIconsCell({ customer, direction }) {
const hasNeg = customer.negotiating;
const hasIssue = customer.has_problem;
if (!hasNeg && !hasIssue) return <td className="px-3 py-3" />;
// Returns { icon, color, title } for a customer based on their status + orders
function resolveStatusIcon(customer) {
const status = customer.relationship_status || "lead";
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 (
<td className="px-3 py-3" style={{ textAlign: "center" }}>
<div style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
{hasNeg && (
<span
title={pendingOurReply ? "Negotiating — client awaiting our reply" : "Negotiating — we sent last"}
style={{ color: negColor, display: "inline-flex" }}
>
<IconNegotiations style={{ width: QUICK_NEG_ICON_SIZE, height: QUICK_NEG_ICON_SIZE, display: "inline-block", flexShrink: 0 }} />
</span>
)}
{renderMaskedIcon(icon, color, title)}
</td>
);
}
// ── Icon color filter system ──────────────────────────────────────────────────
// CSS filters starting from a black SVG source.
// ── 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 && (
<span
title={pendingOurReply ? "Open issue — client awaiting our reply" : "Open issue — we last contacted them"}
style={{ color: issColor, display: "inline-flex" }}
>
<IconIssues style={{ width: QUICK_ISS_ICON_SIZE, height: QUICK_ISS_ICON_SIZE, display: "inline-block", flexShrink: 0 }} />
<span title={`${summary.active_issues_count} active technical issue(s)`}
style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
<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>
</span>
)}
{(hasNeg || hasIssue) && pendingOurReply && (
<span title="Awaiting our reply" style={{ color: "var(--crm-status-alert)", display: "inline-flex" }}>
<IconImportant style={{ width: QUICK_IMP_ICON_SIZE, height: QUICK_IMP_ICON_SIZE, display: "inline-block", flexShrink: 0 }} className="crm-icon-breathe" />
{hasSupport && (
<span title={`${summary.active_support_count} active support item(s)`}
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>
)}
</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 ────────────────────────────────────────────────────────────
function ColumnToggle({ visible, orderedIds, onChange, onReorder }) {
@@ -284,8 +420,13 @@ function ColumnToggle({ visible, orderedIds, onChange, onReorder }) {
// ── Filter dropdown ──────────────────────────────────────────────────────────
const FILTER_OPTIONS = [
{ value: "negotiating", label: "Negotiating" },
{ value: "has_problem", label: "Has Open Issue" },
{ value: "lead", label: "Lead" },
{ 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 }) {
@@ -470,105 +611,19 @@ function primaryContact(customer, type) {
return primary?.value || contacts.find((c) => c.type === type)?.value || null;
}
function ActionsDropdown({ customer, onUpdate }) {
const [open, setOpen] = useState(false);
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);
}
};
function ActionsDropdown({ customer }) {
const navigate = useNavigate();
return (
<div onClick={e => e.stopPropagation()}>
<button
ref={btnRef}
onClick={handleOpen}
style={{
padding: "4px 10px", fontSize: 11, fontWeight: 600, borderRadius: 5,
border: "1px solid var(--border-primary)", cursor: "pointer",
backgroundColor: "transparent", color: "var(--text-secondary)",
}}
>
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>
<button
onClick={(e) => { e.stopPropagation(); navigate(`/crm/customers/${customer.id}`); }}
style={{
padding: "4px 10px", fontSize: 11, fontWeight: 600, borderRadius: 5,
border: "1px solid var(--border-primary)", cursor: "pointer",
backgroundColor: "transparent", color: "var(--text-secondary)",
}}
>
Open
</button>
);
}
@@ -621,7 +676,13 @@ export default function CustomerList() {
};
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;
const results = await Promise.allSettled(
flagged.map(c =>
@@ -643,6 +704,20 @@ export default function CustomerList() {
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]);
const updateColVisible = (id, vis) => {
@@ -662,10 +737,15 @@ export default function CustomerList() {
.map((id) => ALL_COLUMNS.find((c) => c.id === id))
.filter((c) => c && colPrefs.visible[c.id]);
const filteredCustomers = activeFilters.size === 0 ? customers : customers.filter(c =>
(!activeFilters.has("negotiating") || c.negotiating) &&
(!activeFilters.has("has_problem") || c.has_problem)
);
const filteredCustomers = activeFilters.size === 0 ? customers : customers.filter(c => {
const summary = c.crm_summary || {};
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 safePage = Math.min(page, Math.max(1, totalPages));
@@ -675,18 +755,9 @@ export default function CustomerList() {
const handleCustomerUpdate = (updated) => {
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 || {};
switch (col.id) {
case "name":
@@ -696,7 +767,9 @@ export default function CustomerList() {
</td>
);
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":
return <td key={col.id} className="px-4 py-3" style={{ color: "var(--text-primary)" }}>{c.organization || "—"}</td>;
case "address": {
@@ -736,23 +809,23 @@ export default function CustomerList() {
}
};
// In expanded mode, hide the status column — info is shown as sub-rows instead
const visibleColsForMode = notesMode === "expanded"
? visibleCols.filter(c => c.id !== "status")
: visibleCols;
const visibleColsForMode = visibleCols;
// Total column count for colSpan on expanded sub-rows
const totalCols = visibleColsForMode.length + (canEdit ? 1 : 0);
// Row gradient background for customers with active status flags
function rowGradient(customer, direction) {
const hasNeg = customer.negotiating;
const hasIssue = customer.has_problem;
if (!hasNeg && !hasIssue) return undefined;
const pendingOurReply = direction === "inbound";
// Index of the "name" column among visible columns (sub-rows align under it)
const nameColIndex = visibleColsForMode.findIndex(c => c.id === "name");
// Row gradient background for customers with active issues or support items
function rowGradient(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 undefined;
const color = hasIssue
? (pendingOurReply ? "rgba(224, 53, 53, 0.07)" : "rgba(224, 53, 53, 0.05)")
: (pendingOurReply ? "rgba(247, 103, 7, 0.07)" : "rgba(232, 165, 4, 0.05)");
? "rgba(224, 53, 53, 0.05)"
: "rgba(247, 103, 7, 0.05)";
return `linear-gradient(to right, ${color} 0%, transparent 70%)`;
}
@@ -824,7 +897,7 @@ export default function CustomerList() {
<thead>
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
{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}
</th>
))}
@@ -835,21 +908,24 @@ export default function CustomerList() {
{pagedCustomers.map((c, index) => {
const direction = commDirections[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 gradient = rowGradient(c, direction);
const gradient = rowGradient(c);
const rowBg = hoveredRow === c.id ? "var(--bg-card-hover)" : undefined;
const zebraBase = index % 2 === 1 ? "var(--bg-row-alt)" : "transparent";
const rowBackground = gradient
? `${gradient}, ${zebraBase}`
: zebraBase;
const rowStyle = {
borderBottom: (!isLast && !(notesMode === "expanded" && hasStatus))
borderBottom: (!isLast && notesMode !== "expanded")
? "1px solid var(--border-secondary)"
: "none",
background: rowBg ? rowBg : rowBackground,
};
// In expanded mode, hue overlay is applied on sub-rows (computed there)
const mainRow = (
<tr
key={`${c.id}-main`}
@@ -859,7 +935,7 @@ export default function CustomerList() {
onMouseEnter={() => setHoveredRow(c.id)}
onMouseLeave={() => setHoveredRow(null)}
>
{visibleColsForMode.map((col) => renderCell(col, c, direction))}
{visibleColsForMode.map((col) => renderCell(col, c, direction, lastDate))}
{canEdit && (
<td className="px-4 py-3 text-right">
<ActionsDropdown customer={c} onUpdate={handleCustomerUpdate} />
@@ -868,92 +944,106 @@ export default function CustomerList() {
</tr>
);
if (notesMode === "expanded" && hasStatus) {
const { negColor, issColor, pendingOurReply } = statusColors(direction);
const when = relDays(lastDate);
if (notesMode === "expanded") {
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) {
let text;
if (pendingOurReply) {
text = when
? `Undergoing negotiations — client last contacted us ${when}. Reply needed.`
: "Undergoing negotiations — client is awaiting our reply.";
} else {
text = when
? `Undergoing negotiations — we last reached out ${when}.`
: "Undergoing negotiations.";
}
subRows.push(
<tr
key={`${c.id}-neg`}
className="cursor-pointer"
onClick={() => navigate(`/crm/customers/${c.id}`)}
style={{
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: negColor, display: "inline-flex" }}>
<IconNegotiations style={{ width: EXP_NEG_ICON_SIZE, height: EXP_NEG_ICON_SIZE, flexShrink: 0 }} />
</span>
{pendingOurReply && (
<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" />
</span>
)}
<span style={{ fontSize: 11.5, color: negColor, fontWeight: 500 }}>{text}</span>
// Icon box size — fixed so text aligns regardless of icon
const SUB_ICON_BOX = 40;
// Hue tint for the whole customer block when issues/support exist
const hueGradient = issueCount > 0
? "linear-gradient(to right, rgba(224, 53, 53, 0.07) 0%, transparent 70%)"
: supportCount > 0
? "linear-gradient(to right, rgba(247, 103, 7, 0.07) 0%, transparent 70%)"
: null;
// All rows in this customer's block share the same zebra+hue tint
const sharedBg = subRowBg
? subRowBg
: hueGradient
? `${hueGradient}, ${zebraBase}`
: zebraBase;
// Columns before "name" get empty cells; content spans from name onward
const colsBeforeName = nameColIndex > 0 ? nameColIndex : 0;
const colsFromName = totalCols - colsBeforeName;
const makeSubRow = (key, content, isLastSubRow = false) => (
<tr
key={key}
className="cursor-pointer"
onClick={() => navigate(`/crm/customers/${c.id}`)}
style={{ borderBottom: "none", background: sharedBg }}
onMouseEnter={() => setHoveredRow(c.id)}
onMouseLeave={() => setHoveredRow(null)}
>
{colsBeforeName > 0 && (
<td colSpan={colsBeforeName} style={{ padding: 0 }} />
)}
<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>
</td>
</tr>
);
}
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.";
<span style={{ fontSize: 11.5, color: "var(--crm-issue-active-text)", fontWeight: 500 }}>{label}</span>
</div>
), isLastSubRow);
}
subRows.push(
<tr
key={`${c.id}-iss`}
className="cursor-pointer"
onClick={() => navigate(`/crm/customers/${c.id}`)}
style={{
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>
if (type === "support") {
const label = `${supportCount} active support ticket${supportCount > 1 ? "s" : ""}`;
return makeSubRow(`${c.id}-sup`, (
<div style={{ display: "flex", alignItems: "center" }}>
<div style={{ width: SUB_ICON_BOX, flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
{renderMaskedIcon(wrenchIcon, "var(--crm-support-active-text)", "Support", 15)}
</div>
</td>
</tr>
);
}
<span style={{ fontSize: 11.5, color: "var(--crm-support-active-text)", fontWeight: 500 }}>{label}</span>
</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) {
subRows.push(
@@ -980,7 +1070,7 @@ export default function CustomerList() {
onMouseEnter={() => setHoveredRow(c.id)}
onMouseLeave={() => setHoveredRow(null)}
>
{visibleColsForMode.map((col) => renderCell(col, c, direction))}
{visibleColsForMode.map((col) => renderCell(col, c, direction, lastDate))}
{canEdit && (
<td className="px-4 py-3 text-right">
<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 { useNavigate } from "react-router-dom";
import api from "../../api/client";
import { useAuth } from "../../auth/AuthContext";
const STATUS_COLORS = {
draft: { bg: "var(--bg-card-hover)", color: "var(--text-secondary)" },
confirmed: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" },
in_production: { bg: "#fff7ed", color: "#9a3412" },
shipped: { bg: "#f5f3ff", color: "#6d28d9" },
delivered: { bg: "var(--success-bg)", color: "var(--success-text)" },
cancelled: { bg: "var(--danger-bg)", color: "var(--danger-text)" },
const 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",
};
const PAYMENT_COLORS = {
pending: { bg: "#fef9c3", color: "#854d0e" },
partial: { bg: "#fff7ed", color: "#9a3412" },
paid: { bg: "var(--success-bg)", color: "var(--success-text)" },
};
const ORDER_STATUSES = Object.keys(ORDER_STATUS_LABELS);
const inputStyle = {
backgroundColor: "var(--bg-input)",
@@ -24,30 +23,13 @@ const inputStyle = {
color: "var(--text-primary)",
};
const ORDER_STATUSES = ["draft", "confirmed", "in_production", "shipped", "delivered", "cancelled"];
const PAYMENT_STATUSES = ["pending", "partial", "paid"];
export default function OrderList() {
const [orders, setOrders] = useState([]);
const [customerMap, setCustomerMap] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [paymentFilter, setPaymentFilter] = useState("");
const [hoveredRow, setHoveredRow] = useState(null);
const navigate = useNavigate();
const { hasPermission } = useAuth();
const canEdit = hasPermission("crm", "edit");
useEffect(() => {
api.get("/crm/customers")
.then((data) => {
const map = {};
(data.customers || []).forEach((c) => { map[c.id] = c; });
setCustomerMap(map);
})
.catch(() => {});
}, []);
const fetchOrders = async () => {
setLoading(true);
@@ -55,7 +37,6 @@ export default function OrderList() {
try {
const params = new URLSearchParams();
if (statusFilter) params.set("status", statusFilter);
if (paymentFilter) params.set("payment_status", paymentFilter);
const qs = params.toString();
const data = await api.get(`/crm/orders${qs ? `?${qs}` : ""}`);
setOrders(data.orders || []);
@@ -66,23 +47,12 @@ export default function OrderList() {
}
};
useEffect(() => {
fetchOrders();
}, [statusFilter, paymentFilter]);
useEffect(() => { fetchOrders(); }, [statusFilter]);
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Orders Manager</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>
)}
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Orders</h1>
</div>
<div className="flex gap-3 mb-4">
@@ -94,27 +64,14 @@ export default function OrderList() {
>
<option value="">All Statuses</option>
{ORDER_STATUSES.map((s) => (
<option key={s} value={s}>{s.replace("_", " ")}</option>
))}
</select>
<select
value={paymentFilter}
onChange={(e) => setPaymentFilter(e.target.value)}
className="px-3 py-2 text-sm rounded-md border"
style={inputStyle}
>
<option value="">All Payment Statuses</option>
{PAYMENT_STATUSES.map((s) => (
<option key={s} value={s}>{s}</option>
<option key={s} value={s}>{ORDER_STATUS_LABELS[s]}</option>
))}
</select>
</div>
{error && (
<div
className="text-sm rounded-md p-3 mb-4 border"
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
>
<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>
)}
@@ -122,44 +79,33 @@ export default function OrderList() {
{loading ? (
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>
) : orders.length === 0 ? (
<div
className="rounded-lg p-8 text-center text-sm border"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}
>
<div className="rounded-lg p-8 text-center text-sm border"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}>
No orders found.
</div>
) : (
<div
className="rounded-lg overflow-hidden border"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div className="rounded-lg overflow-hidden border"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Order #</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Customer</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>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)" }}>Total</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Payment</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Date</th>
</tr>
</thead>
<tbody>
{orders.map((o, index) => {
const statusStyle = STATUS_COLORS[o.status] || STATUS_COLORS.draft;
const payStyle = PAYMENT_COLORS[o.payment_status] || PAYMENT_COLORS.pending;
const customer = customerMap[o.customer_id];
const customerName = customer
? customer.organization
? `${customer.name} / ${customer.organization}`
: customer.name
: o.customer_id || "—";
const statusLabel = ORDER_STATUS_LABELS[o.status] || o.status || "—";
const customerName = o.customer_name || o.customer_id || "—";
return (
<tr
key={o.id}
onClick={() => navigate(`/crm/orders/${o.id}`)}
onClick={() => navigate(`/crm/customers/${o.customer_id}?tab=Orders`)}
className="cursor-pointer"
style={{
borderBottom: index < orders.length - 1 ? "1px solid var(--border-secondary)" : "none",
@@ -169,26 +115,19 @@ export default function OrderList() {
onMouseLeave={() => setHoveredRow(null)}
>
<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 className="px-4 py-3" style={{ color: "var(--text-primary)" }}>{customerName}</td>
<td className="px-4 py-3">
<span
className="px-2 py-0.5 text-xs rounded-full capitalize"
style={{ backgroundColor: statusStyle.bg, color: statusStyle.color }}
>
{(o.status || "draft").replace("_", " ")}
</span>
</td>
<td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>
{o.currency || "EUR"} {Number(o.total_price || 0).toFixed(2)}
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
{o.title || <span style={{ fontStyle: "italic" }}>Untitled</span>}
</td>
<td className="px-4 py-3">
<span
className="px-2 py-0.5 text-xs rounded-full capitalize"
style={{ backgroundColor: payStyle.bg, color: payStyle.color }}
>
{o.payment_status || "pending"}
<span className="px-2 py-0.5 text-xs rounded-full"
style={{
backgroundColor: `var(--crm-ord-${o.status}-bg, var(--bg-card-hover))`,
color: `var(--crm-ord-${o.status}-text, var(--text-secondary))`,
}}>
{statusLabel}
</span>
</td>
<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 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);
const latNum = parseFloat(lat);
const lngNum = parseFloat(lng);
const coordStr = (!isNaN(latNum) && !isNaN(lngNum))
? `${Math.abs(latNum).toFixed(7)}° ${latNum >= 0 ? "N" : "S"}, ${Math.abs(lngNum).toFixed(7)}° ${lngNum >= 0 ? "E" : "W"}`
: device?.device_location_coordinates || "";
const coordPayload = (!isNaN(latNum) && !isNaN(lngNum))
? { lat: latNum, lng: lngNum }
: (device?.device_location_coordinates || null);
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();
onClose();
} finally { setSaving(false); }
@@ -1408,11 +1408,21 @@ function playbackPlaceholderForId(seedValue) {
function parseCoordinates(coordStr) {
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);
if (numbers && numbers.length >= 2) {
const lat = parseFloat(numbers[0]);
const lng = parseFloat(numbers[1]);
let lat = parseFloat(numbers[0]);
let lng = parseFloat(numbers[1]);
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 };
}
}

View File

@@ -2,6 +2,14 @@
/* BellSystems Dark Theme - Custom Properties */
: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-secondary: rgba(31, 41, 55, 0.959);
--bg-card: #1f2937;
@@ -55,6 +63,53 @@
--crm-status-alert: #f76707; /* orange — client sent last */
--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 ── */
--section-padding: 2.25rem 2.5rem 2.25rem;
--section-padding-compact: 1.25rem 1.5rem;