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

439 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, 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>
);
}