v1.6.8: remove offer exchange rate, add invoice item description, offer filters, table animations

- Remove exchange_rate and exchange_rate_date from quotations (schema, service, PDF, form)
- Add item_description field to invoice_items (schema, migration, service, form, PDF)
- Add offer status/customer/order filters with tab-based UI
- Clean up offer statuses to active/ordered/invalidated
- Allow invalidate action for non-ordered offers (not just expired)
- Add fade animations on offers and invoices table data changes

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-05-28 12:58:51 +02:00
parent 66b98e2765
commit 0330453ad6
20 changed files with 3126 additions and 3029 deletions

572
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "1.6.7", "version": "1.6.8",
"description": "", "description": "",
"main": "dist/server.js", "main": "dist/server.js",
"scripts": { "scripts": {
@@ -18,7 +18,6 @@
"db:studio": "prisma studio", "db:studio": "prisma studio",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"bones": "boneyard-js build http://localhost:3000",
"seed": "tsx prisma/seed.ts" "seed": "tsx prisma/seed.ts"
}, },
"prisma": { "prisma": {
@@ -42,7 +41,6 @@
"@tanstack/react-query": "^5.100.5", "@tanstack/react-query": "^5.100.5",
"@types/jsdom": "^28.0.1", "@types/jsdom": "^28.0.1",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"boneyard-js": "^1.8.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dompurify": "^3.3.3", "dompurify": "^3.3.3",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE `quotations` DROP COLUMN `exchange_rate`;
ALTER TABLE `quotations` DROP COLUMN `exchange_rate_date`;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `invoice_items` ADD COLUMN `item_description` TEXT NULL;

View File

@@ -153,8 +153,9 @@ model customers {
model invoice_items { model invoice_items {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
invoice_id Int invoice_id Int
description String? @db.VarChar(500) description String? @db.VarChar(500)
quantity Decimal? @default(1.000) @db.Decimal(12, 3) item_description String? @db.Text
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
unit String? @db.VarChar(20) unit String? @db.VarChar(20)
unit_price Decimal? @default(0.00) @db.Decimal(12, 2) unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
@@ -397,8 +398,6 @@ model quotations {
language String? @default("cs") @db.VarChar(5) language String? @default("cs") @db.VarChar(5)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
apply_vat Boolean? @default(true) apply_vat Boolean? @default(true)
exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
exchange_rate_date DateTime? @db.Date
order_id Int? order_id Int?
status String @default("active") @db.VarChar(20) status String @default("active") @db.VarChar(20)
scope_title String? @db.VarChar(500) scope_title String? @db.VarChar(500)

View File

@@ -34,7 +34,7 @@ export interface InvoiceStats {
export interface InvoiceItem { export interface InvoiceItem {
id?: number; id?: number;
description: string; description: string;
item_description?: string; item_description: string;
quantity: number; quantity: number;
unit: string; unit: string;
unit_price: number; unit_price: number;

View File

@@ -70,6 +70,9 @@ export const offerListOptions = (filters: {
order?: string; order?: string;
page?: number; page?: number;
perPage?: number; perPage?: number;
status?: string;
customer_id?: number;
has_order?: string;
}) => }) =>
queryOptions({ queryOptions({
queryKey: ["offers", "list", filters], queryKey: ["offers", "list", filters],
@@ -80,6 +83,10 @@ export const offerListOptions = (filters: {
if (filters.order) params.set("order", filters.order); if (filters.order) params.set("order", filters.order);
if (filters.page) params.set("page", String(filters.page)); if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage)); if (filters.perPage) params.set("per_page", String(filters.perPage));
if (filters.status) params.set("status", filters.status);
if (filters.customer_id)
params.set("customer_id", String(filters.customer_id));
if (filters.has_order) params.set("has_order", filters.has_order);
const qs = params.toString(); const qs = params.toString();
return paginatedJsonQuery(`/api/admin/offers${qs ? `?${qs}` : ""}`); return paginatedJsonQuery(`/api/admin/offers${qs ? `?${qs}` : ""}`);
}, },
@@ -127,7 +134,6 @@ export interface OfferDetailData {
language: string; language: string;
vat_rate: number; vat_rate: number;
apply_vat: boolean; apply_vat: boolean;
exchange_rate: string;
scope_title: string; scope_title: string;
scope_description: string; scope_description: string;
items?: OfferItemData[]; items?: OfferItemData[];

View File

@@ -627,3 +627,33 @@
margin-top: 0.2rem; margin-top: 0.2rem;
opacity: 0.8; opacity: 0.8;
} }
/* Filters */
.admin-filters {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-start;
}
.admin-filter-group {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.admin-filter-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.admin-filter-group .admin-tabs {
flex-wrap: wrap;
}
.admin-filter-group .admin-form-select {
min-width: 10rem;
}

File diff suppressed because it is too large Load Diff

View File

@@ -20,9 +20,6 @@ import {
type CurrencyAmount, type CurrencyAmount,
} from "../lib/queries/invoices"; } from "../lib/queries/invoices";
import Pagination from "../components/Pagination"; import Pagination from "../components/Pagination";
import { Skeleton } from "boneyard-js/react";
import InvoicesFixture from "../fixtures/InvoicesFixture";
import ReceivedInvoicesFixture from "../fixtures/ReceivedInvoicesFixture";
const ReceivedInvoices = lazy(() => import("./ReceivedInvoices")); const ReceivedInvoices = lazy(() => import("./ReceivedInvoices"));
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
@@ -283,13 +280,9 @@ export default function Invoices() {
if (initialLoad) { if (initialLoad) {
return ( return (
<Skeleton <div className="admin-loading">
name="invoices" <div className="admin-spinner" />
loading={initialLoad} </div>
fixture={<InvoicesFixture />}
>
<div />
</Skeleton>
); );
} }
@@ -420,13 +413,9 @@ export default function Invoices() {
> >
<Suspense <Suspense
fallback={ fallback={
<Skeleton <div className="admin-loading">
name="invoices-received-kpi" <div className="admin-spinner" />
loading={true} </div>
fixture={<ReceivedInvoicesFixture />}
>
<div />
</Skeleton>
} }
> >
<ReceivedInvoices <ReceivedInvoices
@@ -445,13 +434,9 @@ export default function Invoices() {
transition={{ duration: 0.25, delay: 0.1 }} transition={{ duration: 0.25, delay: 0.1 }}
> >
{statsQuery.isPending && !hasLoadedOnce.current ? ( {statsQuery.isPending && !hasLoadedOnce.current ? (
<Skeleton <div className="admin-loading">
name="invoices-kpi" <div className="admin-spinner" />
loading={statsQuery.isPending && !hasLoadedOnce.current} </div>
fixture={<InvoicesFixture />}
>
<div />
</Skeleton>
) : ( ) : (
stats && ( stats && (
<div style={{ overflow: "hidden", marginBottom: "1.5rem" }}> <div style={{ overflow: "hidden", marginBottom: "1.5rem" }}>
@@ -632,245 +617,143 @@ export default function Invoices() {
/> />
</div> </div>
{invoices.length === 0 && !(draft && !statusFilter) ? ( <AnimatePresence mode="wait">
<div className="admin-empty-state"> <motion.div
<div className="admin-empty-icon"> key={`${statusFilter}-${search}-${statsMonth}-${statsYear}-${page}-${invoices.length}`}
<svg initial={{ opacity: 0, y: 6 }}
width="28" animate={{ opacity: 1, y: 0 }}
height="28" exit={{ opacity: 0 }}
viewBox="0 0 24 24" transition={{ duration: 0.15 }}
fill="none" >
stroke="currentColor" {invoices.length === 0 && !(draft && !statusFilter) ? (
strokeWidth="1.5" <div className="admin-empty-state">
strokeLinecap="round" <div className="admin-empty-icon">
strokeLinejoin="round" <svg
> width="28"
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /> height="28"
<polyline points="14 2 14 8 20 8" /> viewBox="0 0 24 24"
<line x1="16" y1="13" x2="8" y2="13" /> fill="none"
<line x1="16" y1="17" x2="8" y2="17" /> stroke="currentColor"
<polyline points="10 9 9 9 8 9" /> strokeWidth="1.5"
</svg> strokeLinecap="round"
</div> strokeLinejoin="round"
<p>Zatím nejsou žádné faktury.</p>
{hasPermission("invoices.create") && (
<p
className="text-tertiary"
style={{ fontSize: "0.875rem" }}
>
Vytvořte první fakturu tlačítkem výše.
</p>
)}
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("invoice_number")}
> >
Číslo{" "} <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<SortIcon <polyline points="14 2 14 8 20 8" />
column="invoice_number" <line x1="16" y1="13" x2="8" y2="13" />
sort={activeSort} <line x1="16" y1="17" x2="8" y2="17" />
order={order} <polyline points="10 9 9 9 8 9" />
/> </svg>
</th> </div>
<th>Zákazník</th> <p>Zatím nejsou žádné faktury.</p>
<th {hasPermission("invoices.create") && (
style={{ cursor: "pointer" }} <p
onClick={() => handleSort("status")} className="text-tertiary"
style={{ fontSize: "0.875rem" }}
> >
Stav{" "} Vytvořte první fakturu tlačítkem výše.
<SortIcon </p>
column="status" )}
sort={activeSort} </div>
order={order} ) : (
/> <div className="admin-table-responsive">
</th> <table className="admin-table">
<th <thead>
style={{ cursor: "pointer" }} <tr>
onClick={() => handleSort("issue_date")} <th
> style={{ cursor: "pointer" }}
Vystaveno{" "} onClick={() => handleSort("invoice_number")}
<SortIcon >
column="issue_date" Číslo{" "}
sort={activeSort} <SortIcon
order={order} column="invoice_number"
/> sort={activeSort}
</th> order={order}
<th />
style={{ cursor: "pointer" }} </th>
onClick={() => handleSort("due_date")} <th>Zákazník</th>
> <th
Splatnost{" "} style={{ cursor: "pointer" }}
<SortIcon onClick={() => handleSort("status")}
column="due_date" >
sort={activeSort} Stav{" "}
order={order} <SortIcon
/> column="status"
</th> sort={activeSort}
<th className="text-right">Celkem</th> order={order}
<th>Akce</th> />
</tr> </th>
</thead> <th
<tbody> style={{ cursor: "pointer" }}
{draft && !search && !statusFilter && ( onClick={() => handleSort("issue_date")}
<tr className="offers-draft-row"> >
<td> Vystaveno{" "}
<span className="offers-draft-row-label"> <SortIcon
Koncept column="issue_date"
{draft.savedAt && ( sort={activeSort}
<span style={{ fontWeight: 400, opacity: 0.8 }}> order={order}
{" · "} />
{new Date(draft.savedAt).toLocaleTimeString( </th>
"cs-CZ", <th
{ hour: "2-digit", minute: "2-digit" }, style={{ cursor: "pointer" }}
onClick={() => handleSort("due_date")}
>
Splatnost{" "}
<SortIcon
column="due_date"
sort={activeSort}
order={order}
/>
</th>
<th className="text-right">Celkem</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{draft && !search && !statusFilter && (
<tr className="offers-draft-row">
<td>
<span className="offers-draft-row-label">
Koncept
{draft.savedAt && (
<span
style={{ fontWeight: 400, opacity: 0.8 }}
>
{" · "}
{new Date(
draft.savedAt,
).toLocaleTimeString("cs-CZ", {
hour: "2-digit",
minute: "2-digit",
})}
</span>
)} )}
</span> </span>
)} </td>
</span> <td>
</td> {(draft.form.customer_name as string) ||
<td> "\u2014"}
{(draft.form.customer_name as string) || "\u2014"} </td>
</td> <td>{"\u2014"}</td>
<td>{"\u2014"}</td> <td className="admin-mono">
<td className="admin-mono"> {draft.form.issue_date
{draft.form.issue_date ? formatDate(draft.form.issue_date as string)
? formatDate(draft.form.issue_date as string) : "\u2014"}
: "\u2014"} </td>
</td> <td className="admin-mono">
<td className="admin-mono"> {draft.form.due_date
{draft.form.due_date ? formatDate(draft.form.due_date as string)
? formatDate(draft.form.due_date as string) : "\u2014"}
: "\u2014"} </td>
</td> <td />
<td /> <td>
<td> <div className="admin-table-actions">
<div className="admin-table-actions"> <Link
<Link to="/invoices/new"
to="/invoices/new" className="admin-btn-icon"
className="admin-btn-icon" title="Pokračovat v konceptu"
title="Pokračovat v konceptu" aria-label="Pokračovat v konceptu"
aria-label="Pokračovat v konceptu" >
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</Link>
<button
onClick={discardDraft}
className="admin-btn-icon danger"
title="Zahodit koncept"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</td>
</tr>
)}
{invoices.map((inv) => {
const isOverdue =
inv.status === "overdue" ||
(inv.status === "issued" &&
inv.due_date &&
new Date(inv.due_date) <
new Date(new Date().toDateString()));
return (
<tr
key={inv.id}
className={isOverdue ? "offers-expired-row" : ""}
>
<td className="admin-mono">
<Link
to={`/invoices/${inv.id}`}
className="link-accent"
>
{inv.invoice_number}
</Link>
</td>
<td>{inv.customer_name || "\u2014"}</td>
<td>
{inv.status === "paid" ? (
<span
className={`admin-badge ${STATUS_CLASSES[inv.status]}`}
>
{STATUS_LABELS[inv.status]}
</span>
) : (
<button
onClick={() => toggleStatus(inv)}
className={`admin-badge ${STATUS_CLASSES[inv.status] || ""}`}
style={{ cursor: "pointer" }}
>
{STATUS_LABELS[inv.status] || inv.status}
</button>
)}
</td>
<td className="admin-mono">
{formatDate(inv.issue_date)}
</td>
<td
className="admin-mono"
style={
inv.status === "overdue"
? { color: "var(--danger)", fontWeight: 600 }
: undefined
}
>
{formatDate(inv.due_date)}
</td>
<td
className="admin-mono"
style={{ textAlign: "right", fontWeight: 500 }}
>
{formatCurrency(inv.total, inv.currency)}
</td>
<td>
<div className="admin-table-actions">
<Link
to={`/invoices/${inv.id}`}
className="admin-btn-icon"
title={
inv.status === "paid" ? "Detail" : "Upravit"
}
aria-label={
inv.status === "paid" ? "Detail" : "Upravit"
}
>
{inv.status === "paid" ? (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
) : (
<svg <svg
width="18" width="18"
height="18" height="18"
@@ -882,51 +765,11 @@ export default function Invoices() {
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /> <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" /> <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg> </svg>
)} </Link>
</Link>
{hasPermission("invoices.export") && (
<button <button
onClick={() => handlePdf(inv)} onClick={discardDraft}
className="admin-btn-icon"
title="Zobrazit fakturu"
disabled={pdfLoading === inv.id}
>
{pdfLoading === inv.id ? (
<div
className="admin-spinner"
style={{
width: 18,
height: 18,
borderWidth: 2,
}}
/>
) : (
<svg
width="18"
height="18"
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" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
)}
</button>
)}
{hasPermission("invoices.delete") && (
<button
onClick={() =>
setDeleteConfirm({
show: true,
invoice: inv,
})
}
className="admin-btn-icon danger" className="admin-btn-icon danger"
title="Smazat" title="Zahodit koncept"
> >
<svg <svg
width="18" width="18"
@@ -940,16 +783,195 @@ export default function Invoices() {
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /> <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg> </svg>
</button> </button>
)} </div>
</div> </td>
</td> </tr>
</tr> )}
); {invoices.map((inv) => {
})} const isOverdue =
</tbody> inv.status === "overdue" ||
</table> (inv.status === "issued" &&
</div> inv.due_date &&
)} new Date(inv.due_date) <
new Date(new Date().toDateString()));
return (
<tr
key={inv.id}
className={
isOverdue ? "offers-expired-row" : ""
}
>
<td className="admin-mono">
<Link
to={`/invoices/${inv.id}`}
className="link-accent"
>
{inv.invoice_number}
</Link>
</td>
<td>{inv.customer_name || "\u2014"}</td>
<td>
{inv.status === "paid" ? (
<span
className={`admin-badge ${STATUS_CLASSES[inv.status]}`}
>
{STATUS_LABELS[inv.status]}
</span>
) : (
<button
onClick={() => toggleStatus(inv)}
className={`admin-badge ${STATUS_CLASSES[inv.status] || ""}`}
style={{ cursor: "pointer" }}
>
{STATUS_LABELS[inv.status] || inv.status}
</button>
)}
</td>
<td className="admin-mono">
{formatDate(inv.issue_date)}
</td>
<td
className="admin-mono"
style={
inv.status === "overdue"
? {
color: "var(--danger)",
fontWeight: 600,
}
: undefined
}
>
{formatDate(inv.due_date)}
</td>
<td
className="admin-mono"
style={{
textAlign: "right",
fontWeight: 500,
}}
>
{formatCurrency(inv.total, inv.currency)}
</td>
<td>
<div className="admin-table-actions">
<Link
to={`/invoices/${inv.id}`}
className="admin-btn-icon"
title={
inv.status === "paid"
? "Detail"
: "Upravit"
}
aria-label={
inv.status === "paid"
? "Detail"
: "Upravit"
}
>
{inv.status === "paid" ? (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
) : (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
)}
</Link>
{hasPermission("invoices.export") && (
<button
onClick={() => handlePdf(inv)}
className="admin-btn-icon"
title="Zobrazit fakturu"
disabled={pdfLoading === inv.id}
>
{pdfLoading === inv.id ? (
<div
className="admin-spinner"
style={{
width: 18,
height: 18,
borderWidth: 2,
}}
/>
) : (
<svg
width="18"
height="18"
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" />
<line
x1="16"
y1="13"
x2="8"
y2="13"
/>
<line
x1="16"
y1="17"
x2="8"
y2="17"
/>
</svg>
)}
</button>
)}
{hasPermission("invoices.delete") && (
<button
onClick={() =>
setDeleteConfirm({
show: true,
invoice: inv,
})
}
className="admin-btn-icon danger"
title="Smazat"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</motion.div>
</AnimatePresence>
<Pagination pagination={pagination} onPageChange={setPage} /> <Pagination pagination={pagination} onPageChange={setPage} />
</div> </div>
</motion.div> </motion.div>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -450,10 +450,11 @@ export default async function invoicesPdfRoutes(
const lineVat = applyVat ? (lineSubtotal * vatRate) / 100 : 0; const lineVat = applyVat ? (lineSubtotal * vatRate) / 100 : 0;
const lineTotal = lineSubtotal + lineVat; const lineTotal = lineSubtotal + lineVat;
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2; const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
const subDesc = item.item_description || "";
return `<tr> return `<tr>
<td class="row-num">${i + 1}</td> <td class="row-num">${i + 1}</td>
<td class="desc">${escapeHtml(item.description)}</td> <td class="desc">${escapeHtml(item.description)}${subDesc ? `<div class="item-subdesc">${escapeHtml(subDesc)}</div>` : ""}</td>
<td class="center">${formatNum(qty, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td> <td class="center">${formatNum(qty, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
<td class="right">${formatNum(unitPrice)}</td> <td class="right">${formatNum(unitPrice)}</td>
<td class="right">${formatNum(lineSubtotal)}</td> <td class="right">${formatNum(lineSubtotal)}</td>
@@ -693,6 +694,11 @@ export default async function invoicesPdfRoutes(
font-weight: 600; font-weight: 600;
color: #1a1a1a; color: #1a1a1a;
} }
.item-subdesc {
font-size: 9pt;
color: #646464;
margin-top: 2px;
}
table.items tbody td.total-cell { font-weight: 700; } table.items tbody td.total-cell { font-weight: 700; }
/* Soucet + total - styl z nabidek */ /* Soucet + total - styl z nabidek */

View File

@@ -199,7 +199,6 @@ const TRANSLATIONS: Record<string, Record<string, string>> = {
subtotal: { EN: "Subtotal", CZ: "Mezisou\u010Det" }, subtotal: { EN: "Subtotal", CZ: "Mezisou\u010Det" },
vat: { EN: "VAT", CZ: "DPH" }, vat: { EN: "VAT", CZ: "DPH" },
total_to_pay: { EN: "Total to pay", CZ: "Celkem k \u00FAhrad\u011B" }, total_to_pay: { EN: "Total to pay", CZ: "Celkem k \u00FAhrad\u011B" },
exchange_rate: { EN: "Exchange rate", CZ: "Sm\u011Bnn\u00FD kurz" },
ico: { EN: "ID", CZ: "I\u010CO" }, ico: { EN: "ID", CZ: "I\u010CO" },
dic: { EN: "VAT ID", CZ: "DI\u010C" }, dic: { EN: "VAT ID", CZ: "DI\u010C" },
page: { EN: "Page", CZ: "Strana" }, page: { EN: "Page", CZ: "Strana" },
@@ -262,8 +261,6 @@ export default async function offersPdfRoutes(
const vatRate = Number(quotation.vat_rate) || 21; const vatRate = Number(quotation.vat_rate) || 21;
const vatAmount = applyVat ? subtotal * (vatRate / 100) : 0; const vatAmount = applyVat ? subtotal * (vatRate / 100) : 0;
const totalToPay = subtotal + vatAmount; const totalToPay = subtotal + vatAmount;
const exchangeRate = Number(quotation.exchange_rate) || 0;
let hasScopeContent = false; let hasScopeContent = false;
for (const s of quotation.scope_sections) { for (const s of quotation.scope_sections) {
if ((s.content || "").trim() || (s.title || "").trim()) { if ((s.content || "").trim() || (s.title || "").trim()) {
@@ -331,10 +328,6 @@ export default async function offersPdfRoutes(
<span class="label">${escapeHtml(t("total_to_pay"))}</span> <span class="label">${escapeHtml(t("total_to_pay"))}</span>
<span class="value">${formatCurrency(totalToPay, currency)}</span> <span class="value">${formatCurrency(totalToPay, currency)}</span>
</div>`; </div>`;
if (exchangeRate > 0) {
totalsHtml += `<div class="exchange-rate">${escapeHtml(t("exchange_rate"))}: ${formatNum(exchangeRate, 4)}</div>`;
}
const quotationNumber = escapeHtml(quotation.quotation_number); const quotationNumber = escapeHtml(quotation.quotation_number);
let scopeHtml = ""; let scopeHtml = "";

View File

@@ -42,6 +42,7 @@ export default async function quotationsRoutes(
search, search,
status: query.status ? String(query.status) : undefined, status: query.status ? String(query.status) : undefined,
customer_id: query.customer_id ? Number(query.customer_id) : undefined, customer_id: query.customer_id ? Number(query.customer_id) : undefined,
has_order: query.has_order ? String(query.has_order) : undefined,
}); });
return paginated( return paginated(

View File

@@ -2,6 +2,7 @@ import { z } from "zod";
const InvoiceItemSchema = z.object({ const InvoiceItemSchema = z.object({
description: z.string().nullish(), description: z.string().nullish(),
item_description: z.string().nullish(),
quantity: z quantity: z
.union([z.number(), z.string()]) .union([z.number(), z.string()])
.transform((v) => { .transform((v) => {

View File

@@ -59,16 +59,10 @@ export const CreateQuotationSchema = z.object({
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean()) .preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
.optional() .optional()
.default(true), .default(true),
exchange_rate: z
.union([z.number(), z.string()])
.transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional()
.default(1.0),
status: z status: z
.enum(["nova", "odeslana", "prijata", "odmitnuta", "dokoncena", "active"]) .enum(["active", "ordered", "invalidated"])
.optional() .optional()
.default("nova"), .default("active"),
scope_title: z.string().nullish(), scope_title: z.string().nullish(),
scope_description: z.string().nullish(), scope_description: z.string().nullish(),
items: z.array(QuotationItemSchema).optional(), items: z.array(QuotationItemSchema).optional(),
@@ -93,14 +87,7 @@ export const UpdateQuotationSchema = z.object({
apply_vat: z apply_vat: z
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean()) .preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
.optional(), .optional(),
exchange_rate: z status: z.enum(["active", "ordered", "invalidated"]).optional(),
.union([z.number(), z.string()])
.transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional(),
status: z
.enum(["nova", "odeslana", "prijata", "odmitnuta", "dokoncena", "active"])
.optional(),
scope_title: z.string().nullish(), scope_title: z.string().nullish(),
scope_description: z.string().nullish(), scope_description: z.string().nullish(),
items: z.array(QuotationItemSchema).optional(), items: z.array(QuotationItemSchema).optional(),

View File

@@ -23,6 +23,7 @@ const ALLOWED_SORT_FIELDS = [
interface InvoiceItemInput { interface InvoiceItemInput {
description?: string; description?: string;
item_description?: string;
quantity?: number; quantity?: number;
unit?: string; unit?: string;
unit_price?: number; unit_price?: number;
@@ -374,6 +375,7 @@ export async function createInvoice(body: Record<string, any>) {
data: (body.items as InvoiceItemInput[]).map((item, i) => ({ data: (body.items as InvoiceItemInput[]).map((item, i) => ({
invoice_id: invoice.id, invoice_id: invoice.id,
description: item.description ?? null, description: item.description ?? null,
item_description: item.item_description ?? null,
quantity: item.quantity ?? 1, quantity: item.quantity ?? 1,
unit: item.unit ?? null, unit: item.unit ?? null,
unit_price: item.unit_price ?? 0, unit_price: item.unit_price ?? 0,
@@ -471,6 +473,7 @@ export async function updateInvoice(id: number, body: Record<string, any>) {
data: (body.items as InvoiceItemInput[]).map((item, i) => ({ data: (body.items as InvoiceItemInput[]).map((item, i) => ({
invoice_id: id, invoice_id: id,
description: item.description ?? null, description: item.description ?? null,
item_description: item.item_description ?? null,
quantity: item.quantity ?? 1, quantity: item.quantity ?? 1,
unit: item.unit ?? null, unit: item.unit ?? null,
unit_price: item.unit_price ?? 0, unit_price: item.unit_price ?? 0,

View File

@@ -44,6 +44,7 @@ interface ListOffersParams {
search: string; search: string;
status?: string; status?: string;
customer_id?: number; customer_id?: number;
has_order?: string;
} }
function enrichQuotation(q: any) { function enrichQuotation(q: any) {
@@ -72,13 +73,24 @@ function enrichQuotation(q: any) {
} }
export async function listOffers(params: ListOffersParams) { export async function listOffers(params: ListOffersParams) {
const { page, limit, skip, sort, order, search, status, customer_id } = const {
params; page,
limit,
skip,
sort,
order,
search,
status,
customer_id,
has_order,
} = params;
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id"; const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
const where: Record<string, unknown> = {}; const where: Record<string, unknown> = {};
if (status) where.status = status; if (status) where.status = status;
if (customer_id) where.customer_id = customer_id; if (customer_id) where.customer_id = customer_id;
if (has_order === "yes") where.order_id = { not: null };
if (has_order === "no") where.order_id = null;
if (search) { if (search) {
where.OR = [ where.OR = [
{ quotation_number: { contains: search } }, { quotation_number: { contains: search } },
@@ -184,7 +196,6 @@ export async function createOffer(body: Record<string, any>) {
language: body.language ? String(body.language) : "cs", language: body.language ? String(body.language) : "cs",
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0, vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
apply_vat: body.apply_vat !== false, apply_vat: body.apply_vat !== false,
exchange_rate: body.exchange_rate ? Number(body.exchange_rate) : 1.0,
status: body.status ? String(body.status) : "active", status: body.status ? String(body.status) : "active",
scope_title: body.scope_title ? String(body.scope_title) : null, scope_title: body.scope_title ? String(body.scope_title) : null,
scope_description: body.scope_description scope_description: body.scope_description
@@ -255,8 +266,6 @@ export async function updateOffer(id: number, body: Record<string, any>) {
body.apply_vat === 1 || body.apply_vat === 1 ||
body.apply_vat === "1" body.apply_vat === "1"
: undefined, : undefined,
exchange_rate:
body.exchange_rate !== undefined ? Number(body.exchange_rate) : undefined,
status: body.status !== undefined ? String(body.status) : undefined, status: body.status !== undefined ? String(body.status) : undefined,
project_code: project_code:
body.project_code !== undefined body.project_code !== undefined
@@ -354,7 +363,6 @@ export async function duplicateOffer(id: number) {
language: original.language, language: original.language,
vat_rate: original.vat_rate, vat_rate: original.vat_rate,
apply_vat: original.apply_vat, apply_vat: original.apply_vat,
exchange_rate: original.exchange_rate,
status: "active", status: "active",
scope_title: original.scope_title, scope_title: original.scope_title,
scope_description: original.scope_description, scope_description: original.scope_description,

View File

@@ -206,7 +206,6 @@ export async function createOrderFromQuotation(
language: quotation.language || "cs", language: quotation.language || "cs",
vat_rate: quotation.vat_rate ?? 21.0, vat_rate: quotation.vat_rate ?? 21.0,
apply_vat: quotation.apply_vat ?? true, apply_vat: quotation.apply_vat ?? true,
exchange_rate: quotation.exchange_rate ?? 1.0,
scope_title: quotation.scope_title, scope_title: quotation.scope_title,
scope_description: quotation.scope_description, scope_description: quotation.scope_description,
attachment_data: attachmentBuffer attachment_data: attachmentBuffer