diff --git a/CRM_STATUS_SYSTEM_PLAN.md b/CRM_STATUS_SYSTEM_PLAN.md new file mode 100644 index 0000000..ef9ca3e --- /dev/null +++ b/CRM_STATUS_SYSTEM_PLAN.md @@ -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: + `""` 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/` diff --git a/frontend/src/assets/customer-status/3-months.svg b/frontend/src/assets/customer-status/3-months.svg new file mode 100644 index 0000000..d9e8bda --- /dev/null +++ b/frontend/src/assets/customer-status/3-months.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/customer-status/6-months.svg b/frontend/src/assets/customer-status/6-months.svg new file mode 100644 index 0000000..029cd2c --- /dev/null +++ b/frontend/src/assets/customer-status/6-months.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/customer-status/9-months.svg b/frontend/src/assets/customer-status/9-months.svg new file mode 100644 index 0000000..2298434 --- /dev/null +++ b/frontend/src/assets/customer-status/9-months.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/customer-status/awaiting-confirmation.svg b/frontend/src/assets/customer-status/awaiting-confirmation.svg new file mode 100644 index 0000000..543bbd3 --- /dev/null +++ b/frontend/src/assets/customer-status/awaiting-confirmation.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/customer-status/awaiting-payment.svg b/frontend/src/assets/customer-status/awaiting-payment.svg new file mode 100644 index 0000000..18ab6dc --- /dev/null +++ b/frontend/src/assets/customer-status/awaiting-payment.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/customer-status/awating-quotation.svg b/frontend/src/assets/customer-status/awating-quotation.svg new file mode 100644 index 0000000..bffd32a --- /dev/null +++ b/frontend/src/assets/customer-status/awating-quotation.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/customer-status/churned.svg b/frontend/src/assets/customer-status/churned.svg new file mode 100644 index 0000000..3bda2fb --- /dev/null +++ b/frontend/src/assets/customer-status/churned.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/customer-status/client.svg b/frontend/src/assets/customer-status/client.svg new file mode 100644 index 0000000..97635b7 --- /dev/null +++ b/frontend/src/assets/customer-status/client.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/assets/customer-status/declined.svg b/frontend/src/assets/customer-status/declined.svg new file mode 100644 index 0000000..71050f5 --- /dev/null +++ b/frontend/src/assets/customer-status/declined.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/customer-status/exclamation.svg b/frontend/src/assets/customer-status/exclamation.svg new file mode 100644 index 0000000..ccc02e7 --- /dev/null +++ b/frontend/src/assets/customer-status/exclamation.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/customer-status/inactive.svg b/frontend/src/assets/customer-status/inactive.svg new file mode 100644 index 0000000..da14b41 --- /dev/null +++ b/frontend/src/assets/customer-status/inactive.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/customer-status/negotiating.svg b/frontend/src/assets/customer-status/negotiating.svg new file mode 100644 index 0000000..23c2550 --- /dev/null +++ b/frontend/src/assets/customer-status/negotiating.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/customer-status/order.svg b/frontend/src/assets/customer-status/order.svg new file mode 100644 index 0000000..ccb703b --- /dev/null +++ b/frontend/src/assets/customer-status/order.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/src/assets/customer-status/quotation-accepted.svg b/frontend/src/assets/customer-status/quotation-accepted.svg new file mode 100644 index 0000000..b493df9 --- /dev/null +++ b/frontend/src/assets/customer-status/quotation-accepted.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/customer-status/shipped.svg b/frontend/src/assets/customer-status/shipped.svg new file mode 100644 index 0000000..b343e56 --- /dev/null +++ b/frontend/src/assets/customer-status/shipped.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/customer-status/started-mfg.svg b/frontend/src/assets/customer-status/started-mfg.svg new file mode 100644 index 0000000..e65493e --- /dev/null +++ b/frontend/src/assets/customer-status/started-mfg.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/customer-status/wrench.svg b/frontend/src/assets/customer-status/wrench.svg new file mode 100644 index 0000000..717f022 --- /dev/null +++ b/frontend/src/assets/customer-status/wrench.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/src/crm/customers/CustomerDetail.jsx b/frontend/src/crm/customers/CustomerDetail.jsx index 11c0807..db5277b 100644 --- a/frontend/src/crm/customers/CustomerDetail.jsx +++ b/frontend/src/crm/customers/CustomerDetail.jsx @@ -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 }) => ; @@ -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
Loading...
; @@ -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 (
{/* Header */} -
+

{[customer.title, customer.name, customer.surname].filter(Boolean).join(" ")}

- {customer.organization && ( -

{customer.organization}

- )} - {locationStr && ( -

{locationStr}

- )} +

+ {[customer.organization, loc.city].filter(Boolean).join(" · ")} +

- {canEdit && ( + + {/* Action buttons */} +
+ {canEdit && ( + <> + {/* Init Negotiations */} + + + {/* Record Issue/Support */} + + + {/* Record Payment */} + + + )} + + {/* Edit Customer */} - )} +
- {/* Divider after header */} -
- {/* Tabs */}
{TABS.map((tab) => ( @@ -1110,6 +1174,22 @@ export default function CustomerDetail() { {/* Overview Tab */} {activeTab === "Overview" && ( + setExpandedComms((prev) => ({ ...prev, [commId]: true }))} + user={user} + /> + )} + + {/* OLD Overview content — replaced, keeping stub to avoid parse error */} + {false && (
{/* Hero: Basic Info card — 75/25 split */}
@@ -1401,65 +1481,41 @@ export default function CustomerDetail() {
)} + {/* Support Tab */} + {activeTab === "Support" && ( + + )} + + {/* Financials Tab */} + {activeTab === "Financials" && ( + + )} + {/* Orders Tab */} {activeTab === "Orders" && ( -
-
- {orders.length} order{orders.length !== 1 ? "s" : ""} - {canEdit && ( - - )} -
- {ordersLoading ? ( -
Loading...
- ) : orders.length === 0 ? ( -
- No orders yet. -
- ) : ( -
- - - - - - - - - - - {orders.map((o, idx) => ( - 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")} - > - - - - - - ))} - -
Order #StatusTotalDate
{o.order_number} - - {o.status} - - €{Number(o.total_price || 0).toFixed(2)} - {o.created_at ? new Date(o.created_at).toLocaleDateString() : "—"} -
-
- )} -
+ setExpandOrderId(null)} + /> )} {/* Quotations Tab */} @@ -3644,6 +3700,33 @@ export default function CustomerDetail() {
); })()} + + {/* Quick-entry modals */} + {showInitNegModal && ( + setShowInitNegModal(false)} + onSuccess={() => { loadCustomer(); loadOrders(); }} + /> + )} + {showIssueModal && ( + setShowIssueModal(false)} + onSuccess={loadCustomer} + /> + )} + {showPaymentModal && ( + setShowPaymentModal(false)} + onSuccess={loadCustomer} + /> + )}
); } diff --git a/frontend/src/crm/customers/CustomerDetail/FinancialsTab.jsx b/frontend/src/crm/customers/CustomerDetail/FinancialsTab.jsx new file mode 100644 index 0000000..216bbd5 --- /dev/null +++ b/frontend/src/crm/customers/CustomerDetail/FinancialsTab.jsx @@ -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 ( +
+
+

+ {editIndex !== null && editIndex !== undefined ? "Edit Transaction" : "Record Transaction"} +

+
+
+
Date
+ set("date", e.target.value)} style={inputStyle} /> +
+
+
Flow
+ +
+ {/* Payment type — hidden for invoices */} + {!isInvoice && ( +
+
Payment Type
+ +
+ )} + {/* Category — hidden for invoices */} + {!isInvoice && ( +
+
Category
+ +
+ )} +
+
Amount
+ set("amount", e.target.value)} style={inputStyle} placeholder="0.00" /> +
+
+
Currency
+ +
+
+
Invoice Ref
+ set("invoice_ref", e.target.value)} style={inputStyle} placeholder="e.g. INV-2026-001" /> +
+
+
Order Ref
+ +
+
+
Recorded By
+ set("recorded_by", e.target.value)} style={inputStyle} /> +
+
+
Note
+