Compare commits
2 Commits
b2d1e2bdc4
...
5d8ef96d4c
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d8ef96d4c | |||
| fee686a9f3 |
404
CRM_STATUS_SYSTEM_PLAN.md
Normal 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 1100–2000px, 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/`
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import { useAuth } from "./auth/AuthContext";
|
||||
import CloudFlashPage from "./cloudflash/CloudFlashPage";
|
||||
import SerialMonitorPage from "./serial/SerialMonitorPage";
|
||||
import SerialLogViewer from "./serial/SerialLogViewer";
|
||||
import PublicFeaturesSettings from "./settings/PublicFeaturesSettings";
|
||||
import LoginPage from "./auth/LoginPage";
|
||||
import MainLayout from "./layout/MainLayout";
|
||||
@@ -35,7 +37,7 @@ import DashboardPage from "./dashboard/DashboardPage";
|
||||
import ApiReferencePage from "./developer/ApiReferencePage";
|
||||
import { ProductList, ProductForm } from "./crm/products";
|
||||
import { CustomerList, CustomerForm, CustomerDetail } from "./crm/customers";
|
||||
import { OrderList, OrderForm, OrderDetail } from "./crm/orders";
|
||||
import { OrderList } from "./crm/orders";
|
||||
import { QuotationForm, AllQuotationsList } from "./crm/quotations";
|
||||
import CommsPage from "./crm/inbox/CommsPage";
|
||||
import MailPage from "./crm/mail/MailPage";
|
||||
@@ -110,6 +112,7 @@ export default function App() {
|
||||
<Routes>
|
||||
{/* Public routes — no login required */}
|
||||
<Route path="/cloudflash" element={<CloudFlashPage />} />
|
||||
<Route path="/serial-monitor" element={<SerialMonitorPage />} />
|
||||
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
@@ -176,9 +179,6 @@ export default function App() {
|
||||
<Route path="crm/customers/:id" element={<PermissionGate section="crm"><CustomerDetail /></PermissionGate>} />
|
||||
<Route path="crm/customers/:id/edit" element={<PermissionGate section="crm" action="edit"><CustomerForm /></PermissionGate>} />
|
||||
<Route path="crm/orders" element={<PermissionGate section="crm"><OrderList /></PermissionGate>} />
|
||||
<Route path="crm/orders/new" element={<PermissionGate section="crm" action="edit"><OrderForm /></PermissionGate>} />
|
||||
<Route path="crm/orders/:id" element={<PermissionGate section="crm"><OrderDetail /></PermissionGate>} />
|
||||
<Route path="crm/orders/:id/edit" element={<PermissionGate section="crm" action="edit"><OrderForm /></PermissionGate>} />
|
||||
<Route path="crm/quotations" element={<PermissionGate section="crm"><AllQuotationsList /></PermissionGate>} />
|
||||
<Route path="crm/quotations/new" element={<PermissionGate section="crm" action="edit"><QuotationForm /></PermissionGate>} />
|
||||
<Route path="crm/quotations/:id" element={<PermissionGate section="crm" action="edit"><QuotationForm /></PermissionGate>} />
|
||||
@@ -196,6 +196,9 @@ export default function App() {
|
||||
{/* Settings - Public Features */}
|
||||
<Route path="settings/public-features" element={<RoleGate roles={["sysadmin", "admin"]}><PublicFeaturesSettings /></RoleGate>} />
|
||||
|
||||
{/* Settings - Serial Log Viewer */}
|
||||
<Route path="settings/serial-logs" element={<RoleGate roles={["sysadmin", "admin"]}><SerialLogViewer /></RoleGate>} />
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
6
frontend/src/assets/customer-status/3-months.svg
Normal 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 |
6
frontend/src/assets/customer-status/6-months.svg
Normal 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 |
6
frontend/src/assets/customer-status/9-months.svg
Normal 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 |
@@ -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 |
9
frontend/src/assets/customer-status/awaiting-payment.svg
Normal 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 |
10
frontend/src/assets/customer-status/awating-quotation.svg
Normal 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 |
6
frontend/src/assets/customer-status/churned.svg
Normal 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 |
5
frontend/src/assets/customer-status/client.svg
Normal 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 |
6
frontend/src/assets/customer-status/declined.svg
Normal 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 |
6
frontend/src/assets/customer-status/exclamation.svg
Normal 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 |
6
frontend/src/assets/customer-status/inactive.svg
Normal 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 |
6
frontend/src/assets/customer-status/negotiating.svg
Normal 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 |
4
frontend/src/assets/customer-status/order.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 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 |
10
frontend/src/assets/customer-status/quotation-accepted.svg
Normal 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 |
25
frontend/src/assets/customer-status/shipped.svg
Normal file
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg height="800px" width="800px" version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 512 512" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill: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 |
10
frontend/src/assets/customer-status/started-mfg.svg
Normal 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 |
6
frontend/src/assets/customer-status/wrench.svg
Normal 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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
426
frontend/src/crm/customers/CustomerDetail/FinancialsTab.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
680
frontend/src/crm/customers/CustomerDetail/OrdersTab.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
671
frontend/src/crm/customers/CustomerDetail/OverviewTab.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
239
frontend/src/crm/customers/CustomerDetail/QuickEntryModals.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
315
frontend/src/crm/customers/CustomerDetail/SupportTab.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
frontend/src/crm/customers/CustomerDetail/shared.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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." />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)" }}>
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
export { default as OrderList } from "./OrderList";
|
||||
export { default as OrderForm } from "./OrderForm";
|
||||
export { default as OrderDetail } from "./OrderDetail";
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -147,6 +147,7 @@ const navSections = [
|
||||
const settingsChildren = [
|
||||
{ to: "/settings/staff", label: "Staff Management", icon: StaffIcon },
|
||||
{ to: "/settings/public-features", label: "Public Features", icon: SettingsIcon },
|
||||
{ to: "/settings/serial-logs", label: "Log Viewer", icon: BlackBoxIcon },
|
||||
{ to: "/settings/pages", label: "Page Settings", icon: PlaceholderIcon, placeholder: true },
|
||||
];
|
||||
|
||||
|
||||
242
frontend/src/serial/SerialLogViewer.jsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
function formatDate(isoString) {
|
||||
try {
|
||||
return new Date(isoString).toLocaleString();
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
|
||||
function LevelBadge({ line }) {
|
||||
const text = line.toUpperCase();
|
||||
if (text.includes("ERROR") || text.includes("ERR") || text.includes("[E]")) {
|
||||
return <span className="text-xs font-bold" style={{ color: "var(--danger)" }}>ERR</span>;
|
||||
}
|
||||
if (text.includes("WARN") || text.includes("[W]")) {
|
||||
return <span className="text-xs font-bold" style={{ color: "#fb923c" }}>WRN</span>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function lineColor(text) {
|
||||
const t = text.toUpperCase();
|
||||
if (t.includes("ERROR") || t.includes("ERR]") || t.includes("[E]")) return "var(--danger-text)";
|
||||
if (t.includes("WARN") || t.includes("[W]")) return "#fb923c";
|
||||
return "#7ec87e";
|
||||
}
|
||||
|
||||
export default function SerialLogViewer() {
|
||||
const [sessions, setSessions] = useState([]);
|
||||
const [expanded, setExpanded] = useState(null);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const raw = localStorage.getItem("bellsystems_serial_logs");
|
||||
if (raw) {
|
||||
try { setSessions(JSON.parse(raw)); } catch (_) {}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDelete = (id) => {
|
||||
const updated = sessions.filter((s) => s.id !== id);
|
||||
setSessions(updated);
|
||||
localStorage.setItem("bellsystems_serial_logs", JSON.stringify(updated));
|
||||
if (expanded === id) setExpanded(null);
|
||||
};
|
||||
|
||||
const handleExport = (session) => {
|
||||
const text = session.lines.map((l) => `[${l.ts}] ${l.text}`).join("\n");
|
||||
const blob = new Blob([text], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `serial-log-${session.label}.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const filteredSessions = sessions.filter((s) => {
|
||||
if (!search) return true;
|
||||
const q = search.toLowerCase();
|
||||
return (
|
||||
s.label.toLowerCase().includes(q) ||
|
||||
s.lines?.some((l) => l.text.toLowerCase().includes(q))
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold" style={{ color: "var(--text-heading)" }}>Serial Log Viewer</h1>
|
||||
<p className="text-sm mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||
Saved sessions from the Serial Monitor page.
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm" style={{ color: "var(--text-muted)" }}>
|
||||
{sessions.length} session{sessions.length !== 1 ? "s" : ""} stored
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
{sessions.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search sessions or log content…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-md text-sm"
|
||||
style={{
|
||||
backgroundColor: "var(--bg-card)",
|
||||
border: "1px solid var(--border-input)",
|
||||
color: "var(--text-primary)",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{sessions.length === 0 && (
|
||||
<div
|
||||
className="rounded-lg border p-10 text-center"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||
>
|
||||
<svg className="w-10 h-10 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
style={{ color: "var(--text-muted)" }}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p className="text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>No saved logs yet</p>
|
||||
<p className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||
Use the Serial Monitor page to connect and save sessions.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session list */}
|
||||
<div className="space-y-2">
|
||||
{filteredSessions.map((session) => {
|
||||
const isOpen = expanded === session.id;
|
||||
|
||||
// If searching, filter lines too
|
||||
const displayLines = search
|
||||
? session.lines?.filter((l) => l.text.toLowerCase().includes(search.toLowerCase()))
|
||||
: session.lines;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className="rounded-lg border overflow-hidden"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: isOpen ? "var(--accent)" : "var(--border-primary)" }}
|
||||
>
|
||||
{/* Session header row */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(isOpen ? null : session.id)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 text-left cursor-pointer hover:bg-[var(--bg-card-hover)] transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Chevron */}
|
||||
<svg
|
||||
className={`w-4 h-4 flex-shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`}
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
|
||||
{/* Icon */}
|
||||
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
style={{ color: isOpen ? "var(--accent)" : "var(--text-muted)" }}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8}
|
||||
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
|
||||
<div>
|
||||
<span className="text-sm font-medium font-mono" style={{ color: "var(--text-heading)" }}>
|
||||
{session.label}
|
||||
</span>
|
||||
<span className="text-xs ml-3" style={{ color: "var(--text-muted)" }}>
|
||||
{formatDate(session.savedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded-full font-medium"
|
||||
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}
|
||||
>
|
||||
{session.lineCount} lines
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Expanded log panel */}
|
||||
{isOpen && (
|
||||
<div style={{ borderTop: "1px solid var(--border-primary)" }}>
|
||||
{/* Toolbar */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-2"
|
||||
style={{ backgroundColor: "var(--bg-secondary)", borderBottom: "1px solid var(--border-primary)" }}
|
||||
>
|
||||
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||
{search ? `${displayLines?.length ?? 0} of ${session.lineCount} lines match` : `${session.lineCount} lines`}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleExport(session)}
|
||||
className="px-3 py-1 rounded text-xs font-medium cursor-pointer hover:opacity-80 transition-opacity flex items-center gap-1"
|
||||
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Export .txt
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(session.id)}
|
||||
className="px-3 py-1 rounded text-xs font-medium cursor-pointer hover:opacity-80 transition-opacity"
|
||||
style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)", border: "1px solid var(--danger)" }}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log content */}
|
||||
<div
|
||||
className="overflow-y-auto font-mono text-xs"
|
||||
style={{
|
||||
backgroundColor: "#070d07",
|
||||
maxHeight: 500,
|
||||
padding: "10px 14px",
|
||||
}}
|
||||
>
|
||||
{(!displayLines || displayLines.length === 0) ? (
|
||||
<span style={{ color: "#3a5c3a" }}>No lines to display.</span>
|
||||
) : (
|
||||
displayLines.map((l, i) => (
|
||||
<div key={i} className="leading-relaxed flex items-start gap-2">
|
||||
<span className="flex-shrink-0" style={{ color: "#3a6b3a", userSelect: "none" }}>
|
||||
{l.ts}
|
||||
</span>
|
||||
<span style={{ color: lineColor(l.text) }}>{l.text}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
340
frontend/src/serial/SerialMonitorPage.jsx
Normal file
@@ -0,0 +1,340 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatTimestamp(date) {
|
||||
return date.toISOString().replace("T", " ").replace("Z", "");
|
||||
}
|
||||
|
||||
function saveLogsToFile(lines, sessionLabel) {
|
||||
const text = lines.map((l) => `[${l.ts}] ${l.text}`).join("\n");
|
||||
const blob = new Blob([text], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `serial-log-${sessionLabel}.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function persistSession(lines) {
|
||||
const saved = JSON.parse(localStorage.getItem("bellsystems_serial_logs") || "[]");
|
||||
const label = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
const session = {
|
||||
id: Date.now(),
|
||||
label,
|
||||
savedAt: new Date().toISOString(),
|
||||
lineCount: lines.length,
|
||||
lines,
|
||||
};
|
||||
saved.unshift(session);
|
||||
// Keep last 20 sessions
|
||||
localStorage.setItem("bellsystems_serial_logs", JSON.stringify(saved.slice(0, 20)));
|
||||
return label;
|
||||
}
|
||||
|
||||
// ─── sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
function InfoBox({ type = "info", children }) {
|
||||
const styles = {
|
||||
info: { bg: "var(--badge-blue-bg)", border: "#1e3a5f", color: "var(--badge-blue-text)" },
|
||||
warning: { bg: "#2e1a00", border: "#7c4a00", color: "#fb923c" },
|
||||
error: { bg: "var(--danger-bg)", border: "var(--danger)", color: "var(--danger-text)" },
|
||||
success: { bg: "var(--success-bg)", border: "var(--success)", color: "var(--success-text)" },
|
||||
};
|
||||
const s = styles[type] || styles.info;
|
||||
return (
|
||||
<div
|
||||
className="text-sm rounded-md p-3 border"
|
||||
style={{ backgroundColor: s.bg, borderColor: s.border, color: s.color }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SerialMonitorPage() {
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [portName, setPortName] = useState("");
|
||||
const [lines, setLines] = useState([]);
|
||||
const [error, setError] = useState("");
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [savedLabel, setSavedLabel] = useState("");
|
||||
|
||||
const portRef = useRef(null);
|
||||
const readerRef = useRef(null);
|
||||
const readingRef = useRef(false);
|
||||
const logEndRef = useRef(null);
|
||||
const linesRef = useRef([]); // mirror for use inside async loop
|
||||
|
||||
const webSerialAvailable = "serial" in navigator;
|
||||
|
||||
// Keep linesRef in sync
|
||||
useEffect(() => {
|
||||
linesRef.current = lines;
|
||||
}, [lines]);
|
||||
|
||||
// Auto-scroll
|
||||
useEffect(() => {
|
||||
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [lines]);
|
||||
|
||||
const appendLine = useCallback((text) => {
|
||||
const entry = { ts: formatTimestamp(new Date()), text };
|
||||
setLines((prev) => [...prev, entry]);
|
||||
}, []);
|
||||
|
||||
const startReading = useCallback(async (port) => {
|
||||
const decoder = new TextDecoderStream();
|
||||
port.readable.pipeTo(decoder.writable).catch(() => {});
|
||||
const reader = decoder.readable.getReader();
|
||||
readerRef.current = reader;
|
||||
readingRef.current = true;
|
||||
|
||||
let buffer = "";
|
||||
try {
|
||||
while (readingRef.current) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += value;
|
||||
const parts = buffer.split("\n");
|
||||
buffer = parts.pop(); // keep incomplete line
|
||||
for (const part of parts) {
|
||||
const trimmed = part.replace(/\r$/, "");
|
||||
if (trimmed) appendLine(trimmed);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (readingRef.current) {
|
||||
setError("Serial connection lost: " + (err.message || String(err)));
|
||||
setConnected(false);
|
||||
}
|
||||
}
|
||||
}, [appendLine]);
|
||||
|
||||
const handleConnect = async () => {
|
||||
setError("");
|
||||
setSaved(false);
|
||||
setConnecting(true);
|
||||
try {
|
||||
const port = await navigator.serial.requestPort();
|
||||
await port.open({ baudRate: 115200 });
|
||||
|
||||
const info = port.getInfo?.() || {};
|
||||
const label = info.usbVendorId
|
||||
? `USB ${info.usbVendorId.toString(16).toUpperCase()}:${(info.usbProductId || 0).toString(16).toUpperCase()}`
|
||||
: "Serial Port";
|
||||
|
||||
portRef.current = port;
|
||||
setPortName(label);
|
||||
setConnected(true);
|
||||
setLines([]);
|
||||
appendLine("--- Serial monitor connected ---");
|
||||
startReading(port);
|
||||
} catch (err) {
|
||||
if (err.name !== "NotFoundError") {
|
||||
setError(err.message || "Failed to open port.");
|
||||
}
|
||||
} finally {
|
||||
setConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
readingRef.current = false;
|
||||
try { readerRef.current?.cancel(); } catch (_) {}
|
||||
try { await portRef.current?.close(); } catch (_) {}
|
||||
portRef.current = null;
|
||||
readerRef.current = null;
|
||||
setConnected(false);
|
||||
appendLine("--- Disconnected ---");
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (lines.length === 0) return;
|
||||
const label = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
// Save to localStorage for the Log Viewer page
|
||||
persistSession(lines);
|
||||
// Also download as a .txt file
|
||||
saveLogsToFile(lines, label);
|
||||
setSaved(true);
|
||||
setSavedLabel(label);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setLines([]);
|
||||
setSaved(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex flex-col"
|
||||
style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-primary)" }}
|
||||
>
|
||||
{/* ── Top bar ── */}
|
||||
<div
|
||||
className="flex items-center justify-between px-6 py-4 border-b"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: "var(--accent)" }}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8}
|
||||
d="M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2V9M9 21H5a2 2 0 01-2-2V9m0 0h18" />
|
||||
</svg>
|
||||
<span className="font-bold text-base" style={{ color: "var(--text-heading)" }}>
|
||||
BellSystems — Serial Monitor
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Connection indicator */}
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: connected ? "#22c55e" : "var(--border-primary)" }}
|
||||
/>
|
||||
<span className="text-sm" style={{ color: connected ? "var(--text-heading)" : "var(--text-muted)" }}>
|
||||
{connected ? portName || "Connected" : "Not connected"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Body ── */}
|
||||
<div className="flex-1 flex flex-col max-w-4xl w-full mx-auto px-6 py-6 gap-4">
|
||||
|
||||
{/* Browser support warning */}
|
||||
{!webSerialAvailable && (
|
||||
<InfoBox type="warning">
|
||||
<strong>Browser not supported.</strong> Serial Monitor requires Google Chrome or Microsoft Edge on a desktop.
|
||||
Safari, Firefox, and mobile browsers are not supported.
|
||||
</InfoBox>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && <InfoBox type="error">{error}</InfoBox>}
|
||||
|
||||
{/* Success feedback */}
|
||||
{saved && (
|
||||
<InfoBox type="success">
|
||||
Logs saved! Downloaded as <strong>serial-log-{savedLabel}.txt</strong> and stored in Log Viewer.
|
||||
</InfoBox>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<div
|
||||
className="rounded-lg border p-4 flex flex-wrap items-center justify-between gap-3"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: connected ? "var(--accent)" : "var(--border-primary)" }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{!connected ? (
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={!webSerialAvailable || connecting}
|
||||
className="px-5 py-2 rounded-md text-sm font-semibold cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90 transition-opacity"
|
||||
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||
>
|
||||
{connecting ? "Waiting…" : "Connect to Port"}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleDisconnect}
|
||||
className="px-5 py-2 rounded-md text-sm font-medium cursor-pointer hover:opacity-80 transition-opacity"
|
||||
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
)}
|
||||
|
||||
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||
{lines.length > 0 ? `${lines.length} lines` : "No output yet"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleClear}
|
||||
disabled={lines.length === 0}
|
||||
className="px-4 py-2 rounded-md text-sm font-medium cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-80 transition-opacity"
|
||||
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)", border: "1px solid var(--border-primary)" }}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={lines.length === 0}
|
||||
className="px-5 py-2 rounded-md text-sm font-semibold cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90 transition-opacity flex items-center gap-2"
|
||||
style={{ backgroundColor: "var(--accent)", color: "var(--bg-primary)" }}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
|
||||
</svg>
|
||||
Save Logs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* How-to hint when not connected */}
|
||||
{!connected && lines.length === 0 && (
|
||||
<div
|
||||
className="rounded-lg border p-4 space-y-2"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-secondary)" }}
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
|
||||
How to use
|
||||
</p>
|
||||
{[
|
||||
"Connect your BellSystems device via USB cable.",
|
||||
"Click \"Connect to Port\" — a browser popup will appear.",
|
||||
"Select the COM port for your device (look for CP210x, CH340, or USB Serial).",
|
||||
"Serial output will appear live at 115200 baud.",
|
||||
"When finished, click \"Save Logs\" to download a .txt file and store it in Log Viewer.",
|
||||
].map((step, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<span className="text-xs font-bold flex-shrink-0 mt-0.5" style={{ color: "var(--accent)" }}>{i + 1}.</span>
|
||||
<span className="text-xs" style={{ color: "var(--text-muted)" }}>{step}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log output */}
|
||||
<div
|
||||
className="rounded-md border overflow-y-auto font-mono text-xs flex-1"
|
||||
style={{
|
||||
backgroundColor: "#070d07",
|
||||
borderColor: "var(--border-secondary)",
|
||||
color: "#7ec87e",
|
||||
minHeight: 400,
|
||||
maxHeight: "calc(100vh - 340px)",
|
||||
padding: "12px 14px",
|
||||
}}
|
||||
>
|
||||
{lines.length === 0 ? (
|
||||
<span style={{ color: "#3a5c3a" }}>Waiting for serial output…</span>
|
||||
) : (
|
||||
lines.map((l, i) => (
|
||||
<div key={i} className="leading-relaxed">
|
||||
<span style={{ color: "#3a6b3a", userSelect: "none" }}>{l.ts} </span>
|
||||
<span>{l.text}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<div
|
||||
className="border-t px-6 py-3 text-center"
|
||||
style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-card)" }}
|
||||
>
|
||||
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||
BellSystems Serial Monitor · 115200 baud · Chrome / Edge only
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||