feat: NAS storage for invoices/offers, code cleanup, date/time fixes

- NAS storage for created invoices (PDF via puppeteer), received invoices,
  and offers with auto-save on create/edit
- Deterministic file paths derived from DB fields (no file_path column needed)
- Separate NAS mount points: NAS_FINANCIALS_PATH, NAS_OFFERS_PATH
- Invoice language field (cs/en) stored per invoice, replaces lang modal
- Invoices list filtered by month/year matching KPI card selection
- Centralized date helpers (src/utils/date.ts) replacing all .toISOString()
  calls that returned UTC instead of local time
- Attendance project switching uses exact time (not rounded)
- Comment cleanup: removed ~100 unnecessary/Czech comments
- Removed as-any casts in orders and attendance
- Prisma migrations: add invoice language, drop received_invoices BLOB columns

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-03-26 10:36:39 +01:00
parent 0317ba3168
commit baceb88347
60 changed files with 2475 additions and 563 deletions

View File

@@ -345,7 +345,6 @@ export default function OfferDetail() {
form.valid_until &&
new Date(form.valid_until) < new Date(new Date().toDateString());
// Load data
const fetchDetail = useCallback(async () => {
if (!id) return;
try {
@@ -473,7 +472,6 @@ export default function OfferDetail() {
}
}, [showCustomerDropdown]);
// Fetch next quotation number for new offers
useEffect(() => {
if (isEdit) return;
const fetchNextNumber = async () => {
@@ -584,7 +582,6 @@ export default function OfferDetail() {
setItems((prev) => prev.filter((_, i) => i !== index));
};
// Totals
const subtotal = items.reduce((sum, item) => {
if (item.is_included_in_total) {
return (
@@ -624,6 +621,12 @@ export default function OfferDetail() {
});
const result = await response.json();
if (result.success) {
const offerId = isEdit ? id : result.data?.id;
if (offerId) {
await apiFetch(`${API_BASE}/offers-pdf/${offerId}?save=1`).catch(
() => {},
);
}
alert.success(
result.message ||
(isEdit ? "Nabídka byla aktualizována" : "Nabídka byla vytvořena"),
@@ -736,22 +739,16 @@ export default function OfferDetail() {
if (!isEdit || pdfLoading) return;
setPdfLoading(true);
try {
const response = await apiFetch(`${API_BASE}/offers-pdf/${id}`);
const response = await apiFetch(`${API_BASE}/offers/${id}/file`);
if (response.status === 401) return;
if (!response.ok) {
alert.error("Nepodařilo se vygenerovat PDF");
alert.error("PDF soubor nenalezen — uložte nabídku pro vygenerování");
return;
}
const html = await response.text();
const w = window.open("", "_blank");
if (w) {
w.document.open();
w.document.write(html);
w.document.close();
w.onload = () => w.print();
} else {
alert.error("Prohlížeč zablokoval vyskakovací okno");
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
window.open(url, "_blank");
setTimeout(() => URL.revokeObjectURL(url), 60000);
} catch {
alert.error("Chyba při generování PDF");
} finally {
@@ -858,29 +855,29 @@ export default function OfferDetail() {
<button
onClick={handlePdf}
className="admin-btn admin-btn-secondary"
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.4rem",
}}
disabled={pdfLoading}
>
{pdfLoading ? (
<>
<div className="admin-spinner admin-spinner-sm" />
PDF...
</>
<div className="admin-spinner admin-spinner-sm" />
) : (
<>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
PDF
</>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
)}
Zobrazit nabídku
</button>
)}
{isEdit &&