1071 lines
45 KiB
JavaScript
1071 lines
45 KiB
JavaScript
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>
|
||
);
|
||
}
|