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 (
{children}
); } function TextInput({ value, onChange, placeholder, style, type = "text", readOnly }) { return ( 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 ( 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 ( ); } // ── 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 (
e.stopPropagation()} >
Add Product from Catalogue
setSearch(e.target.value)} placeholder="Search products..." className="w-full px-3 py-2 text-sm rounded border" style={inputStyle} />
{loading &&
Loading...
} {!loading && filtered.length === 0 && (
{search.trim() ? "No products match your search" : "No products in catalogue"}
)} {filtered.map(p => (
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 = ""} >
{p.name}
{p.sku && SKU: {p.sku}} {fmt(p.price)}
))}
); } // ── 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 (
{error}
); } const hasSpecialRows = showShipping || showInstall; return (
{/* Page Header */}

{isEdit ? `Edit Quotation ${quotationNumber}` : "New Quotation"}

{customer && (
{customer.organization || [customer.title, customer.name, customer.surname].filter(Boolean).join(" ")}
)}
{error && (
{error}
)} {/* 2-column layout: 40% left, 60% right */}
{/* LEFT COLUMN — 40% */}
{/* ① Header */}
Header
{/* Row 1: Language + Quotation# */}
Language
{["en", "gr"].map(lang => ( ))}
{/* Row 2: Title */} setField("title", v)} placeholder="Quotation title..." /> {/* Row 3: Subtitle */} setField("subtitle", v)} placeholder="Optional subtitle..." />
{/* ② Client Info */}
Client Info

Editable for this quotation only — customer record is not affected.

{/* Row 1: Org + Phone */}
setField("client_org", v)} placeholder="Company name..." /> setField("client_phone", v)} placeholder="+30..." />
{/* Row 2: Name + Email */}
setField("client_name", v)} placeholder="Full name..." /> setField("client_email", v)} placeholder="email@..." />
{/* Row 3: Location */} setField("client_location", v)} placeholder="City, region, country..." />
{/* ③ Order Details */}
Order Details
setField("shipping_method", v)} options={SHIPPING_METHOD_OPTIONS} />
setField("estimated_shipping_date", v)} />
{/* end LEFT COLUMN */} {/* RIGHT COLUMN — 60% */}
{/* ⑤ Items */}
Items
{/* Table header */} {(form.items.length > 0 || hasSpecialRows) && (
{["Description", "Unit Cost €", "Disc %", "Qty", "Unit", "VAT %", "Line Total", ""].map((h, i) => (
= 1 && i <= 6 ? "right" : "left", paddingRight: i >= 1 && i <= 6 ? 4 : 0 }}>{h}
))}
)} {/* Items */} {form.items.map((item, idx) => (
setItemField(idx, "description", e.target.value)} placeholder="Description..." className="px-2 py-1.5 text-sm rounded border" style={inputStyle} /> 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} /> 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} /> 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} /> 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} />
{fmt(calcLineTotal(item))}
))} {form.items.length === 0 && !hasSpecialRows && (
No items yet — add from catalogue or add a blank row
)} {hasSpecialRows && form.items.length > 0 && (
)} {/* Shipping row */} {showShipping && (
Shipping
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} />
{fmt(parseFloat(form.shipping_cost) || 0)}
)} {/* Install row */} {showInstall && (
Installation
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} />
{fmt(parseFloat(form.install_cost) || 0)}
)} {/* Add shipping/install buttons */}
{!showShipping && ( )} {!showInstall && ( )}
{/* ⑥ Totals */}
Totals
{/* Both rows use identical layout: label | wide text field | number field | symbol */} {/* Row 1: Global Discount */}
Discount
setField("global_discount_label", e.target.value)} placeholder="Discount label..." className="px-2 py-1 text-xs rounded border" style={inputStyle} /> 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} /> %
{/* Row 2: Other Costs — same grid as above */}
Other
setField("extras_label", e.target.value)} placeholder="other costs..." className="px-2 py-1 text-xs rounded border" style={inputStyle} /> 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} />
{/* Row 3: 4 summary boxes */}
{[ { 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 }) => (
{label}
{value}
))}
{/* ④ Notes / Comments — right column, below Totals */}
Notes / Comments
{/* Quick Notes — left-aligned columns: checkbox | label (flex) | input | suffix */}
Quick Notes
{[ { 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 }) => (
setQuickNote(key, "enabled", e.target.checked)} style={{ cursor: "pointer" }} /> {label} 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%" }} /> {suffix}
))}
{/* Dynamic comments */} {form.comments.length === 0 && (
No notes. Click "Add Note" to add one.
)} {form.comments.map((comment, idx) => (