update: CRM customers, orders, device detail, and status system changes

- CustomerList, CustomerForm, CustomerDetail: various updates
- Orders: removed OrderDetail and OrderForm, updated OrderList and index
- DeviceDetail: updates
- index.css: added new styles
- CRM_STATUS_SYSTEM_PLAN.md: new planning document
- Added customer-status assets and CustomerDetail subfolder

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-25 10:39:38 +02:00
parent fee686a9f3
commit 5d8ef96d4c
33 changed files with 3699 additions and 1455 deletions

View File

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