update: Major Overhaul to all subsystems

This commit is contained in:
2026-03-07 11:32:18 +02:00
parent 810e81b323
commit c62188fda6
107 changed files with 20414 additions and 929 deletions

View 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>
);
}