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:
572
package-lock.json
generated
572
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `quotations` DROP COLUMN `exchange_rate`;
|
||||||
|
ALTER TABLE `quotations` DROP COLUMN `exchange_rate_date`;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `invoice_items` ADD COLUMN `item_description` TEXT NULL;
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 */
|
||||||
|
|||||||
@@ -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 = "";
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user