update: Major Overhaul to all subsystems
This commit is contained in:
438
frontend/src/crm/quotations/QuotationList.jsx
Normal file
438
frontend/src/crm/quotations/QuotationList.jsx
Normal file
@@ -0,0 +1,438 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import api from "../../api/client";
|
||||
|
||||
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)" },
|
||||
};
|
||||
|
||||
function fmt(n) {
|
||||
const f = parseFloat(n) || 0;
|
||||
return f.toLocaleString("el-GR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " €";
|
||||
}
|
||||
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return "—";
|
||||
return iso.slice(0, 10);
|
||||
}
|
||||
|
||||
// ── PDF thumbnail via PDF.js ──────────────────────────────────────────────────
|
||||
function loadPdfJs() {
|
||||
return new Promise((res, rej) => {
|
||||
if (window.pdfjsLib) { res(); return; }
|
||||
if (document.getElementById("__pdfjs2__")) {
|
||||
// Script already injected — wait for it
|
||||
const check = setInterval(() => {
|
||||
if (window.pdfjsLib) { clearInterval(check); res(); }
|
||||
}, 50);
|
||||
return;
|
||||
}
|
||||
const s = document.createElement("script");
|
||||
s.id = "__pdfjs2__";
|
||||
s.src = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js";
|
||||
s.onload = () => {
|
||||
window.pdfjsLib.GlobalWorkerOptions.workerSrc =
|
||||
"https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
|
||||
res();
|
||||
};
|
||||
s.onerror = rej;
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
function PdfThumbnail({ quotationId, onClick }) {
|
||||
const canvasRef = useRef(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
await loadPdfJs();
|
||||
const token = localStorage.getItem("access_token");
|
||||
const url = `/api/crm/quotations/${quotationId}/pdf`;
|
||||
const loadingTask = window.pdfjsLib.getDocument({
|
||||
url,
|
||||
httpHeaders: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
const pdf = await loadingTask.promise;
|
||||
if (cancelled) return;
|
||||
const page = await pdf.getPage(1);
|
||||
if (cancelled) return;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
const scale = Math.min(72 / viewport.width, 96 / viewport.height);
|
||||
const scaled = page.getViewport({ scale });
|
||||
canvas.width = scaled.width;
|
||||
canvas.height = scaled.height;
|
||||
await page.render({ canvasContext: canvas.getContext("2d"), viewport: scaled }).promise;
|
||||
} catch {
|
||||
if (!cancelled) setFailed(true);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [quotationId]);
|
||||
|
||||
const style = {
|
||||
width: 72,
|
||||
height: 96,
|
||||
borderRadius: 4,
|
||||
overflow: "hidden",
|
||||
flexShrink: 0,
|
||||
cursor: "pointer",
|
||||
border: "1px solid var(--border-primary)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "var(--bg-primary)",
|
||||
};
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<div style={style} onClick={onClick} title="Open PDF">
|
||||
<span style={{ fontSize: 28 }}>📑</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={style} onClick={onClick} title="Open PDF">
|
||||
<canvas ref={canvasRef} style={{ width: "100%", height: "100%", objectFit: "contain", display: "block" }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DraftThumbnail() {
|
||||
return (
|
||||
<div style={{
|
||||
width: 72, height: 96, borderRadius: 4, flexShrink: 0,
|
||||
border: "1px dashed var(--border-primary)",
|
||||
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
|
||||
backgroundColor: "var(--bg-primary)", gap: 4,
|
||||
}}>
|
||||
<span style={{ fontSize: 18 }}>📄</span>
|
||||
<span style={{ fontSize: 9, fontWeight: 700, color: "var(--text-muted)", letterSpacing: "0.06em", textTransform: "uppercase" }}>
|
||||
DRAFT
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PdfViewModal({ quotationId, quotationNumber, onClose }) {
|
||||
const [blobUrl, setBlobUrl] = useState(null);
|
||||
const [loadingPdf, setLoadingPdf] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let objectUrl = null;
|
||||
const token = localStorage.getItem("access_token");
|
||||
fetch(`/api/crm/quotations/${quotationId}/pdf`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error("Failed to load PDF");
|
||||
return r.blob();
|
||||
})
|
||||
.then(blob => {
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setBlobUrl(objectUrl);
|
||||
})
|
||||
.catch(() => setError(true))
|
||||
.finally(() => setLoadingPdf(false));
|
||||
return () => { if (objectUrl) URL.revokeObjectURL(objectUrl); };
|
||||
}, [quotationId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: "fixed", inset: 0, zIndex: 1000,
|
||||
backgroundColor: "rgba(0,0,0,0.88)",
|
||||
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
backgroundColor: "var(--bg-card)", borderRadius: 10, overflow: "hidden",
|
||||
width: "80vw", height: "88vh", display: "flex", flexDirection: "column",
|
||||
boxShadow: "0 24px 64px rgba(0,0,0,0.5)",
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
padding: "10px 16px", borderBottom: "1px solid var(--border-primary)", flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: "var(--text-heading)" }}>{quotationNumber}</span>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{blobUrl && (
|
||||
<a
|
||||
href={blobUrl}
|
||||
download={`${quotationNumber}.pdf`}
|
||||
style={{ padding: "4px 12px", fontSize: 12, borderRadius: 6, textDecoration: "none", backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{ background: "none", border: "none", color: "var(--text-muted)", fontSize: 22, cursor: "pointer", lineHeight: 1, padding: "0 4px" }}
|
||||
>×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
{loadingPdf && <span style={{ color: "var(--text-muted)", fontSize: 13 }}>Loading PDF...</span>}
|
||||
{error && <span style={{ color: "var(--danger-text)", fontSize: 13 }}>Failed to load PDF.</span>}
|
||||
{blobUrl && (
|
||||
<iframe
|
||||
src={blobUrl}
|
||||
style={{ width: "100%", height: "100%", border: "none" }}
|
||||
title={quotationNumber}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function QuotationList({ customerId, onSend }) {
|
||||
const navigate = useNavigate();
|
||||
const [quotations, setQuotations] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleting, setDeleting] = useState(null);
|
||||
const [regenerating, setRegenerating] = useState(null);
|
||||
const [pdfPreview, setPdfPreview] = useState(null); // { id, number }
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!customerId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get(`/crm/quotations/customer/${customerId}`);
|
||||
setQuotations(res.quotations || []);
|
||||
} catch {
|
||||
setQuotations([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [customerId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
async function handleDelete(q) {
|
||||
if (!window.confirm(`Delete quotation ${q.quotation_number}? This cannot be undone.`)) return;
|
||||
setDeleting(q.id);
|
||||
try {
|
||||
await api.delete(`/crm/quotations/${q.id}`);
|
||||
setQuotations(prev => prev.filter(x => x.id !== q.id));
|
||||
} catch {
|
||||
alert("Failed to delete quotation");
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegenerate(q) {
|
||||
setRegenerating(q.id);
|
||||
try {
|
||||
const updated = await api.post(`/crm/quotations/${q.id}/regenerate-pdf`);
|
||||
setQuotations(prev => prev.map(x => x.id === updated.id ? {
|
||||
...x,
|
||||
nextcloud_pdf_url: updated.nextcloud_pdf_url,
|
||||
} : x));
|
||||
} catch {
|
||||
alert("PDF regeneration failed");
|
||||
} finally {
|
||||
setRegenerating(null);
|
||||
}
|
||||
}
|
||||
|
||||
function openPdfModal(q) {
|
||||
setPdfPreview({ id: q.id, number: q.quotation_number });
|
||||
}
|
||||
|
||||
// Grid columns: thumbnail | number | title | date | status | total | actions
|
||||
const GRID = "90px 120px minmax(0,1fr) 130px 130px 130px 120px";
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* PDF Preview Modal */}
|
||||
{pdfPreview && (
|
||||
<PdfViewModal
|
||||
quotationId={pdfPreview.id}
|
||||
quotationNumber={pdfPreview.number}
|
||||
onClose={() => setPdfPreview(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
|
||||
<h2 style={{ fontSize: 15, fontWeight: 600, color: "var(--text-heading)" }}>Quotations</h2>
|
||||
<button
|
||||
onClick={() => navigate(`/crm/quotations/new?customer_id=${customerId}`)}
|
||||
style={{
|
||||
padding: "7px 16px", fontSize: 13, fontWeight: 600, borderRadius: 6,
|
||||
border: "none", cursor: "pointer", backgroundColor: "var(--accent)", color: "#fff",
|
||||
}}
|
||||
>
|
||||
+ New Quotation
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div style={{ textAlign: "center", padding: 40, color: "var(--text-muted)", fontSize: 13 }}>Loading...</div>
|
||||
)}
|
||||
|
||||
{!loading && quotations.length === 0 && (
|
||||
<div style={{ textAlign: "center", padding: "40px 20px", backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)", borderRadius: 8 }}>
|
||||
<div style={{ fontSize: 32, marginBottom: 10 }}>📄</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: "var(--text-heading)", marginBottom: 6 }}>No quotations yet</div>
|
||||
<div style={{ fontSize: 13, color: "var(--text-muted)", marginBottom: 16 }}>
|
||||
Create a quotation to generate a professional PDF offer for this customer.
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate(`/crm/quotations/new?customer_id=${customerId}`)}
|
||||
style={{ padding: "8px 20px", fontSize: 13, fontWeight: 600, borderRadius: 6, border: "none", cursor: "pointer", backgroundColor: "var(--accent)", color: "#fff" }}
|
||||
>
|
||||
+ New Quotation
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && quotations.length > 0 && (
|
||||
<div style={{ borderRadius: 8, border: "1px solid var(--border-primary)", overflow: "hidden" }}>
|
||||
{/* Table header */}
|
||||
<div style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: GRID,
|
||||
backgroundColor: "var(--bg-card)",
|
||||
borderBottom: "1px solid var(--border-primary)",
|
||||
padding: "8px 16px",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
}}>
|
||||
<div />
|
||||
{[
|
||||
{ label: "Number", align: "left" },
|
||||
{ label: "Title", align: "left" },
|
||||
{ label: "Date", align: "center" },
|
||||
{ label: "Status", align: "center" },
|
||||
{ label: "Total", align: "right", paddingRight: 16 },
|
||||
{ label: "Actions", align: "center" },
|
||||
].map(({ label, align, paddingRight }) => (
|
||||
<div key={label} style={{ fontSize: 11, fontWeight: 600, color: "var(--text-muted)", textTransform: "uppercase", letterSpacing: "0.04em", textAlign: align, paddingRight }}>
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
{quotations.map(q => (
|
||||
<div
|
||||
key={q.id}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: GRID,
|
||||
gap: 12,
|
||||
padding: "12px 16px",
|
||||
borderBottom: "1px solid var(--border-secondary)",
|
||||
alignItems: "center",
|
||||
minHeight: 110,
|
||||
backgroundColor: "var(--bg-card)",
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"}
|
||||
onMouseLeave={e => e.currentTarget.style.backgroundColor = "var(--bg-card)"}
|
||||
>
|
||||
{/* Thumbnail — click opens modal if PDF exists */}
|
||||
<div>
|
||||
{q.nextcloud_pdf_url ? (
|
||||
<PdfThumbnail quotationId={q.id} onClick={() => openPdfModal(q)} />
|
||||
) : (
|
||||
<DraftThumbnail />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Number */}
|
||||
<div style={{ fontFamily: "monospace", fontSize: 13, fontWeight: 600, color: "var(--text-primary)" }}>
|
||||
{q.quotation_number}
|
||||
</div>
|
||||
|
||||
{/* Title + subtitle */}
|
||||
<div style={{ overflow: "hidden", paddingRight: 8 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 600, color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{q.title || <span style={{ color: "var(--text-muted)", fontStyle: "italic", fontWeight: 400 }}>Untitled</span>}
|
||||
</div>
|
||||
{q.subtitle && (
|
||||
<div style={{ fontSize: 12, color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", marginTop: 2 }}>
|
||||
{q.subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div style={{ fontSize: 12, color: "var(--text-secondary)", textAlign: "center" }}>
|
||||
{fmtDate(q.created_at)}
|
||||
</div>
|
||||
|
||||
{/* Status badge */}
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<span style={{
|
||||
display: "inline-block", padding: "2px 10px", borderRadius: 20,
|
||||
fontSize: 11, fontWeight: 600,
|
||||
backgroundColor: STATUS_STYLES[q.status]?.bg || "var(--bg-card-hover)",
|
||||
color: STATUS_STYLES[q.status]?.color || "var(--text-secondary)",
|
||||
}}>
|
||||
{q.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Total */}
|
||||
<div style={{ fontSize: 16, fontWeight: 600, color: "var(--success-text)", textAlign: "right", paddingRight: 16 }}>
|
||||
{fmt(q.final_total)}
|
||||
</div>
|
||||
|
||||
{/* Actions — Edit + Delete same width; Gen PDF if no PDF yet */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 5, alignItems: "stretch", paddingLeft: 25, paddingRight: 25 }}>
|
||||
{!q.nextcloud_pdf_url && (
|
||||
<button
|
||||
onClick={() => handleRegenerate(q)}
|
||||
disabled={regenerating === q.id}
|
||||
style={{ padding: "4px 10px", fontSize: 11, fontWeight: 500, borderRadius: 5, border: "1px solid var(--border-primary)", cursor: "pointer", backgroundColor: "transparent", color: "var(--text-muted)", whiteSpace: "nowrap", textAlign: "center" }}
|
||||
>
|
||||
{regenerating === q.id ? "..." : "Gen PDF"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => navigate(`/crm/quotations/${q.id}`)}
|
||||
style={{ padding: "4px 10px", fontSize: 11, fontWeight: 500, borderRadius: 5, border: "1px solid var(--border-primary)", cursor: "pointer", backgroundColor: "transparent", color: "var(--text-secondary)", whiteSpace: "nowrap", textAlign: "center" }}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onSend && onSend(q)}
|
||||
style={{ padding: "4px 10px", fontSize: 11, fontWeight: 500, borderRadius: 5, border: "1px solid var(--border-primary)", cursor: "pointer", backgroundColor: "transparent", color: "var(--accent)", whiteSpace: "nowrap", textAlign: "center" }}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleDelete(q)}
|
||||
disabled={deleting === q.id}
|
||||
style={{ padding: "4px 10px", fontSize: 11, fontWeight: 500, borderRadius: 5, border: "1px solid var(--border-primary)", cursor: "pointer", backgroundColor: "transparent", color: "var(--danger-text)", whiteSpace: "nowrap", textAlign: "center" }}
|
||||
>
|
||||
{deleting === q.id ? "..." : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user