439 lines
17 KiB
JavaScript
439 lines
17 KiB
JavaScript
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>
|
||
);
|
||
}
|