Files
bellsystems-cp/frontend/src/crm/quotations/QuotationForm.jsx

1071 lines
45 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import api from "../../api/client";
// ── Styling helpers ────────────────────────────────────────────────────────────
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,
};
const cardStyle = {
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
borderWidth: 1,
borderStyle: "solid",
borderRadius: "var(--section-radius)",
padding: "var(--section-padding)",
marginBottom: 16,
};
const sectionTitle = {
fontSize: 11,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--text-secondary)",
marginBottom: 12,
};
function Field({ label, children, style }) {
return (
<div style={style}>
<label style={labelStyle}>{label}</label>
{children}
</div>
);
}
function TextInput({ value, onChange, placeholder, style, type = "text", readOnly }) {
return (
<input
type={type}
value={value ?? ""}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
readOnly={readOnly}
className="w-full px-3 py-1.5 text-sm rounded border"
style={{ ...inputStyle, ...style }}
/>
);
}
function NumberInput({ value, onChange, min = 0, step = "0.01", style }) {
return (
<input
type="number"
value={value ?? 0}
onChange={e => onChange(parseFloat(e.target.value) || 0)}
min={min}
step={step}
className="w-full px-3 py-1.5 text-sm rounded border text-right"
style={{ ...inputStyle, ...style }}
/>
);
}
function Select({ value, onChange, options, style }) {
return (
<select
value={value ?? ""}
onChange={e => onChange(e.target.value)}
className="w-full px-3 py-1.5 text-sm rounded border"
style={{ ...inputStyle, ...style }}
>
{options.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
);
}
// ── Monetary formatting ────────────────────────────────────────────────────────
function fmt(n) {
const f = parseFloat(n) || 0;
return f.toLocaleString("el-GR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " €";
}
// ── Item helpers ───────────────────────────────────────────────────────────────
const emptyItem = (sortOrder = 0) => ({
product_id: null,
description: "",
unit_type: "pcs",
unit_cost: 0,
discount_percent: 0,
quantity: 1,
vat_percent: 24,
sort_order: sortOrder,
});
function calcLineTotal(item) {
const cost = parseFloat(item.unit_cost) || 0;
const qty = parseFloat(item.quantity) || 0;
const disc = parseFloat(item.discount_percent) || 0;
return cost * qty * (1 - disc / 100);
}
function calcTotals(form) {
const items = form.items || [];
const itemsNet = items.reduce((sum, it) => sum + calcLineTotal(it), 0);
const itemsVat = items.reduce((sum, it) => {
const net = calcLineTotal(it);
return sum + net * ((parseFloat(it.vat_percent) || 0) / 100);
}, 0);
const shipGross = parseFloat(form.shipping_cost) || 0;
const shipDisc = parseFloat(form.shipping_cost_discount) || 0;
const shipNet = shipGross * (1 - shipDisc / 100);
const installGross = parseFloat(form.install_cost) || 0;
const installDisc = parseFloat(form.install_cost_discount) || 0;
const installNet = installGross * (1 - installDisc / 100);
const subtotal = itemsNet + shipNet + installNet;
const globalDiscPct = parseFloat(form.global_discount_percent) || 0;
const globalDiscAmt = subtotal * (globalDiscPct / 100);
const newSubtotal = subtotal - globalDiscAmt;
const vatAmt = subtotal > 0 ? itemsVat * (newSubtotal / subtotal) : 0;
const extras = parseFloat(form.extras_cost) || 0;
const finalTotal = newSubtotal + vatAmt + extras;
return {
subtotal_before_discount: subtotal,
global_discount_amount: globalDiscAmt,
new_subtotal: newSubtotal,
vat_amount: vatAmt,
final_total: finalTotal,
};
}
// ── Product Search Modal ───────────────────────────────────────────────────────
function ProductSearchModal({ onSelect, onClose }) {
const [search, setSearch] = useState("");
const [allProducts, setAllProducts] = useState([]);
const [loading, setLoading] = useState(true);
const searchRef = useRef(null);
// Load all products on open
useEffect(() => {
setLoading(true);
api.get("/crm/products")
.then(res => setAllProducts(res.products || []))
.catch(() => setAllProducts([]))
.finally(() => setLoading(false));
searchRef.current?.focus();
}, []);
useEffect(() => {
searchRef.current?.focus();
}, []);
const filtered = search.trim()
? allProducts.filter(p =>
p.name.toLowerCase().includes(search.toLowerCase()) ||
(p.sku || "").toLowerCase().includes(search.toLowerCase())
)
: allProducts;
return (
<div
style={{ position: "fixed", inset: 0, zIndex: 50, backgroundColor: "rgba(0,0,0,0.6)", display: "flex", alignItems: "center", justifyContent: "center" }}
onClick={onClose}
>
<div
style={{ backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)", borderRadius: 10, width: 520, display: "flex", flexDirection: "column", overflow: "hidden" }}
onClick={e => e.stopPropagation()}
>
<div style={{ padding: "16px 20px", borderBottom: "1px solid var(--border-primary)" }}>
<div style={{ fontSize: 14, fontWeight: 600, color: "var(--text-heading)", marginBottom: 10 }}>Add Product from Catalogue</div>
<input
ref={searchRef}
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search products..."
className="w-full px-3 py-2 text-sm rounded border"
style={inputStyle}
/>
</div>
<div style={{ overflowY: "auto", maxHeight: 300 }}>
{loading && <div style={{ padding: 20, color: "var(--text-muted)", fontSize: 13, textAlign: "center" }}>Loading...</div>}
{!loading && filtered.length === 0 && (
<div style={{ padding: 20, color: "var(--text-muted)", fontSize: 13, textAlign: "center" }}>
{search.trim() ? "No products match your search" : "No products in catalogue"}
</div>
)}
{filtered.map(p => (
<div
key={p.id}
onClick={() => onSelect(p)}
style={{ padding: "10px 20px", cursor: "pointer", borderBottom: "1px solid var(--border-secondary)" }}
onMouseEnter={e => e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"}
onMouseLeave={e => e.currentTarget.style.backgroundColor = ""}
>
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--text-primary)" }}>{p.name}</div>
<div style={{ fontSize: 11, color: "var(--text-muted)" }}>
{p.sku && <span style={{ marginRight: 8 }}>SKU: {p.sku}</span>}
<span style={{ color: "var(--accent)" }}>{fmt(p.price)}</span>
</div>
</div>
))}
</div>
<div style={{ padding: "10px 20px", borderTop: "1px solid var(--border-primary)", textAlign: "right" }}>
<button onClick={onClose} className="px-4 py-1.5 text-sm rounded" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}>
Cancel
</button>
</div>
</div>
</div>
);
}
// ── Status badge ───────────────────────────────────────────────────────────────
const STATUS_STYLES = {
draft: { bg: "var(--bg-card-hover)", color: "var(--text-secondary)" },
sent: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" },
accepted: { bg: "var(--success-bg)", color: "var(--success-text)" },
rejected: { bg: "var(--danger-bg)", color: "var(--danger-text)" },
};
const STATUS_OPTIONS = [
{ value: "draft", label: "Draft" },
{ value: "sent", label: "Sent" },
{ value: "accepted", label: "Accepted" },
{ value: "rejected", label: "Rejected" },
];
const ORDER_TYPE_OPTIONS = [
{ value: "", label: "— Select —" },
{ value: "Shipping", label: "Shipping" },
{ value: "Pick-Up", label: "Pick-Up" },
{ value: "Install On-Site", label: "Install On-Site" },
{ value: "Other", label: "Other" },
];
const SHIPPING_METHOD_OPTIONS = [
{ value: "", label: "— Select —" },
{ value: "Courier", label: "Courier" },
{ value: "Transport", label: "Transport" },
{ value: "Arranged with Client", label: "Arranged with Client" },
];
const UNIT_TYPE_OPTIONS = [
{ value: "pcs", label: "pcs" },
{ value: "kg", label: "kg" },
{ value: "m", label: "m" },
];
// Grid template for items table (Description | Unit Cost | Disc% | Qty | Unit | VAT% | Line Total | X)
const ITEM_GRID = "3fr 90px 65px 65px 70px 55px 90px 32px";
// Grid for special rows (Shipping/Install): Description | Cost | (no disc) | spacers... | Line Total | X
const SPECIAL_GRID = "3fr 90px 90px 32px";
// ── Main Form ─────────────────────────────────────────────────────────────────
export default function QuotationForm() {
const { id } = useParams();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const isEdit = Boolean(id);
const [customer, setCustomer] = useState(null);
const [form, setForm] = useState({
language: "en",
title: "",
subtitle: "",
customer_id: searchParams.get("customer_id") || "",
order_type: "",
shipping_method: "",
estimated_shipping_date: "",
global_discount_label: "",
global_discount_percent: 0,
shipping_cost: 0,
shipping_cost_discount: 0,
install_cost: 0,
install_cost_discount: 0,
extras_label: "",
extras_cost: 0,
comments: [],
items: [],
status: "draft",
// Client overrides
client_org: "",
client_name: "",
client_location: "",
client_phone: "",
client_email: "",
});
const [quickNotes, setQuickNotes] = useState({
payment_advance: { enabled: false, percent: "30" },
lead_time: { enabled: false, days: "7" },
backup_relays: { enabled: false, count: "2" },
});
const [quotationNumber, setQuotationNumber] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const [showProductModal, setShowProductModal] = useState(false);
const [showShipping, setShowShipping] = useState(false);
const [showInstall, setShowInstall] = useState(false);
const [clientPopulated, setClientPopulated] = useState(false);
// Load existing quotation on edit
useEffect(() => {
if (isEdit) {
api.get(`/crm/quotations/${id}`).then(q => {
setForm({
language: q.language || "en",
title: q.title || "",
subtitle: q.subtitle || "",
customer_id: q.customer_id,
order_type: q.order_type || "",
shipping_method: q.shipping_method || "",
estimated_shipping_date: q.estimated_shipping_date || "",
global_discount_label: q.global_discount_label || "",
global_discount_percent: q.global_discount_percent || 0,
shipping_cost: q.shipping_cost || 0,
shipping_cost_discount: q.shipping_cost_discount || 0,
install_cost: q.install_cost || 0,
install_cost_discount: q.install_cost_discount || 0,
extras_label: q.extras_label || "",
extras_cost: q.extras_cost || 0,
comments: q.comments || [],
items: (q.items || []).map(it => ({
product_id: it.product_id || null,
description: it.description || "",
unit_type: it.unit_type || "pcs",
unit_cost: it.unit_cost || 0,
discount_percent: it.discount_percent || 0,
quantity: it.quantity || 1,
vat_percent: it.vat_percent ?? 24,
sort_order: it.sort_order || 0,
})),
status: q.status || "draft",
client_org: q.client_org || "",
client_name: q.client_name || "",
client_location: q.client_location || "",
client_phone: q.client_phone || "",
client_email: q.client_email || "",
});
if (q.quick_notes && typeof q.quick_notes === "object") {
setQuickNotes(prev => ({
payment_advance: { ...prev.payment_advance, ...q.quick_notes.payment_advance },
lead_time: { ...prev.lead_time, ...q.quick_notes.lead_time },
backup_relays: { ...prev.backup_relays, ...q.quick_notes.backup_relays },
}));
}
setQuotationNumber(q.quotation_number);
if (q.shipping_cost > 0) setShowShipping(true);
if (q.install_cost > 0) setShowInstall(true);
setClientPopulated(true);
}).catch(() => setError("Failed to load quotation"));
} else {
api.get("/crm/quotations/next-number").then(r => setQuotationNumber(r.next_number)).catch(() => {});
}
}, [id, isEdit]);
// Load customer info; pre-populate client fields for new quotations
useEffect(() => {
const cid = form.customer_id;
if (!cid) return;
api.get(`/crm/customers/${cid}`).then(c => {
setCustomer(c);
// Only pre-populate on new quotations (not edit mode where saved values take precedence)
if (!clientPopulated) {
const name = [c.title, c.name, c.surname].filter(Boolean).join(" ");
const location = [c.location?.address, c.location?.city, c.location?.region, c.location?.country].filter(Boolean).join(", ");
const phone = (c.contacts || []).find(ct => ct.type === "phone")?.value || "";
const email = (c.contacts || []).find(ct => ct.type === "email")?.value || "";
setForm(f => ({
...f,
client_org: c.organization || "",
client_name: name,
client_location: location,
client_phone: phone,
client_email: email,
}));
setClientPopulated(true);
}
}).catch(() => setCustomer(null));
}, [form.customer_id, clientPopulated]);
const setField = useCallback((key, value) => {
setForm(f => ({ ...f, [key]: value }));
}, []);
// Items management
const addBlankItem = useCallback(() => {
setForm(f => ({ ...f, items: [...f.items, emptyItem(f.items.length)] }));
}, []);
const removeItem = useCallback((idx) => {
setForm(f => ({ ...f, items: f.items.filter((_, i) => i !== idx) }));
}, []);
const setItemField = useCallback((idx, key, value) => {
setForm(f => ({
...f,
items: f.items.map((it, i) => i === idx ? { ...it, [key]: value } : it),
}));
}, []);
const addProductFromCatalogue = useCallback((product) => {
setForm(f => ({
...f,
items: [
...f.items,
{
product_id: product.id,
description: product.name,
unit_type: "pcs",
unit_cost: product.price || 0,
discount_percent: 0,
quantity: 1,
vat_percent: 24,
sort_order: f.items.length,
},
],
}));
setShowProductModal(false);
}, []);
// Comments management
const addComment = useCallback(() => {
setForm(f => ({ ...f, comments: [...f.comments, ""] }));
}, []);
const setComment = useCallback((idx, value) => {
setForm(f => ({ ...f, comments: f.comments.map((c, i) => i === idx ? value : c) }));
}, []);
const removeComment = useCallback((idx) => {
setForm(f => ({ ...f, comments: f.comments.filter((_, i) => i !== idx) }));
}, []);
// Quick notes helpers
const setQuickNote = useCallback((key, field, value) => {
setQuickNotes(prev => ({
...prev,
[key]: { ...prev[key], [field]: value },
}));
}, []);
// Live totals
const totals = useMemo(() => calcTotals(form), [form]);
// Save handlers
async function handleSave(generatePdf = false) {
setSaving(true);
setError(null);
try {
const payload = {
customer_id: form.customer_id,
title: form.title || null,
subtitle: form.subtitle || null,
language: form.language,
order_type: form.order_type || null,
shipping_method: form.shipping_method || null,
estimated_shipping_date: form.estimated_shipping_date || null,
global_discount_label: form.global_discount_label || null,
global_discount_percent: parseFloat(form.global_discount_percent) || 0,
shipping_cost: showShipping ? (parseFloat(form.shipping_cost) || 0) : 0,
shipping_cost_discount: showShipping ? (parseFloat(form.shipping_cost_discount) || 0) : 0,
install_cost: showInstall ? (parseFloat(form.install_cost) || 0) : 0,
install_cost_discount: showInstall ? (parseFloat(form.install_cost_discount) || 0) : 0,
extras_label: form.extras_label || null,
extras_cost: parseFloat(form.extras_cost) || 0,
comments: form.comments.filter(c => c.trim()),
quick_notes: quickNotes,
items: form.items.map((it, i) => ({
product_id: it.product_id || null,
description: it.description || null,
unit_type: it.unit_type || "pcs",
unit_cost: parseFloat(it.unit_cost) || 0,
discount_percent: parseFloat(it.discount_percent) || 0,
quantity: parseFloat(it.quantity) || 1,
vat_percent: parseFloat(it.vat_percent) ?? 24,
sort_order: i,
})),
client_org: form.client_org || null,
client_name: form.client_name || null,
client_location: form.client_location || null,
client_phone: form.client_phone || null,
client_email: form.client_email || null,
};
const pdfParam = generatePdf ? "?generate_pdf=true" : "";
let result;
if (isEdit) {
const updatePayload = { ...payload, status: form.status };
result = await api.put(`/crm/quotations/${id}${pdfParam}`, updatePayload);
} else {
result = await api.post(`/crm/quotations${pdfParam}`, payload);
}
const customerId = result.customer_id;
navigate(`/crm/customers/${customerId}`, { state: { tab: "Quotations" } });
} catch (e) {
setError(e.message || "Save failed");
} finally {
setSaving(false);
}
}
if (error && !form.customer_id) {
return (
<div style={{ padding: 40, textAlign: "center", color: "var(--danger-text)" }}>{error}</div>
);
}
const hasSpecialRows = showShipping || showInstall;
return (
<div style={{ maxWidth: 1400, margin: "0 auto", padding: "24px 16px 100px" }}>
{/* Page Header */}
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 20 }}>
<button
onClick={() => navigate(-1)}
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-secondary)", fontSize: 20, padding: 0, lineHeight: 1 }}
>
</button>
<div>
<h1 style={{ fontSize: 18, fontWeight: 700, color: "var(--text-heading)" }}>
{isEdit ? `Edit Quotation ${quotationNumber}` : "New Quotation"}
</h1>
{customer && (
<div style={{ fontSize: 12, color: "var(--text-muted)", marginTop: 2 }}>
{customer.organization || [customer.title, customer.name, customer.surname].filter(Boolean).join(" ")}
</div>
)}
</div>
</div>
{error && (
<div style={{ marginBottom: 12, padding: "10px 14px", backgroundColor: "var(--danger-bg)", color: "var(--danger-text)", borderRadius: 6, fontSize: 13 }}>
{error}
</div>
)}
{/* 2-column layout: 40% left, 60% right */}
<div style={{ display: "flex", gap: 20, alignItems: "flex-start" }}>
{/* LEFT COLUMN — 40% */}
<div style={{ flex: "0 0 40%", minWidth: 0 }}>
{/* ① Header */}
<div style={cardStyle}>
<div style={sectionTitle}>Header</div>
{/* Row 1: Language + Quotation# */}
<div style={{ display: "flex", gap: 12, marginBottom: 12, alignItems: "flex-end" }}>
<div>
<div style={labelStyle}>Language</div>
<div style={{ display: "flex", gap: 4 }}>
{["en", "gr"].map(lang => (
<button
key={lang}
onClick={() => setField("language", lang)}
style={{
padding: "6px 12px", fontSize: 12, fontWeight: 600,
borderRadius: 6, border: "1px solid var(--border-primary)", cursor: "pointer",
backgroundColor: form.language === lang ? "var(--accent)" : "var(--bg-input)",
color: form.language === lang ? "#fff" : "var(--text-secondary)",
}}
>
{lang === "en" ? "🇬🇧 EN" : "🇬🇷 GR"}
</button>
))}
</div>
</div>
<Field label="Quotation #" style={{ flex: 1 }}>
<TextInput
value={quotationNumber}
onChange={setQuotationNumber}
style={{ fontFamily: "monospace", fontWeight: 600 }}
readOnly
/>
</Field>
</div>
{/* Row 2: Title */}
<Field label="Title" style={{ marginBottom: 12 }}>
<TextInput value={form.title} onChange={v => setField("title", v)} placeholder="Quotation title..." />
</Field>
{/* Row 3: Subtitle */}
<Field label="Subtitle">
<TextInput value={form.subtitle} onChange={v => setField("subtitle", v)} placeholder="Optional subtitle..." />
</Field>
</div>
{/* ② Client Info */}
<div style={cardStyle}>
<div style={sectionTitle}>Client Info</div>
<p style={{ fontSize: 11, color: "var(--text-muted)", marginBottom: 10, marginTop: -6, fontStyle: "italic" }}>
Editable for this quotation only customer record is not affected.
</p>
{/* Row 1: Org + Phone */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 10 }}>
<Field label="Organization">
<TextInput value={form.client_org} onChange={v => setField("client_org", v)} placeholder="Company name..." />
</Field>
<Field label="Phone">
<TextInput value={form.client_phone} onChange={v => setField("client_phone", v)} placeholder="+30..." />
</Field>
</div>
{/* Row 2: Name + Email */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 10 }}>
<Field label="Name">
<TextInput value={form.client_name} onChange={v => setField("client_name", v)} placeholder="Full name..." />
</Field>
<Field label="Email">
<TextInput value={form.client_email} onChange={v => setField("client_email", v)} placeholder="email@..." />
</Field>
</div>
{/* Row 3: Location */}
<Field label="Location">
<TextInput value={form.client_location} onChange={v => setField("client_location", v)} placeholder="City, region, country..." />
</Field>
</div>
{/* ③ Order Details */}
<div style={cardStyle}>
<div style={sectionTitle}>Order Details</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12, marginBottom: 12 }}>
<Field label="Order Type">
<Select value={form.order_type} onChange={v => setField("order_type", v)} options={ORDER_TYPE_OPTIONS} />
</Field>
<Field label="Shipping Method">
<Select value={form.shipping_method} onChange={v => setField("shipping_method", v)} options={SHIPPING_METHOD_OPTIONS} />
</Field>
</div>
<Field label="Estimated Delivery Date">
<TextInput type="date" value={form.estimated_shipping_date} onChange={v => setField("estimated_shipping_date", v)} />
</Field>
</div>
</div>{/* end LEFT COLUMN */}
{/* RIGHT COLUMN — 60% */}
<div style={{ flex: 1, minWidth: 0 }}>
{/* ⑤ Items */}
<div style={cardStyle}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}>
<div style={sectionTitle}>Items</div>
<div style={{ display: "flex", gap: 8 }}>
<button
onClick={() => setShowProductModal(true)}
style={{ padding: "5px 12px", fontSize: 12, borderRadius: 6, border: "1px solid var(--accent)", cursor: "pointer", backgroundColor: "transparent", color: "var(--accent)", fontWeight: 500 }}
>
+ From Catalogue
</button>
<button
onClick={addBlankItem}
style={{ padding: "5px 12px", fontSize: 12, borderRadius: 6, border: "1px solid var(--border-primary)", cursor: "pointer", backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
+ Blank Row
</button>
</div>
</div>
{/* Table header */}
{(form.items.length > 0 || hasSpecialRows) && (
<div style={{ display: "grid", gridTemplateColumns: ITEM_GRID, gap: 6, marginBottom: 6, padding: "0 4px" }}>
{["Description", "Unit Cost €", "Disc %", "Qty", "Unit", "VAT %", "Line Total", ""].map((h, i) => (
<div key={i} style={{ fontSize: 10, fontWeight: 600, color: "var(--text-muted)", textTransform: "uppercase", textAlign: i >= 1 && i <= 6 ? "right" : "left", paddingRight: i >= 1 && i <= 6 ? 4 : 0 }}>{h}</div>
))}
</div>
)}
{/* Items */}
{form.items.map((item, idx) => (
<div key={idx} style={{ display: "grid", gridTemplateColumns: ITEM_GRID, gap: 6, marginBottom: 4, alignItems: "center" }}>
<input
type="text"
value={item.description}
onChange={e => setItemField(idx, "description", e.target.value)}
placeholder="Description..."
className="px-2 py-1.5 text-sm rounded border"
style={inputStyle}
/>
<input
type="number"
value={item.unit_cost}
onChange={e => setItemField(idx, "unit_cost", parseFloat(e.target.value) || 0)}
min={0} step="0.01"
className="px-2 py-1.5 text-sm rounded border text-right"
style={inputStyle}
/>
<input
type="number"
value={item.discount_percent}
onChange={e => setItemField(idx, "discount_percent", parseFloat(e.target.value) || 0)}
min={0} max={100} step="0.5"
className="px-2 py-1.5 text-sm rounded border text-right"
style={inputStyle}
/>
<input
type="number"
value={item.quantity}
onChange={e => setItemField(idx, "quantity", parseFloat(e.target.value) || 0)}
min={0} step="1"
className="px-2 py-1.5 text-sm rounded border text-right"
style={inputStyle}
/>
<select
value={item.unit_type}
onChange={e => setItemField(idx, "unit_type", e.target.value)}
className="px-2 py-1.5 text-sm rounded border"
style={inputStyle}
>
{UNIT_TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<input
type="number"
value={item.vat_percent}
onChange={e => setItemField(idx, "vat_percent", parseFloat(e.target.value) ?? 0)}
min={0} max={100} step="1"
className="px-2 py-1.5 text-sm rounded border text-right"
style={inputStyle}
/>
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--text-primary)", textAlign: "right", padding: "0 4px" }}>
{fmt(calcLineTotal(item))}
</div>
<button
onClick={() => removeItem(idx)}
style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 28, height: 28, borderRadius: 4, border: "1px solid var(--border-primary)", backgroundColor: "transparent", cursor: "pointer", color: "var(--danger-text)", fontSize: 14 }}
>
×
</button>
</div>
))}
{form.items.length === 0 && !hasSpecialRows && (
<div style={{ padding: "24px", textAlign: "center", color: "var(--text-muted)", fontSize: 13, borderRadius: 6, border: "1px dashed var(--border-secondary)" }}>
No items yet add from catalogue or add a blank row
</div>
)}
{hasSpecialRows && form.items.length > 0 && (
<div style={{ height: 8, margin: "6px 0" }} />
)}
{/* Shipping row */}
{showShipping && (
<div style={{ display: "grid", gridTemplateColumns: SPECIAL_GRID, gap: 6, marginBottom: 4, alignItems: "center", backgroundColor: "var(--bg-card-hover)", borderRadius: 4, padding: "6px 4px" }}>
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--text-primary)", padding: "0 4px" }}>
Shipping
</div>
<input
type="number"
value={form.shipping_cost}
onChange={e => setField("shipping_cost", parseFloat(e.target.value) || 0)}
min={0} step="0.01"
placeholder="Cost"
className="px-2 py-1.5 text-sm rounded border text-right"
style={inputStyle}
/>
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--text-primary)", textAlign: "right", padding: "0 4px" }}>
{fmt(parseFloat(form.shipping_cost) || 0)}
</div>
<button onClick={() => { setShowShipping(false); setField("shipping_cost", 0); setField("shipping_cost_discount", 0); }} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 28, height: 28, borderRadius: 4, border: "1px solid var(--border-primary)", backgroundColor: "transparent", cursor: "pointer", color: "var(--danger-text)", fontSize: 14 }}>×</button>
</div>
)}
{/* Install row */}
{showInstall && (
<div style={{ display: "grid", gridTemplateColumns: SPECIAL_GRID, gap: 6, marginBottom: 4, alignItems: "center", backgroundColor: "var(--bg-card-hover)", borderRadius: 4, padding: "6px 4px" }}>
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--text-primary)", padding: "0 4px" }}>
Installation
</div>
<input
type="number"
value={form.install_cost}
onChange={e => setField("install_cost", parseFloat(e.target.value) || 0)}
min={0} step="0.01"
className="px-2 py-1.5 text-sm rounded border text-right"
style={inputStyle}
/>
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--text-primary)", textAlign: "right", padding: "0 4px" }}>
{fmt(parseFloat(form.install_cost) || 0)}
</div>
<button onClick={() => { setShowInstall(false); setField("install_cost", 0); setField("install_cost_discount", 0); }} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 28, height: 28, borderRadius: 4, border: "1px solid var(--border-primary)", backgroundColor: "transparent", cursor: "pointer", color: "var(--danger-text)", fontSize: 14 }}>×</button>
</div>
)}
{/* Add shipping/install buttons */}
<div style={{ marginTop: 12, display: "flex", gap: 8, flexWrap: "wrap" }}>
{!showShipping && (
<button
onClick={() => setShowShipping(true)}
style={{ padding: "5px 12px", fontSize: 12, borderRadius: 6, border: "1px solid var(--border-primary)", cursor: "pointer", backgroundColor: "transparent", color: "var(--text-muted)" }}
>
+ Shipping Cost
</button>
)}
{!showInstall && (
<button
onClick={() => setShowInstall(true)}
style={{ padding: "5px 12px", fontSize: 12, borderRadius: 6, border: "1px solid var(--border-primary)", cursor: "pointer", backgroundColor: "transparent", color: "var(--text-muted)" }}
>
+ Installation Cost
</button>
)}
</div>
</div>
{/* ⑥ Totals */}
<div style={cardStyle}>
<div style={sectionTitle}>Totals</div>
{/* Both rows use identical layout: label | wide text field | number field | symbol */}
{/* Row 1: Global Discount */}
<div style={{ display: "grid", gridTemplateColumns: "80px 1fr 100px 20px", alignItems: "center", gap: 8, marginBottom: 8, padding: "8px 10px", borderRadius: 6, border: "1px solid var(--border-secondary)", backgroundColor: "var(--bg-primary)" }}>
<div style={{ fontSize: 12, color: "var(--text-muted)", fontWeight: 500 }}>Discount</div>
<input
type="text"
value={form.global_discount_label}
onChange={e => setField("global_discount_label", e.target.value)}
placeholder="Discount label..."
className="px-2 py-1 text-xs rounded border"
style={inputStyle}
/>
<input
type="number"
value={form.global_discount_percent}
onChange={e => setField("global_discount_percent", parseFloat(e.target.value) || 0)}
min={0} max={100} step="0.5"
className="px-2 py-1 text-xs rounded border text-right"
style={inputStyle}
/>
<span style={{ fontSize: 12, color: "var(--text-muted)", textAlign: "center" }}>%</span>
</div>
{/* Row 2: Other Costs — same grid as above */}
<div style={{ display: "grid", gridTemplateColumns: "80px 1fr 100px 20px", alignItems: "center", gap: 8, marginBottom: 14, padding: "8px 10px", borderRadius: 6, border: "1px solid var(--border-secondary)", backgroundColor: "var(--bg-primary)" }}>
<div style={{ fontSize: 12, color: "var(--text-muted)", fontWeight: 500 }}>Other</div>
<input
type="text"
value={form.extras_label}
onChange={e => setField("extras_label", e.target.value)}
placeholder="other costs..."
className="px-2 py-1 text-xs rounded border"
style={inputStyle}
/>
<input
type="number"
value={form.extras_cost}
onChange={e => setField("extras_cost", parseFloat(e.target.value) || 0)}
min={0} step="0.01"
className="px-2 py-1 text-xs rounded border text-right"
style={inputStyle}
/>
<span style={{ fontSize: 12, color: "var(--text-muted)", textAlign: "center" }}></span>
</div>
{/* Row 3: 4 summary boxes */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 10 }}>
{[
{ label: "Subtotal excl. VAT", value: fmt(totals.subtotal_before_discount), muted: true },
{ label: "After Discount", value: fmt(totals.new_subtotal), muted: true },
{ label: "Total VAT", value: fmt(totals.vat_amount), muted: true },
{ label: "Total Due", value: fmt(totals.final_total), accent: true },
].map(({ label, value, accent }) => (
<div
key={label}
style={{
padding: "10px 12px",
borderRadius: 6,
border: `1px solid ${accent ? "var(--accent)" : "var(--border-secondary)"}`,
backgroundColor: accent ? "var(--bg-card-hover)" : "var(--bg-primary)",
textAlign: "center",
}}
>
<div style={{ fontSize: 10, fontWeight: 600, color: "var(--text-muted)", textTransform: "uppercase", letterSpacing: "0.04em", marginBottom: 4 }}>
{label}
</div>
<div style={{ fontSize: accent ? 16 : 14, fontWeight: accent ? 700 : 500, color: accent ? "var(--accent)" : "var(--text-heading)" }}>
{value}
</div>
</div>
))}
</div>
</div>
{/* ④ Notes / Comments — right column, below Totals */}
<div style={cardStyle}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 10 }}>
<div style={sectionTitle}>Notes / Comments</div>
<button
onClick={addComment}
style={{ padding: "4px 10px", fontSize: 12, borderRadius: 6, border: "1px solid var(--border-primary)", cursor: "pointer", backgroundColor: "transparent", color: "var(--text-muted)" }}
>
+ Add Note
</button>
</div>
{/* Quick Notes — left-aligned columns: checkbox | label (flex) | input | suffix */}
<div style={{ marginBottom: 14 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: "var(--text-muted)", textTransform: "uppercase", letterSpacing: "0.04em", marginBottom: 8 }}>Quick Notes</div>
{[
{
key: "payment_advance",
label: "Payment Advance",
fieldKey: "percent",
suffix: "%",
},
{
key: "lead_time",
label: "Lead Time",
fieldKey: "days",
suffix: "days",
},
{
key: "backup_relays",
label: "Backup Relays",
fieldKey: "count",
suffix: `relay${parseInt(quickNotes.backup_relays.count) === 1 ? "" : "s"}`,
},
].map(({ key, label, fieldKey, suffix }) => (
<div
key={key}
style={{
display: "grid",
gridTemplateColumns: "20px 1fr 80px 46px",
alignItems: "center",
gap: 10,
padding: "7px 10px", marginBottom: 6, borderRadius: 6,
border: "1px solid var(--border-secondary)",
backgroundColor: quickNotes[key].enabled ? "var(--bg-card-hover)" : "transparent",
}}
>
<input
type="checkbox"
checked={quickNotes[key].enabled}
onChange={e => setQuickNote(key, "enabled", e.target.checked)}
style={{ cursor: "pointer" }}
/>
<span style={{ fontSize: 13, color: quickNotes[key].enabled ? "var(--text-primary)" : "var(--text-muted)" }}>
{label}
</span>
<input
type="number"
value={quickNotes[key][fieldKey]}
onChange={e => setQuickNote(key, fieldKey, e.target.value)}
min={1} step={1}
disabled={!quickNotes[key].enabled}
className="px-2 py-1 text-sm rounded border"
style={{ ...inputStyle, opacity: quickNotes[key].enabled ? 1 : 0.4, width: "100%" }}
/>
<span style={{ fontSize: 12, color: "var(--text-muted)", whiteSpace: "nowrap" }}>{suffix}</span>
</div>
))}
</div>
{/* Dynamic comments */}
{form.comments.length === 0 && (
<div style={{ fontSize: 12, color: "var(--text-muted)", fontStyle: "italic" }}>No notes. Click "Add Note" to add one.</div>
)}
{form.comments.map((comment, idx) => (
<div key={idx} style={{ display: "flex", gap: 8, marginBottom: 8, alignItems: "flex-start" }}>
<textarea
value={comment}
onChange={e => setComment(idx, e.target.value)}
rows={2}
className="flex-1 px-3 py-2 text-sm rounded border"
style={{ ...inputStyle, resize: "vertical" }}
placeholder="Note text..."
/>
<button
onClick={() => removeComment(idx)}
style={{ marginTop: 4, display: "flex", alignItems: "center", justifyContent: "center", width: 28, height: 28, borderRadius: 4, border: "1px solid var(--border-primary)", backgroundColor: "transparent", cursor: "pointer", color: "var(--danger-text)", fontSize: 14, flexShrink: 0 }}
>
×
</button>
</div>
))}
</div>
</div>{/* end RIGHT COLUMN */}
</div>{/* end 2-col layout */}
{/* ⑦ Floating Action Bar */}
<div style={{
position: "fixed",
bottom: 20,
left: "50%",
transform: "translateX(-50%)",
zIndex: 100,
backgroundColor: "var(--bg-card)",
border: "1px solid var(--border-primary)",
borderRadius: 12,
padding: "12px 20px",
display: "flex",
alignItems: "center",
gap: 16,
boxShadow: "0 0 0 1px rgba(0,0,0,0.4), 0 8px 32px rgba(0,0,0,0.7), 0 0 24px rgba(0,0,0,0.5)",
}}>
{isEdit && (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 12, color: "var(--text-muted)" }}>Status:</span>
<select
value={form.status}
onChange={e => setField("status", e.target.value)}
className="text-xs rounded border px-2 py-1"
style={{ ...inputStyle, minWidth: 90 }}
>
{STATUS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
)}
<button
onClick={() => navigate(-1)}
disabled={saving}
style={{ padding: "8px 16px", fontSize: 13, borderRadius: 6, border: "1px solid var(--border-primary)", cursor: "pointer", backgroundColor: "transparent", color: "var(--text-secondary)" }}
>
Cancel
</button>
<button
onClick={() => handleSave(false)}
disabled={saving}
style={{ padding: "8px 18px", fontSize: 13, fontWeight: 600, borderRadius: 6, border: "none", cursor: "pointer", backgroundColor: "var(--btn-neutral)", color: "#fff" }}
>
{saving ? "Saving..." : "Save as Draft"}
</button>
<button
onClick={() => handleSave(true)}
disabled={saving}
style={{ padding: "8px 18px", fontSize: 13, fontWeight: 600, borderRadius: 6, border: "none", cursor: "pointer", backgroundColor: "var(--accent)", color: "#fff" }}
>
{saving ? "Generating..." : "Generate & Save PDF"}
</button>
</div>
{showProductModal && (
<ProductSearchModal
onSelect={addProductFromCatalogue}
onClose={() => setShowProductModal(false)}
/>
)}
</div>
);
}