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

View File

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

View File

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

View File

@@ -627,3 +627,33 @@
margin-top: 0.2rem;
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,
} from "../lib/queries/invoices";
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 API_BASE = "/api/admin";
@@ -283,13 +280,9 @@ export default function Invoices() {
if (initialLoad) {
return (
<Skeleton
name="invoices"
loading={initialLoad}
fixture={<InvoicesFixture />}
>
<div />
</Skeleton>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
@@ -420,13 +413,9 @@ export default function Invoices() {
>
<Suspense
fallback={
<Skeleton
name="invoices-received-kpi"
loading={true}
fixture={<ReceivedInvoicesFixture />}
>
<div />
</Skeleton>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
}
>
<ReceivedInvoices
@@ -445,13 +434,9 @@ export default function Invoices() {
transition={{ duration: 0.25, delay: 0.1 }}
>
{statsQuery.isPending && !hasLoadedOnce.current ? (
<Skeleton
name="invoices-kpi"
loading={statsQuery.isPending && !hasLoadedOnce.current}
fixture={<InvoicesFixture />}
>
<div />
</Skeleton>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : (
stats && (
<div style={{ overflow: "hidden", marginBottom: "1.5rem" }}>
@@ -632,245 +617,143 @@ export default function Invoices() {
/>
</div>
{invoices.length === 0 && !(draft && !statusFilter) ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<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" />
<polyline points="10 9 9 9 8 9" />
</svg>
</div>
<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")}
<AnimatePresence mode="wait">
<motion.div
key={`${statusFilter}-${search}-${statsMonth}-${statsYear}-${page}-${invoices.length}`}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{invoices.length === 0 && !(draft && !statusFilter) ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
Číslo{" "}
<SortIcon
column="invoice_number"
sort={activeSort}
order={order}
/>
</th>
<th>Zákazník</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("status")}
<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" />
<polyline points="10 9 9 9 8 9" />
</svg>
</div>
<p>Zatím nejsou žádné faktury.</p>
{hasPermission("invoices.create") && (
<p
className="text-tertiary"
style={{ fontSize: "0.875rem" }}
>
Stav{" "}
<SortIcon
column="status"
sort={activeSort}
order={order}
/>
</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("issue_date")}
>
Vystaveno{" "}
<SortIcon
column="issue_date"
sort={activeSort}
order={order}
/>
</th>
<th
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" },
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{" "}
<SortIcon
column="invoice_number"
sort={activeSort}
order={order}
/>
</th>
<th>Zákazník</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("status")}
>
Stav{" "}
<SortIcon
column="status"
sort={activeSort}
order={order}
/>
</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("issue_date")}
>
Vystaveno{" "}
<SortIcon
column="issue_date"
sort={activeSort}
order={order}
/>
</th>
<th
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>
<td>
{(draft.form.customer_name as string) || "\u2014"}
</td>
<td>{"\u2014"}</td>
<td className="admin-mono">
{draft.form.issue_date
? formatDate(draft.form.issue_date as string)
: "\u2014"}
</td>
<td className="admin-mono">
{draft.form.due_date
? formatDate(draft.form.due_date as string)
: "\u2014"}
</td>
<td />
<td>
<div className="admin-table-actions">
<Link
to="/invoices/new"
className="admin-btn-icon"
title="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>
) : (
</td>
<td>
{(draft.form.customer_name as string) ||
"\u2014"}
</td>
<td>{"\u2014"}</td>
<td className="admin-mono">
{draft.form.issue_date
? formatDate(draft.form.issue_date as string)
: "\u2014"}
</td>
<td className="admin-mono">
{draft.form.due_date
? formatDate(draft.form.due_date as string)
: "\u2014"}
</td>
<td />
<td>
<div className="admin-table-actions">
<Link
to="/invoices/new"
className="admin-btn-icon"
title="Pokračovat v konceptu"
aria-label="Pokračovat v konceptu"
>
<svg
width="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="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") && (
</Link>
<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,
})
}
onClick={discardDraft}
className="admin-btn-icon danger"
title="Smazat"
title="Zahodit koncept"
>
<svg
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" />
</svg>
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</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
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} />
</div>
</motion.div>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff