Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c1b9dca17 | ||
|
|
7b20c47937 | ||
|
|
7398cad466 | ||
|
|
396a1d37ec | ||
|
|
ca1f07671f | ||
|
|
638264fc7c | ||
|
|
f68a4dafc4 | ||
|
|
700fb47bbc | ||
|
|
c2746d78c9 |
10
CLAUDE.md
10
CLAUDE.md
@@ -520,13 +520,19 @@ The 2026-06-09 file-by-file audit traced most bugs to a handful of patterns. The
|
|||||||
2. `npm run build`
|
2. `npm run build`
|
||||||
3. Commit and tag (`git tag -a vX.Y.Z`)
|
3. Commit and tag (`git tag -a vX.Y.Z`)
|
||||||
4. Push to Gitea (`git push origin master && git push origin vX.Y.Z`)
|
4. Push to Gitea (`git push origin master && git push origin vX.Y.Z`)
|
||||||
5. Create tarball: `tar -czf app-ts-X.Y.Z.tar.gz dist dist-client prisma package.json package-lock.json scripts`
|
5. Create tarball: `tar -czf app-ts-X.Y.Z.tar.gz dist dist-client prisma prisma.config.ts package.json package-lock.json scripts`
|
||||||
|
(⚠️ `prisma.config.ts` is REQUIRED — Prisma 7 keeps the datasource URL there;
|
||||||
|
without it, `prisma generate`/`migrate deploy` on prod have no datasource)
|
||||||
6. Deploy via SSH to production server (`boha_admin@192.168.50.100`):
|
6. Deploy via SSH to production server (`boha_admin@192.168.50.100`):
|
||||||
- Path: `/var/www/app-ts`
|
- Path: `/var/www/app-ts`
|
||||||
- Remove old files: `rm -rf dist dist-client prisma scripts package.json package-lock.json`
|
- Remove old files: `rm -rf dist dist-client prisma prisma.config.ts scripts package.json package-lock.json`
|
||||||
- Copy tarball to server: `scp app-ts-X.Y.Z.tar.gz boha_admin@192.168.50.100:/tmp/`
|
- Copy tarball to server: `scp app-ts-X.Y.Z.tar.gz boha_admin@192.168.50.100:/tmp/`
|
||||||
- Extract tarball: `tar -xzf /tmp/app-ts-X.Y.Z.tar.gz`
|
- Extract tarball: `tar -xzf /tmp/app-ts-X.Y.Z.tar.gz`
|
||||||
- Install dependencies: `npm install --omit=dev`
|
- Install dependencies: `npm install --omit=dev`
|
||||||
|
- Regenerate the Prisma client: `npx prisma generate` — **MANDATORY**.
|
||||||
|
`npm install` skips regeneration when dependencies didn't change, leaving a
|
||||||
|
stale client that still selects dropped/renamed columns → P2022 500s in
|
||||||
|
prod (bit the v2.4.0 supplier release).
|
||||||
- Apply Prisma migrations: `npx prisma migrate deploy`
|
- Apply Prisma migrations: `npx prisma migrate deploy`
|
||||||
- Restart: `pm2 restart app-ts --update-env`
|
- Restart: `pm2 restart app-ts --update-env`
|
||||||
|
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.0",
|
"version": "2.4.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.0",
|
"version": "2.4.3",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.102.0",
|
"@anthropic-ai/sdk": "^0.102.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.0",
|
"version": "2.4.3",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `issued_orders` ADD COLUMN `order_text` VARCHAR(500) NULL;
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `issued_order_items` DROP COLUMN `vat_rate`;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `issued_orders` DROP COLUMN `apply_vat`,
|
||||||
|
DROP COLUMN `vat_rate`;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `orders` DROP COLUMN `apply_vat`,
|
||||||
|
DROP COLUMN `vat_rate`;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `quotations` DROP COLUMN `apply_vat`,
|
||||||
|
DROP COLUMN `vat_rate`;
|
||||||
|
|
||||||
@@ -347,8 +347,6 @@ model orders {
|
|||||||
status String? @default("prijata") @db.VarChar(30)
|
status String? @default("prijata") @db.VarChar(30)
|
||||||
currency String? @default("CZK") @db.VarChar(10)
|
currency String? @default("CZK") @db.VarChar(10)
|
||||||
language String? @default("cs") @db.VarChar(5)
|
language String? @default("cs") @db.VarChar(5)
|
||||||
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
|
||||||
apply_vat Boolean? @default(true)
|
|
||||||
exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
|
exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
|
||||||
scope_title String? @db.VarChar(500)
|
scope_title String? @db.VarChar(500)
|
||||||
scope_description String? @db.Text
|
scope_description String? @db.Text
|
||||||
@@ -372,8 +370,6 @@ model issued_orders {
|
|||||||
supplier_id Int?
|
supplier_id Int?
|
||||||
status issued_orders_status @default(draft)
|
status issued_orders_status @default(draft)
|
||||||
currency String? @default("CZK") @db.VarChar(10)
|
currency String? @default("CZK") @db.VarChar(10)
|
||||||
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
|
||||||
apply_vat Boolean? @default(true)
|
|
||||||
exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
|
exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
|
||||||
order_date DateTime? @db.Date
|
order_date DateTime? @db.Date
|
||||||
delivery_date DateTime? @db.Date
|
delivery_date DateTime? @db.Date
|
||||||
@@ -381,6 +377,7 @@ model issued_orders {
|
|||||||
delivery_terms String? @db.VarChar(500)
|
delivery_terms String? @db.VarChar(500)
|
||||||
payment_terms String? @db.VarChar(500)
|
payment_terms String? @db.VarChar(500)
|
||||||
issued_by String? @db.VarChar(255)
|
issued_by String? @db.VarChar(255)
|
||||||
|
order_text String? @db.VarChar(500)
|
||||||
notes String? @db.Text
|
notes String? @db.Text
|
||||||
internal_notes String? @db.Text
|
internal_notes String? @db.Text
|
||||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
@@ -400,7 +397,6 @@ model issued_order_items {
|
|||||||
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
|
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)
|
|
||||||
position Int? @default(0)
|
position Int? @default(0)
|
||||||
issued_orders issued_orders @relation(fields: [issued_order_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "issued_order_items_ibfk_1")
|
issued_orders issued_orders @relation(fields: [issued_order_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "issued_order_items_ibfk_1")
|
||||||
|
|
||||||
@@ -493,8 +489,6 @@ model quotations {
|
|||||||
valid_until DateTime? @db.Date
|
valid_until DateTime? @db.Date
|
||||||
currency String? @default("CZK") @db.VarChar(10)
|
currency String? @default("CZK") @db.VarChar(10)
|
||||||
language String? @default("cs") @db.VarChar(5)
|
language String? @default("cs") @db.VarChar(5)
|
||||||
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
|
||||||
apply_vat Boolean? @default(true)
|
|
||||||
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)
|
||||||
|
|||||||
@@ -56,16 +56,21 @@ describe("issued-order numbering", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("CreateIssuedOrderSchema", () => {
|
describe("CreateIssuedOrderSchema", () => {
|
||||||
it("coerces string form numbers and rejects an out-of-range VAT", () => {
|
it("coerces string form numbers and rejects a NaN quantity", () => {
|
||||||
const ok = CreateIssuedOrderSchema.safeParse({
|
const ok = CreateIssuedOrderSchema.safeParse({
|
||||||
supplier_id: "5",
|
supplier_id: "5",
|
||||||
vat_rate: "21",
|
|
||||||
items: [{ description: "X", quantity: "2", unit_price: "100" }],
|
items: [{ description: "X", quantity: "2", unit_price: "100" }],
|
||||||
});
|
});
|
||||||
expect(ok.success).toBe(true);
|
expect(ok.success).toBe(true);
|
||||||
if (ok.success) expect(ok.data.supplier_id).toBe(5);
|
if (ok.success) {
|
||||||
|
expect(ok.data.supplier_id).toBe(5);
|
||||||
|
expect(ok.data.items![0].quantity).toBe(2);
|
||||||
|
expect(ok.data.items![0].unit_price).toBe(100);
|
||||||
|
}
|
||||||
|
|
||||||
const bad = CreateIssuedOrderSchema.safeParse({ vat_rate: "200" });
|
const bad = CreateIssuedOrderSchema.safeParse({
|
||||||
|
items: [{ description: "X", quantity: "not-a-number" }],
|
||||||
|
});
|
||||||
expect(bad.success).toBe(false);
|
expect(bad.success).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -105,37 +110,22 @@ async function mkIssued(input: IssuedOrderInput = {}) {
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("computeIssuedOrderTotals (NET + VAT-on-top)", () => {
|
describe("computeIssuedOrderTotals (NET only — no VAT on issued orders)", () => {
|
||||||
it("adds VAT on top of net, rounded per line", () => {
|
it("sums qty × unit_price per line, rounded to 2dp", () => {
|
||||||
const t = computeIssuedOrderTotals(
|
const t = computeIssuedOrderTotals([
|
||||||
[
|
{ quantity: 2, unit_price: 100 },
|
||||||
{ quantity: 2, unit_price: 100, vat_rate: 21 },
|
{ quantity: 1, unit_price: 50 },
|
||||||
{ quantity: 1, unit_price: 50, vat_rate: 12 },
|
]);
|
||||||
],
|
expect(t).toEqual({ total: 250 });
|
||||||
true,
|
|
||||||
21,
|
|
||||||
);
|
|
||||||
expect(t.subtotal).toBe(250);
|
|
||||||
expect(t.vat_amount).toBe(48);
|
|
||||||
expect(t.total).toBe(298);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("zeroes VAT when apply_vat is false but keeps the net subtotal", () => {
|
it("treats null/garbage numerics as zero", () => {
|
||||||
const t = computeIssuedOrderTotals(
|
const t = computeIssuedOrderTotals([
|
||||||
[{ quantity: 3, unit_price: 100, vat_rate: 21 }],
|
{ quantity: null, unit_price: 100 },
|
||||||
false,
|
{ quantity: 3, unit_price: "abc" },
|
||||||
21,
|
{ quantity: 2, unit_price: "10.555" },
|
||||||
);
|
]);
|
||||||
expect(t).toEqual({ subtotal: 300, vat_amount: 0, total: 300 });
|
expect(t).toEqual({ total: 21.11 });
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the document rate when a line rate is null", () => {
|
|
||||||
const t = computeIssuedOrderTotals(
|
|
||||||
[{ quantity: 1, unit_price: 100, vat_rate: null }],
|
|
||||||
true,
|
|
||||||
15,
|
|
||||||
);
|
|
||||||
expect(t.vat_amount).toBe(15);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -144,9 +134,7 @@ describe("createIssuedOrder", () => {
|
|||||||
const s = await makeSupplier();
|
const s = await makeSupplier();
|
||||||
const order = await mkIssued({
|
const order = await mkIssued({
|
||||||
supplier_id: s.id,
|
supplier_id: s.id,
|
||||||
items: [
|
items: [{ description: "Materiál", quantity: 2, unit_price: 100 }],
|
||||||
{ description: "Materiál", quantity: 2, unit_price: 100, vat_rate: 21 },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
// Deferred numbering: a draft carries no number.
|
// Deferred numbering: a draft carries no number.
|
||||||
expect(order.po_number).toBeNull();
|
expect(order.po_number).toBeNull();
|
||||||
@@ -170,6 +158,23 @@ describe("createIssuedOrder", () => {
|
|||||||
const res = await createIssuedOrder({ supplier_id: 99999999 });
|
const res = await createIssuedOrder({ supplier_id: 99999999 });
|
||||||
expect("error" in res && res.error).toBe("supplier_not_found");
|
expect("error" in res && res.error).toBe("supplier_not_found");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("persists order_text on create, updates and clears it on update", async () => {
|
||||||
|
const order = await mkIssued({ order_text: "Objednáváme dle smlouvy:" });
|
||||||
|
let row = await prisma.issued_orders.findUnique({
|
||||||
|
where: { id: order.id },
|
||||||
|
});
|
||||||
|
expect(row!.order_text).toBe("Objednáváme dle smlouvy:");
|
||||||
|
|
||||||
|
await updateIssuedOrder(order.id, { order_text: "Jiný text:" });
|
||||||
|
row = await prisma.issued_orders.findUnique({ where: { id: order.id } });
|
||||||
|
expect(row!.order_text).toBe("Jiný text:");
|
||||||
|
|
||||||
|
// null clears back to the PDF default.
|
||||||
|
await updateIssuedOrder(order.id, { order_text: null });
|
||||||
|
row = await prisma.issued_orders.findUnique({ where: { id: order.id } });
|
||||||
|
expect(row!.order_text).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("updateIssuedOrder status transitions", () => {
|
describe("updateIssuedOrder status transitions", () => {
|
||||||
@@ -420,8 +425,6 @@ describe("renderIssuedOrderHtml", () => {
|
|||||||
order_date: new Date("2026-06-09T12:00:00"),
|
order_date: new Date("2026-06-09T12:00:00"),
|
||||||
delivery_date: null,
|
delivery_date: null,
|
||||||
currency: "CZK",
|
currency: "CZK",
|
||||||
apply_vat: true,
|
|
||||||
vat_rate: 21,
|
|
||||||
notes: "<p>Pozn</p><script>alert(1)</script>",
|
notes: "<p>Pozn</p><script>alert(1)</script>",
|
||||||
delivery_terms: null,
|
delivery_terms: null,
|
||||||
payment_terms: null,
|
payment_terms: null,
|
||||||
@@ -434,7 +437,6 @@ describe("renderIssuedOrderHtml", () => {
|
|||||||
quantity: 2,
|
quantity: 2,
|
||||||
unit: "ks",
|
unit: "ks",
|
||||||
unit_price: 100,
|
unit_price: 100,
|
||||||
vat_rate: 21,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -507,6 +509,50 @@ describe("renderIssuedOrderHtml", () => {
|
|||||||
expect(html).not.toContain("alert(1)");
|
expect(html).not.toContain("alert(1)");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders the custom order_text heading when set, default when not", () => {
|
||||||
|
const custom = renderIssuedOrderHtml(
|
||||||
|
{ ...order, order_text: "Objednáváme dle nabídky č. 123:" },
|
||||||
|
items,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"cs",
|
||||||
|
issuer,
|
||||||
|
);
|
||||||
|
expect(custom).toContain("Objednáváme dle nabídky č. 123:");
|
||||||
|
expect(custom).not.toContain("Objednáváme si u Vás:");
|
||||||
|
|
||||||
|
const fallback = renderIssuedOrderHtml(
|
||||||
|
order,
|
||||||
|
items,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"cs",
|
||||||
|
issuer,
|
||||||
|
);
|
||||||
|
expect(fallback).toContain("Objednáváme si u Vás:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never renders VAT columns or notices; totals NET with 'Celkem bez DPH'", () => {
|
||||||
|
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
|
||||||
|
// No VAT anywhere — the PO is not a tax document.
|
||||||
|
expect(html).not.toContain("%DPH");
|
||||||
|
expect(html).not.toContain(">DPH<");
|
||||||
|
// Line total = netto (2 × 100).
|
||||||
|
expect(html).toContain('<td class="right total-cell">200,00</td>');
|
||||||
|
expect(html).toContain("Celkem bez DPH");
|
||||||
|
// No Mezisoučet row (it would duplicate the grand total) and no
|
||||||
|
// prices-excl.-VAT notice (user removed it — the total label suffices).
|
||||||
|
expect(html).not.toContain("Mezisoučet");
|
||||||
|
expect(html).not.toContain("Ceny jsou uvedeny bez DPH");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the English total label for lang=en, no VAT columns or notice", () => {
|
||||||
|
const html = renderIssuedOrderHtml(order, items, null, null, "en", issuer);
|
||||||
|
expect(html).toContain("Total excl. VAT");
|
||||||
|
expect(html).not.toContain("VAT%");
|
||||||
|
expect(html).not.toContain("Prices are exclusive of VAT");
|
||||||
|
});
|
||||||
|
|
||||||
it("footer shows the logged-in user's name, no e-mail, no Schválil column", () => {
|
it("footer shows the logged-in user's name, no e-mail, no Schválil column", () => {
|
||||||
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
|
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
|
||||||
// Footer: Vystavil <name> from authData. No e-mail line.
|
// Footer: Vystavil <name> from authData. No e-mail line.
|
||||||
|
|||||||
@@ -47,8 +47,6 @@ const baseOrder = {
|
|||||||
status: "prijata" as const,
|
status: "prijata" as const,
|
||||||
currency: "CZK",
|
currency: "CZK",
|
||||||
language: "cs",
|
language: "cs",
|
||||||
vat_rate: 21,
|
|
||||||
apply_vat: true,
|
|
||||||
exchange_rate: 1,
|
exchange_rate: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -117,8 +115,6 @@ describe("createOrderFromQuotation (auto-project gets its OWN number)", () => {
|
|||||||
status: "active",
|
status: "active",
|
||||||
currency: "CZK",
|
currency: "CZK",
|
||||||
language: "cs",
|
language: "cs",
|
||||||
vat_rate: 21,
|
|
||||||
apply_vat: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
createdQuotationIds.push(quotation.id);
|
createdQuotationIds.push(quotation.id);
|
||||||
|
|||||||
@@ -54,18 +54,16 @@ function amountFor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("getOfferTotals per-currency aggregation", () => {
|
describe("getOfferTotals per-currency aggregation", () => {
|
||||||
it("sums offer TOTAL incl. VAT per currency over the full filtered set", async () => {
|
it("sums offer NET total per currency over the full filtered set", async () => {
|
||||||
// Two CZK offers + one EUR. 21% VAT applied on the whole subtotal
|
// Two CZK offers + one EUR. NET only (enrichQuotation math — offers are
|
||||||
// (enrichQuotation math).
|
// not tax documents, no VAT anywhere).
|
||||||
// CZK #1: 2 x 1000 = 2000 net -> +21% = 2420
|
// CZK #1: 2 x 1000 = 2000
|
||||||
// CZK #2: 1 x 500 = 500 net -> +21% = 605 => CZK total 3025
|
// CZK #2: 1 x 500 = 500 => CZK total 2500
|
||||||
// EUR : 3 x 100 = 300 net -> +21% = 363 => EUR total 363
|
// EUR : 3 x 100 = 300 => EUR total 300
|
||||||
const mk = async (currency: string, qty: number, price: number) => {
|
const mk = async (currency: string, qty: number, price: number) => {
|
||||||
const res = await createOffer({
|
const res = await createOffer({
|
||||||
status: "draft", // draft so no offer number is consumed
|
status: "draft", // draft so no offer number is consumed
|
||||||
currency,
|
currency,
|
||||||
vat_rate: 21,
|
|
||||||
apply_vat: true,
|
|
||||||
project_code: OFFER_MARKER,
|
project_code: OFFER_MARKER,
|
||||||
items: [{ description: "X", quantity: qty, unit_price: price }],
|
items: [{ description: "X", quantity: qty, unit_price: price }],
|
||||||
});
|
});
|
||||||
@@ -79,8 +77,8 @@ describe("getOfferTotals per-currency aggregation", () => {
|
|||||||
await mk("EUR", 3, 100);
|
await mk("EUR", 3, 100);
|
||||||
|
|
||||||
const { totals } = await getOfferTotals({ search: OFFER_MARKER });
|
const { totals } = await getOfferTotals({ search: OFFER_MARKER });
|
||||||
expect(amountFor(totals, "CZK")).toBe(3025);
|
expect(amountFor(totals, "CZK")).toBe(2500);
|
||||||
expect(amountFor(totals, "EUR")).toBe(363);
|
expect(amountFor(totals, "EUR")).toBe(300);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("respects the where: a status filter narrows the result", async () => {
|
it("respects the where: a status filter narrows the result", async () => {
|
||||||
@@ -88,8 +86,6 @@ describe("getOfferTotals per-currency aggregation", () => {
|
|||||||
const res = await createOffer({
|
const res = await createOffer({
|
||||||
status,
|
status,
|
||||||
currency: "CZK",
|
currency: "CZK",
|
||||||
vat_rate: 21,
|
|
||||||
apply_vat: true,
|
|
||||||
project_code: OFFER_MARKER,
|
project_code: OFFER_MARKER,
|
||||||
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||||
});
|
});
|
||||||
@@ -103,23 +99,23 @@ describe("getOfferTotals per-currency aggregation", () => {
|
|||||||
await mk("draft");
|
await mk("draft");
|
||||||
await mk("invalidated");
|
await mk("invalidated");
|
||||||
|
|
||||||
// Marker only: all three count -> 3 x 1210 = 3630.
|
// Marker only: all three count -> 3 x 1000 = 3000 (net).
|
||||||
const all = await getOfferTotals({ search: OFFER_MARKER });
|
const all = await getOfferTotals({ search: OFFER_MARKER });
|
||||||
expect(amountFor(all.totals, "CZK")).toBe(3630);
|
expect(amountFor(all.totals, "CZK")).toBe(3000);
|
||||||
|
|
||||||
// Status filter narrows to the two drafts -> 2 x 1210 = 2420.
|
// Status filter narrows to the two drafts -> 2 x 1000 = 2000.
|
||||||
const onlyDraft = await getOfferTotals({
|
const onlyDraft = await getOfferTotals({
|
||||||
search: OFFER_MARKER,
|
search: OFFER_MARKER,
|
||||||
status: "draft",
|
status: "draft",
|
||||||
});
|
});
|
||||||
expect(amountFor(onlyDraft.totals, "CZK")).toBe(2420);
|
expect(amountFor(onlyDraft.totals, "CZK")).toBe(2000);
|
||||||
|
|
||||||
// Invalidated alone -> 1210.
|
// Invalidated alone -> 1000.
|
||||||
const onlyInvalid = await getOfferTotals({
|
const onlyInvalid = await getOfferTotals({
|
||||||
search: OFFER_MARKER,
|
search: OFFER_MARKER,
|
||||||
status: "invalidated",
|
status: "invalidated",
|
||||||
});
|
});
|
||||||
expect(amountFor(onlyInvalid.totals, "CZK")).toBe(1210);
|
expect(amountFor(onlyInvalid.totals, "CZK")).toBe(1000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import {
|
|||||||
|
|
||||||
// Per-currency total aggregation for both order lists. These hit the real
|
// Per-currency total aggregation for both order lists. These hit the real
|
||||||
// `app_test` DB via the service layer (suite convention) and prove the /stats
|
// `app_test` DB via the service layer (suite convention) and prove the /stats
|
||||||
// endpoints sum order TOTAL (incl. VAT) per currency across the WHOLE filtered
|
// endpoints sum order NET total per currency across the WHOLE filtered set
|
||||||
// set, and that the where (month/year + status) is respected.
|
// (orders are not tax documents — no VAT anywhere), and that the where
|
||||||
|
// (month/year + status) is respected.
|
||||||
//
|
//
|
||||||
// A far-future month/year is used so seeded/other-test rows can't fall in the
|
// A far-future month/year is used so seeded/other-test rows can't fall in the
|
||||||
// window and skew the deterministic sums (mirrors drafts-aggregation.test.ts).
|
// window and skew the deterministic sums (mirrors drafts-aggregation.test.ts).
|
||||||
@@ -46,19 +47,17 @@ function amountFor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("getOrderTotals (received orders) per-currency aggregation", () => {
|
describe("getOrderTotals (received orders) per-currency aggregation", () => {
|
||||||
it("sums TOTAL incl. VAT per currency over the full filtered set", async () => {
|
it("sums NET total per currency over the full filtered set", async () => {
|
||||||
// Two CZK orders + one EUR, all in the same far-future month. 21% VAT,
|
// Two CZK orders + one EUR, all in the same far-future month. NET only
|
||||||
// applied on the whole subtotal (enrichOrder math).
|
// (enrichOrder math — orders carry no VAT).
|
||||||
// CZK #1: 2 x 1000 = 2000 net -> +21% = 2420
|
// CZK #1: 2 x 1000 = 2000
|
||||||
// CZK #2: 1 x 500 = 500 net -> +21% = 605 => CZK total 3025
|
// CZK #2: 1 x 500 = 500 => CZK total 2500
|
||||||
// EUR : 3 x 100 = 300 net -> +21% = 363 => EUR total 363
|
// EUR : 3 x 100 = 300 => EUR total 300
|
||||||
const mk = async (currency: string, qty: number, price: number) => {
|
const mk = async (currency: string, qty: number, price: number) => {
|
||||||
const res = await createOrder({
|
const res = await createOrder({
|
||||||
status: "prijata",
|
status: "prijata",
|
||||||
currency,
|
currency,
|
||||||
language: "cs",
|
language: "cs",
|
||||||
vat_rate: 21,
|
|
||||||
apply_vat: true,
|
|
||||||
create_project: false,
|
create_project: false,
|
||||||
items: [{ description: "X", quantity: qty, unit_price: price }],
|
items: [{ description: "X", quantity: qty, unit_price: price }],
|
||||||
});
|
});
|
||||||
@@ -83,8 +82,8 @@ describe("getOrderTotals (received orders) per-currency aggregation", () => {
|
|||||||
month: STATS_MONTH,
|
month: STATS_MONTH,
|
||||||
year: STATS_YEAR,
|
year: STATS_YEAR,
|
||||||
});
|
});
|
||||||
expect(amountFor(totals, "CZK")).toBe(3025);
|
expect(amountFor(totals, "CZK")).toBe(2500);
|
||||||
expect(amountFor(totals, "EUR")).toBe(363);
|
expect(amountFor(totals, "EUR")).toBe(300);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("respects the where: a different month and a status filter change the result", async () => {
|
it("respects the where: a different month and a status filter change the result", async () => {
|
||||||
@@ -93,8 +92,6 @@ describe("getOrderTotals (received orders) per-currency aggregation", () => {
|
|||||||
status,
|
status,
|
||||||
currency: "CZK",
|
currency: "CZK",
|
||||||
language: "cs",
|
language: "cs",
|
||||||
vat_rate: 21,
|
|
||||||
apply_vat: true,
|
|
||||||
create_project: false,
|
create_project: false,
|
||||||
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||||
});
|
});
|
||||||
@@ -122,48 +119,43 @@ describe("getOrderTotals (received orders) per-currency aggregation", () => {
|
|||||||
await mk("stornovana", 0);
|
await mk("stornovana", 0);
|
||||||
await mk("prijata", 1);
|
await mk("prijata", 1);
|
||||||
|
|
||||||
// Whole month: both same-month orders count -> 2 x 1210 = 2420.
|
// Whole month: both same-month orders count -> 2 x 1000 = 2000 (net).
|
||||||
const whole = await getOrderTotals({
|
const whole = await getOrderTotals({
|
||||||
month: STATS_MONTH,
|
month: STATS_MONTH,
|
||||||
year: STATS_YEAR,
|
year: STATS_YEAR,
|
||||||
});
|
});
|
||||||
expect(amountFor(whole.totals, "CZK")).toBe(2420);
|
expect(amountFor(whole.totals, "CZK")).toBe(2000);
|
||||||
|
|
||||||
// Status filter narrows to the single 'prijata' in the target month -> 1210.
|
// Status filter narrows to the single 'prijata' in the target month -> 1000.
|
||||||
const onlyPrijata = await getOrderTotals({
|
const onlyPrijata = await getOrderTotals({
|
||||||
month: STATS_MONTH,
|
month: STATS_MONTH,
|
||||||
year: STATS_YEAR,
|
year: STATS_YEAR,
|
||||||
status: "prijata",
|
status: "prijata",
|
||||||
});
|
});
|
||||||
expect(amountFor(onlyPrijata.totals, "CZK")).toBe(1210);
|
expect(amountFor(onlyPrijata.totals, "CZK")).toBe(1000);
|
||||||
|
|
||||||
// Next month sees only the one 'prijata' there -> 1210.
|
// Next month sees only the one 'prijata' there -> 1000.
|
||||||
const nextMonth = await getOrderTotals({
|
const nextMonth = await getOrderTotals({
|
||||||
month: STATS_MONTH + 1,
|
month: STATS_MONTH + 1,
|
||||||
year: STATS_YEAR,
|
year: STATS_YEAR,
|
||||||
});
|
});
|
||||||
expect(amountFor(nextMonth.totals, "CZK")).toBe(1210);
|
expect(amountFor(nextMonth.totals, "CZK")).toBe(1000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () => {
|
describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () => {
|
||||||
it("sums TOTAL incl. VAT per currency over the full filtered set", async () => {
|
it("sums NET total per currency over the full filtered set", async () => {
|
||||||
// order_date is settable at create, so no post-create pin needed. VAT is
|
// order_date is settable at create, so no post-create pin needed. NET only
|
||||||
// applied per-line (computeIssuedOrderTotals) but with a single line per
|
// (computeIssuedOrderTotals — issued orders carry no VAT).
|
||||||
// order here the result matches the on-the-whole subtotal math.
|
// CZK #1: 2 x 1000 = 2000
|
||||||
// CZK #1: 2 x 1000 @21% = 2420
|
// CZK #2: 1 x 500 = 500 => CZK total 2500
|
||||||
// CZK #2: 1 x 500 @21% = 605 => CZK total 3025
|
// EUR : 3 x 100 = 300 => EUR total 300
|
||||||
// EUR : 3 x 100 @21% = 363 => EUR total 363
|
|
||||||
const dateStr = `${STATS_YEAR}-0${STATS_MONTH}-15`;
|
const dateStr = `${STATS_YEAR}-0${STATS_MONTH}-15`;
|
||||||
const mk = async (currency: string, qty: number, price: number) => {
|
const mk = async (currency: string, qty: number, price: number) => {
|
||||||
const o = await createIssuedOrder({
|
const o = await createIssuedOrder({
|
||||||
currency,
|
currency,
|
||||||
vat_rate: 21,
|
|
||||||
apply_vat: true,
|
|
||||||
order_date: dateStr,
|
order_date: dateStr,
|
||||||
items: [
|
items: [{ description: "X", quantity: qty, unit_price: price }],
|
||||||
{ description: "X", quantity: qty, unit_price: price, vat_rate: 21 },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
if ("error" in o) throw new Error(`createIssuedOrder failed: ${o.error}`);
|
if ("error" in o) throw new Error(`createIssuedOrder failed: ${o.error}`);
|
||||||
createdIssuedIds.push(o.id);
|
createdIssuedIds.push(o.id);
|
||||||
@@ -178,23 +170,19 @@ describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () =>
|
|||||||
month: STATS_MONTH,
|
month: STATS_MONTH,
|
||||||
year: STATS_YEAR,
|
year: STATS_YEAR,
|
||||||
});
|
});
|
||||||
expect(amountFor(totals, "CZK")).toBe(3025);
|
expect(amountFor(totals, "CZK")).toBe(2500);
|
||||||
expect(amountFor(totals, "EUR")).toBe(363);
|
expect(amountFor(totals, "EUR")).toBe(300);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("respects the where: a different month and a status filter change the result", async () => {
|
it("respects the where: a different month and a status filter change the result", async () => {
|
||||||
const inMonth = `${STATS_YEAR}-0${STATS_MONTH}-15`;
|
const inMonth = `${STATS_YEAR}-0${STATS_MONTH}-15`;
|
||||||
const nextMonth = `${STATS_YEAR}-0${STATS_MONTH + 1}-15`;
|
const nextMonth = `${STATS_YEAR}-0${STATS_MONTH + 1}-15`;
|
||||||
|
|
||||||
// Target month: one draft, one already-sent (both 1210). Next month: one.
|
// Target month: one draft, one already-sent (both 1000 net). Next month: one.
|
||||||
const draft = await createIssuedOrder({
|
const draft = await createIssuedOrder({
|
||||||
currency: "CZK",
|
currency: "CZK",
|
||||||
vat_rate: 21,
|
|
||||||
apply_vat: true,
|
|
||||||
order_date: inMonth,
|
order_date: inMonth,
|
||||||
items: [
|
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||||
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
if ("error" in draft)
|
if ("error" in draft)
|
||||||
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
||||||
@@ -203,12 +191,8 @@ describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () =>
|
|||||||
const sent = await createIssuedOrder({
|
const sent = await createIssuedOrder({
|
||||||
status: "sent",
|
status: "sent",
|
||||||
currency: "CZK",
|
currency: "CZK",
|
||||||
vat_rate: 21,
|
|
||||||
apply_vat: true,
|
|
||||||
order_date: inMonth,
|
order_date: inMonth,
|
||||||
items: [
|
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||||
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
if ("error" in sent)
|
if ("error" in sent)
|
||||||
throw new Error(`createIssuedOrder failed: ${sent.error}`);
|
throw new Error(`createIssuedOrder failed: ${sent.error}`);
|
||||||
@@ -216,37 +200,33 @@ describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () =>
|
|||||||
|
|
||||||
const next = await createIssuedOrder({
|
const next = await createIssuedOrder({
|
||||||
currency: "CZK",
|
currency: "CZK",
|
||||||
vat_rate: 21,
|
|
||||||
apply_vat: true,
|
|
||||||
order_date: nextMonth,
|
order_date: nextMonth,
|
||||||
items: [
|
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||||
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
if ("error" in next)
|
if ("error" in next)
|
||||||
throw new Error(`createIssuedOrder failed: ${next.error}`);
|
throw new Error(`createIssuedOrder failed: ${next.error}`);
|
||||||
createdIssuedIds.push(next.id);
|
createdIssuedIds.push(next.id);
|
||||||
|
|
||||||
// Whole target month: both count -> 2 x 1210 = 2420.
|
// Whole target month: both count -> 2 x 1000 = 2000 (net).
|
||||||
const whole = await getIssuedOrderTotals({
|
const whole = await getIssuedOrderTotals({
|
||||||
month: STATS_MONTH,
|
month: STATS_MONTH,
|
||||||
year: STATS_YEAR,
|
year: STATS_YEAR,
|
||||||
});
|
});
|
||||||
expect(amountFor(whole.totals, "CZK")).toBe(2420);
|
expect(amountFor(whole.totals, "CZK")).toBe(2000);
|
||||||
|
|
||||||
// Status filter narrows to the single 'sent' in the target month -> 1210.
|
// Status filter narrows to the single 'sent' in the target month -> 1000.
|
||||||
const onlySent = await getIssuedOrderTotals({
|
const onlySent = await getIssuedOrderTotals({
|
||||||
month: STATS_MONTH,
|
month: STATS_MONTH,
|
||||||
year: STATS_YEAR,
|
year: STATS_YEAR,
|
||||||
status: "sent",
|
status: "sent",
|
||||||
});
|
});
|
||||||
expect(amountFor(onlySent.totals, "CZK")).toBe(1210);
|
expect(amountFor(onlySent.totals, "CZK")).toBe(1000);
|
||||||
|
|
||||||
// Next month sees only the one order there -> 1210.
|
// Next month sees only the one order there -> 1000.
|
||||||
const nextStats = await getIssuedOrderTotals({
|
const nextStats = await getIssuedOrderTotals({
|
||||||
month: STATS_MONTH + 1,
|
month: STATS_MONTH + 1,
|
||||||
year: STATS_YEAR,
|
year: STATS_YEAR,
|
||||||
});
|
});
|
||||||
expect(amountFor(nextStats.totals, "CZK")).toBe(1210);
|
expect(amountFor(nextStats.totals, "CZK")).toBe(1000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ const baseOrder = {
|
|||||||
status: "prijata" as const,
|
status: "prijata" as const,
|
||||||
currency: "CZK",
|
currency: "CZK",
|
||||||
language: "cs",
|
language: "cs",
|
||||||
vat_rate: 21,
|
|
||||||
apply_vat: true,
|
|
||||||
exchange_rate: 1,
|
exchange_rate: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
147
src/__tests__/pdf-no-vat.test.ts
Normal file
147
src/__tests__/pdf-no-vat.test.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { config } from "../config/env";
|
||||||
|
import { renderOrderConfirmationHtml } from "../routes/admin/orders-pdf";
|
||||||
|
import offersPdfRoutes from "../routes/admin/offers-pdf";
|
||||||
|
|
||||||
|
// Offers, received orders (their confirmation PDF) and issued orders are NOT
|
||||||
|
// tax documents — VAT must never appear on them. These tests pin that contract
|
||||||
|
// for the confirmation and offer PDFs: no VAT columns/rows/notices, and the
|
||||||
|
// grand total is "Celkem bez DPH" / "Total excl. VAT". (The issued-order PDF
|
||||||
|
// is covered in issued-orders.test.ts.)
|
||||||
|
|
||||||
|
// The explanatory prices-excl.-VAT notice was removed at user request — the
|
||||||
|
// total label alone carries the information. Pin its absence too.
|
||||||
|
const CS_NOTE = "Ceny jsou uvedeny bez DPH";
|
||||||
|
const EN_NOTE = "Prices are exclusive of VAT";
|
||||||
|
|
||||||
|
describe("renderOrderConfirmationHtml (order confirmation PDF)", () => {
|
||||||
|
const order = {
|
||||||
|
order_number: "26730042",
|
||||||
|
customer_order_number: "PO-XYZ",
|
||||||
|
created_at: new Date("2026-06-09T12:00:00"),
|
||||||
|
currency: "CZK",
|
||||||
|
notes: null,
|
||||||
|
customers: null,
|
||||||
|
};
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
description: "Materiál",
|
||||||
|
quantity: 2,
|
||||||
|
unit: "ks",
|
||||||
|
unit_price: 100,
|
||||||
|
is_included_in_total: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it("cs: NET total labeled 'Celkem bez DPH', no VAT columns or notice", () => {
|
||||||
|
const html = renderOrderConfirmationHtml(order, items, null, "cs", "Jan");
|
||||||
|
expect(html).not.toContain("%DPH");
|
||||||
|
expect(html).not.toContain(">DPH<");
|
||||||
|
expect(html).not.toContain("Mezisoučet");
|
||||||
|
// Line total = netto (2 × 100), no VAT-on-top anywhere.
|
||||||
|
expect(html).toContain('<td class="right total-cell">200,00</td>');
|
||||||
|
expect(html).toContain("Celkem bez DPH");
|
||||||
|
expect(html).not.toContain(CS_NOTE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("en: 'Total excl. VAT', no VAT columns or notice", () => {
|
||||||
|
const html = renderOrderConfirmationHtml(order, items, null, "en", "Jan");
|
||||||
|
expect(html).not.toContain("VAT%");
|
||||||
|
expect(html).toContain("Total excl. VAT");
|
||||||
|
expect(html).not.toContain(EN_NOTE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes not-included lines from the NET total", () => {
|
||||||
|
const html = renderOrderConfirmationHtml(
|
||||||
|
order,
|
||||||
|
[
|
||||||
|
...items,
|
||||||
|
{
|
||||||
|
description: "Mimo cenu",
|
||||||
|
quantity: 1,
|
||||||
|
unit: "ks",
|
||||||
|
unit_price: 999,
|
||||||
|
is_included_in_total: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
null,
|
||||||
|
"cs",
|
||||||
|
"Jan",
|
||||||
|
);
|
||||||
|
// Grand total stays 200,00 — the excluded line doesn't count.
|
||||||
|
expect(html).toContain("200,00 CZK");
|
||||||
|
expect(html).not.toContain("1 199,00 CZK");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Offer PDF route (returns the HTML for the print view) */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
let app: ReturnType<typeof Fastify> | null = null;
|
||||||
|
let adminToken = "";
|
||||||
|
const createdQuotationIds: number[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
await app.register(offersPdfRoutes, { prefix: "/api/admin/offers-pdf" });
|
||||||
|
|
||||||
|
const admin = await prisma.users.findFirst({
|
||||||
|
where: { roles: { name: "admin" } },
|
||||||
|
});
|
||||||
|
if (!admin) throw new Error("Test setup: admin user not found");
|
||||||
|
adminToken = jwt.sign(
|
||||||
|
{ sub: admin.id, username: admin.username, role: "admin" },
|
||||||
|
config.jwt.secret,
|
||||||
|
{ expiresIn: "15m" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const id of createdQuotationIds)
|
||||||
|
await prisma.quotations.deleteMany({ where: { id } });
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/admin/offers-pdf/:id (offer PDF html)", () => {
|
||||||
|
it("renders NET-only totals with 'Celkem bez DPH', no notice", async () => {
|
||||||
|
// Direct prisma insert (explicit number → no sequence consumed).
|
||||||
|
const quotation = await prisma.quotations.create({
|
||||||
|
data: {
|
||||||
|
quotation_number: `Q-VATNOTE-${Date.now()}`,
|
||||||
|
status: "active",
|
||||||
|
currency: "CZK",
|
||||||
|
language: "cs",
|
||||||
|
quotation_items: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
description: "Položka",
|
||||||
|
quantity: 2,
|
||||||
|
unit: "ks",
|
||||||
|
unit_price: 1000,
|
||||||
|
is_included_in_total: true,
|
||||||
|
position: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdQuotationIds.push(quotation.id);
|
||||||
|
|
||||||
|
const res = await app!.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/admin/offers-pdf/${quotation.id}`,
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const html = res.body;
|
||||||
|
expect(html).toContain("Celkem bez DPH");
|
||||||
|
expect(html).not.toContain(CS_NOTE);
|
||||||
|
expect(html).not.toContain("%DPH");
|
||||||
|
expect(html).not.toContain("Mezisoučet");
|
||||||
|
expect(html).not.toContain("Celkem k úhradě");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -58,34 +58,6 @@ describe("Zod form coercion + NaN rejection", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("CreateQuotationSchema", () => {
|
describe("CreateQuotationSchema", () => {
|
||||||
it("rejects NaN string in top-level vat_rate", () => {
|
|
||||||
const result = CreateQuotationSchema.safeParse({
|
|
||||||
customer_id: 1,
|
|
||||||
vat_rate: "bad",
|
|
||||||
});
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts valid vat_rate", () => {
|
|
||||||
const result = CreateQuotationSchema.safeParse({
|
|
||||||
customer_id: 1,
|
|
||||||
vat_rate: 21,
|
|
||||||
});
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("coerces a valid numeric STRING vat_rate to a number", () => {
|
|
||||||
const result = CreateQuotationSchema.safeParse({
|
|
||||||
customer_id: 1,
|
|
||||||
vat_rate: "21",
|
|
||||||
});
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
if (result.success) {
|
|
||||||
expect(result.data.vat_rate).toBe(21);
|
|
||||||
expect(typeof result.data.vat_rate).toBe("number");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects NaN string in item quantity", () => {
|
it("rejects NaN string in item quantity", () => {
|
||||||
const result = CreateQuotationSchema.safeParse({
|
const result = CreateQuotationSchema.safeParse({
|
||||||
customer_id: 1,
|
customer_id: 1,
|
||||||
@@ -300,8 +272,8 @@ describe("schema hardening — does not reject previously-valid input", () => {
|
|||||||
expect(b.success).toBe(true);
|
expect(b.success).toBe(true);
|
||||||
if (b.success) expect(b.data.trip_date).toBe("2026-06-09");
|
if (b.success) expect(b.data.trip_date).toBe("2026-06-09");
|
||||||
// Genuinely malformed dates are still rejected.
|
// Genuinely malformed dates are still rejected.
|
||||||
expect(CreateTripSchema.safeParse({ ...base, trip_date: "09/06/2026" }).success).toBe(
|
expect(
|
||||||
false,
|
CreateTripSchema.safeParse({ ...base, trip_date: "09/06/2026" }).success,
|
||||||
);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { useTheme } from "@mui/material/styles";
|
|||||||
import { Modal, Button, TextField, Field } from "../ui";
|
import { Modal, Button, TextField, Field } from "../ui";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
|
|
||||||
// Editable line-item. quantity/unit_price/vat_rate are held as the raw typed
|
// Editable line-item. quantity/unit_price are held as the raw typed string
|
||||||
// string while editing (so a field can be cleared — empty renders fine in a
|
// while editing (so a field can be cleared — empty renders fine in a
|
||||||
// type="number" input) and are coerced to numbers in handleEditGenerate before
|
// type="number" input) and are coerced to numbers in handleEditGenerate before
|
||||||
// being handed to onGenerate (the parent posts them verbatim).
|
// being handed to onGenerate (the parent posts them verbatim).
|
||||||
interface ConfirmationItem {
|
interface ConfirmationItem {
|
||||||
@@ -17,7 +17,6 @@ interface ConfirmationItem {
|
|||||||
unit: string;
|
unit: string;
|
||||||
unit_price: string | number;
|
unit_price: string | number;
|
||||||
is_included_in_total: boolean;
|
is_included_in_total: boolean;
|
||||||
vat_rate: string | number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Numeric shape handed to the parent (the raw editing strings are coerced in
|
// Numeric shape handed to the parent (the raw editing strings are coerced in
|
||||||
@@ -28,21 +27,14 @@ interface GeneratedItem {
|
|||||||
unit: string;
|
unit: string;
|
||||||
unit_price: number;
|
unit_price: number;
|
||||||
is_included_in_total: boolean;
|
is_included_in_total: boolean;
|
||||||
vat_rate: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OrderConfirmationModalProps {
|
interface OrderConfirmationModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onGenerate: (
|
onGenerate: (lang: string, items?: GeneratedItem[]) => Promise<void>;
|
||||||
lang: string,
|
|
||||||
applyVat: boolean,
|
|
||||||
items?: GeneratedItem[],
|
|
||||||
) => Promise<void>;
|
|
||||||
initialItems: ConfirmationItem[];
|
initialItems: ConfirmationItem[];
|
||||||
orderNumber: string;
|
orderNumber: string;
|
||||||
defaultVatRate: number;
|
|
||||||
applyVat: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OrderConfirmationModal({
|
export default function OrderConfirmationModal({
|
||||||
@@ -51,15 +43,12 @@ export default function OrderConfirmationModal({
|
|||||||
onGenerate,
|
onGenerate,
|
||||||
initialItems,
|
initialItems,
|
||||||
orderNumber,
|
orderNumber,
|
||||||
defaultVatRate,
|
|
||||||
applyVat,
|
|
||||||
}: OrderConfirmationModalProps) {
|
}: OrderConfirmationModalProps) {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||||
const [step, setStep] = useState<"choose" | "edit">("choose");
|
const [step, setStep] = useState<"choose" | "edit">("choose");
|
||||||
const [lang, setLang] = useState<string>("cs");
|
const [lang, setLang] = useState<string>("cs");
|
||||||
const [applyVatState, setApplyVatState] = useState(applyVat);
|
|
||||||
const [items, setItems] = useState<ConfirmationItem[]>(initialItems);
|
const [items, setItems] = useState<ConfirmationItem[]>(initialItems);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
@@ -72,17 +61,16 @@ export default function OrderConfirmationModal({
|
|||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
setStep("choose");
|
setStep("choose");
|
||||||
setLang("cs");
|
setLang("cs");
|
||||||
setApplyVatState(applyVat);
|
|
||||||
setItems(initialItems);
|
setItems(initialItems);
|
||||||
// initialItems/applyVat are captured at open time; intentionally not in the
|
// initialItems are captured at open time; intentionally not in the dep
|
||||||
// dep array so an unrelated parent re-render doesn't clobber the user's edits.
|
// array so an unrelated parent re-render doesn't clobber the user's edits.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
const handleUseExisting = async () => {
|
const handleUseExisting = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await onGenerate(lang, applyVatState, undefined);
|
await onGenerate(lang, undefined);
|
||||||
// Only close on success — a generation error must keep the modal open.
|
// Only close on success — a generation error must keep the modal open.
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -104,9 +92,8 @@ export default function OrderConfirmationModal({
|
|||||||
unit: it.unit,
|
unit: it.unit,
|
||||||
unit_price: Number(it.unit_price) || 0,
|
unit_price: Number(it.unit_price) || 0,
|
||||||
is_included_in_total: it.is_included_in_total,
|
is_included_in_total: it.is_included_in_total,
|
||||||
vat_rate: Number(it.vat_rate) || 0,
|
|
||||||
}));
|
}));
|
||||||
await onGenerate(lang, applyVatState, coercedItems);
|
await onGenerate(lang, coercedItems);
|
||||||
// Only close on success — on error keep the user's edited items intact.
|
// Only close on success — on error keep the user's edited items intact.
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -145,10 +132,9 @@ export default function OrderConfirmationModal({
|
|||||||
unit: "ks",
|
unit: "ks",
|
||||||
unit_price: 0,
|
unit_price: 0,
|
||||||
is_included_in_total: true,
|
is_included_in_total: true,
|
||||||
vat_rate: defaultVatRate,
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}, [defaultVatRate]);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -186,27 +172,6 @@ export default function OrderConfirmationModal({
|
|||||||
</Box>
|
</Box>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label="DPH">
|
|
||||||
<Box sx={{ display: "flex", gap: 1 }}>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant={applyVatState ? "contained" : "outlined"}
|
|
||||||
color={applyVatState ? "primary" : "inherit"}
|
|
||||||
onClick={() => setApplyVatState(true)}
|
|
||||||
>
|
|
||||||
S DPH
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant={!applyVatState ? "contained" : "outlined"}
|
|
||||||
color={!applyVatState ? "primary" : "inherit"}
|
|
||||||
onClick={() => setApplyVatState(false)}
|
|
||||||
>
|
|
||||||
Bez DPH
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Obsah potvrzení">
|
<Field label="Obsah potvrzení">
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||||
Jak chcete připravit potvrzení objednávky?
|
Jak chcete připravit potvrzení objednávky?
|
||||||
@@ -291,7 +256,7 @@ export default function OrderConfirmationModal({
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
|
||||||
gap: 1,
|
gap: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -318,15 +283,6 @@ export default function OrderConfirmationModal({
|
|||||||
}
|
}
|
||||||
slotProps={{ htmlInput: { step: "0.01" } }}
|
slotProps={{ htmlInput: { step: "0.01" } }}
|
||||||
/>
|
/>
|
||||||
<TextField
|
|
||||||
label="%DPH"
|
|
||||||
type="number"
|
|
||||||
value={item.vat_rate ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateItem(i, "vat_rate", e.target.value)
|
|
||||||
}
|
|
||||||
slotProps={{ htmlInput: { step: "1" } }}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
@@ -356,7 +312,6 @@ export default function OrderConfirmationModal({
|
|||||||
<th>Mn.</th>
|
<th>Mn.</th>
|
||||||
<th>Jedn.</th>
|
<th>Jedn.</th>
|
||||||
<th>Cena</th>
|
<th>Cena</th>
|
||||||
<th>%DPH</th>
|
|
||||||
<th style={{ width: "40px" }} />
|
<th style={{ width: "40px" }} />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -403,17 +358,6 @@ export default function OrderConfirmationModal({
|
|||||||
slotProps={{ htmlInput: { step: "0.01" } }}
|
slotProps={{ htmlInput: { step: "0.01" } }}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
|
||||||
<TextField
|
|
||||||
type="number"
|
|
||||||
value={item.vat_rate ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateItem(i, "vat_rate", e.target.value)
|
|
||||||
}
|
|
||||||
sx={{ width: 80 }}
|
|
||||||
slotProps={{ htmlInput: { step: "1" } }}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
<td>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -1048,6 +1048,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
setShowCreateModal(false);
|
setShowCreateModal(false);
|
||||||
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||||
|
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
|
||||||
|
// punch-button state) — without this it serves a stale cache for up
|
||||||
|
// to its staleTime after records change here.
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
await fetchData(false);
|
await fetchData(false);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
alert.success(
|
alert.success(
|
||||||
@@ -1120,6 +1124,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
setShowBulkModal(false);
|
setShowBulkModal(false);
|
||||||
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||||
|
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
|
||||||
|
// punch-button state) — without this it serves a stale cache for up
|
||||||
|
// to its staleTime after records change here.
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
await fetchData(false);
|
await fetchData(false);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
alert.success(
|
alert.success(
|
||||||
@@ -1255,6 +1263,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||||
|
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
|
||||||
|
// punch-button state) — without this it serves a stale cache for up
|
||||||
|
// to its staleTime after records change here.
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
await fetchData(false);
|
await fetchData(false);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
alert.success(
|
alert.success(
|
||||||
@@ -1285,6 +1297,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
setDeleteConfirm({ show: false, record: null });
|
setDeleteConfirm({ show: false, record: null });
|
||||||
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||||
|
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
|
||||||
|
// punch-button state) — without this it serves a stale cache for up
|
||||||
|
// to its staleTime after records change here.
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
await fetchData(false);
|
await fetchData(false);
|
||||||
alert.success(
|
alert.success(
|
||||||
result.message || result.data?.message || "Záznam smazán",
|
result.message || result.data?.message || "Záznam smazán",
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ export const dashboardOptions = () =>
|
|||||||
queryKey: ["dashboard"],
|
queryKey: ["dashboard"],
|
||||||
queryFn: () => jsonQuery<Record<string, unknown>>("/api/admin/dashboard"),
|
queryFn: () => jsonQuery<Record<string, unknown>>("/api/admin/dashboard"),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
|
// The dashboard aggregates MANY domains (attendance, offers, invoices,
|
||||||
|
// orders, projects, leave). Mutations in those domains can't all be
|
||||||
|
// expected to invalidate ["dashboard"], so always refetch on mount —
|
||||||
|
// navigating back to the dashboard must never show pre-mutation data.
|
||||||
|
refetchOnMount: "always",
|
||||||
});
|
});
|
||||||
|
|
||||||
// require2FAOptions lives in ./settings.ts (the single definition consumers
|
// require2FAOptions lives in ./settings.ts (the single definition consumers
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ export interface IssuedOrder {
|
|||||||
status: string;
|
status: string;
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
order_date: string | null;
|
order_date: string | null;
|
||||||
subtotal: number;
|
// NET total — issued orders carry no VAT (not tax documents).
|
||||||
vat_amount: number;
|
|
||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,19 +31,17 @@ export interface IssuedOrderItem {
|
|||||||
quantity: number | string | null;
|
quantity: number | string | null;
|
||||||
unit: string | null;
|
unit: string | null;
|
||||||
unit_price: number | string | null;
|
unit_price: number | string | null;
|
||||||
vat_rate: number | string | null;
|
|
||||||
position?: number;
|
position?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IssuedOrderDetail extends IssuedOrder {
|
export interface IssuedOrderDetail extends IssuedOrder {
|
||||||
apply_vat: boolean | null;
|
|
||||||
vat_rate: number | string | null;
|
|
||||||
exchange_rate: number | string | null;
|
exchange_rate: number | string | null;
|
||||||
delivery_date: string | null;
|
delivery_date: string | null;
|
||||||
language: string | null;
|
language: string | null;
|
||||||
delivery_terms: string | null;
|
delivery_terms: string | null;
|
||||||
payment_terms: string | null;
|
payment_terms: string | null;
|
||||||
issued_by: string | null;
|
issued_by: string | null;
|
||||||
|
order_text: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
internal_notes: string | null;
|
internal_notes: string | null;
|
||||||
items: IssuedOrderItem[];
|
items: IssuedOrderItem[];
|
||||||
|
|||||||
@@ -157,8 +157,6 @@ export interface OfferDetailData {
|
|||||||
valid_until: string;
|
valid_until: string;
|
||||||
currency: string;
|
currency: string;
|
||||||
language: string;
|
language: string;
|
||||||
vat_rate: number;
|
|
||||||
apply_vat: boolean;
|
|
||||||
items?: OfferItemData[];
|
items?: OfferItemData[];
|
||||||
sections?: OfferSectionData[];
|
sections?: OfferSectionData[];
|
||||||
status: string;
|
status: string;
|
||||||
|
|||||||
@@ -43,8 +43,6 @@ export interface OrderData {
|
|||||||
status: string;
|
status: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
attachment_name?: string;
|
attachment_name?: string;
|
||||||
apply_vat: number | boolean;
|
|
||||||
vat_rate: number;
|
|
||||||
language?: string;
|
language?: string;
|
||||||
items: OrderItem[];
|
items: OrderItem[];
|
||||||
sections: OrderSection[];
|
sections: OrderSection[];
|
||||||
|
|||||||
@@ -274,7 +274,8 @@ export default function Attendance() {
|
|||||||
>({
|
>({
|
||||||
url: () => `${API_BASE}/attendance`,
|
url: () => `${API_BASE}/attendance`,
|
||||||
method: () => "POST",
|
method: () => "POST",
|
||||||
invalidate: ["attendance"],
|
// dashboard included: the punch-button state + presence cards live there.
|
||||||
|
invalidate: ["attendance", "dashboard"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const notesMutation = useApiMutation<{ notes: string }, { message?: string }>(
|
const notesMutation = useApiMutation<{ notes: string }, { message?: string }>(
|
||||||
@@ -300,7 +301,8 @@ export default function Attendance() {
|
|||||||
>({
|
>({
|
||||||
url: () => `${API_BASE}/leave-requests`,
|
url: () => `${API_BASE}/leave-requests`,
|
||||||
method: () => "POST",
|
method: () => "POST",
|
||||||
invalidate: ["attendance", "leave-requests", "leave", "users"],
|
// dashboard included: approvers see the pending-requests KPI there.
|
||||||
|
invalidate: ["attendance", "leave-requests", "leave", "users", "dashboard"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export default function AttendanceCreate() {
|
|||||||
const createMutation = useApiMutation<CreatePayload, { message?: string }>({
|
const createMutation = useApiMutation<CreatePayload, { message?: string }>({
|
||||||
url: () => `${API_BASE}/attendance`,
|
url: () => `${API_BASE}/attendance`,
|
||||||
method: () => "POST",
|
method: () => "POST",
|
||||||
invalidate: ["attendance", "users"],
|
invalidate: ["attendance", "users", "dashboard"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const [form, setForm] = useState<CreateForm>(() => {
|
const [form, setForm] = useState<CreateForm>(() => {
|
||||||
|
|||||||
@@ -787,13 +787,12 @@ export default function InvoiceDetail() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-fill from order
|
// Pre-fill from order. Orders no longer carry VAT (not tax documents) —
|
||||||
|
// the invoice decides its own VAT: default rate from company settings,
|
||||||
|
// apply_vat on.
|
||||||
if (fromOrderId && orderDataQuery.data) {
|
if (fromOrderId && orderDataQuery.data) {
|
||||||
const order = orderDataQuery.data;
|
const order = orderDataQuery.data;
|
||||||
const vatRate = numberOr(
|
const vatRate = numberOr(companySettings?.default_vat_rate, 21);
|
||||||
order.vat_rate,
|
|
||||||
companySettings?.default_vat_rate ?? 21,
|
|
||||||
);
|
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
customer_id: order.customer_id as number,
|
customer_id: order.customer_id as number,
|
||||||
@@ -803,7 +802,7 @@ export default function InvoiceDetail() {
|
|||||||
(order.currency as string) ||
|
(order.currency as string) ||
|
||||||
companySettings?.default_currency ||
|
companySettings?.default_currency ||
|
||||||
"CZK",
|
"CZK",
|
||||||
apply_vat: Number(order.apply_vat) || 0,
|
apply_vat: 1,
|
||||||
vat_rate: vatRate,
|
vat_rate: vatRate,
|
||||||
}));
|
}));
|
||||||
const orderItems = order.items as Record<string, unknown>[] | undefined;
|
const orderItems = order.items as Record<string, unknown>[] | undefined;
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ import {
|
|||||||
DateField,
|
DateField,
|
||||||
Field,
|
Field,
|
||||||
StatusChip,
|
StatusChip,
|
||||||
CheckboxField,
|
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
LoadingState,
|
LoadingState,
|
||||||
PageEnter,
|
PageEnter,
|
||||||
@@ -85,11 +84,6 @@ const TRANSITION_LABELS: Record<string, string> = {
|
|||||||
cancelled: "Stornovat",
|
cancelled: "Stornovat",
|
||||||
};
|
};
|
||||||
|
|
||||||
const VAT_OPTIONS = [0, 10, 12, 15, 21].map((v) => ({
|
|
||||||
value: String(v),
|
|
||||||
label: `${v}%`,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const CURRENCY_FALLBACK = ["CZK", "EUR", "USD", "GBP"];
|
const CURRENCY_FALLBACK = ["CZK", "EUR", "USD", "GBP"];
|
||||||
|
|
||||||
const BackIcon = (
|
const BackIcon = (
|
||||||
@@ -151,26 +145,23 @@ interface OrderItem {
|
|||||||
item_description: string;
|
item_description: string;
|
||||||
// Held as the raw typed string while editing so the field can be cleared
|
// Held as the raw typed string while editing so the field can be cleared
|
||||||
// (empty renders fine in a type="number" input). Coerced via Number(x) || 0
|
// (empty renders fine in a type="number" input). Coerced via Number(x) || 0
|
||||||
// only where used (live totals + save payload). vat_rate stays numeric: it
|
// only where used (live totals + save payload).
|
||||||
// is edited via a Select, never a free-text number input.
|
|
||||||
quantity: string | number;
|
quantity: string | number;
|
||||||
unit: string;
|
unit: string;
|
||||||
unit_price: string | number;
|
unit_price: string | number;
|
||||||
vat_rate: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OrderForm {
|
interface OrderForm {
|
||||||
supplier_id: number | null;
|
supplier_id: number | null;
|
||||||
supplier_name: string;
|
supplier_name: string;
|
||||||
currency: string;
|
currency: string;
|
||||||
apply_vat: boolean;
|
|
||||||
vat_rate: number;
|
|
||||||
order_date: string;
|
order_date: string;
|
||||||
delivery_date: string;
|
delivery_date: string;
|
||||||
language: string;
|
language: string;
|
||||||
delivery_terms: string;
|
delivery_terms: string;
|
||||||
payment_terms: string;
|
payment_terms: string;
|
||||||
issued_by: string;
|
issued_by: string;
|
||||||
|
order_text: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
internal_notes: string;
|
internal_notes: string;
|
||||||
status: string;
|
status: string;
|
||||||
@@ -181,7 +172,6 @@ function SortableOrderRow({
|
|||||||
item,
|
item,
|
||||||
index,
|
index,
|
||||||
currency,
|
currency,
|
||||||
apply_vat,
|
|
||||||
readOnly,
|
readOnly,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onRemove,
|
onRemove,
|
||||||
@@ -190,7 +180,6 @@ function SortableOrderRow({
|
|||||||
item: OrderItem;
|
item: OrderItem;
|
||||||
index: number;
|
index: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
apply_vat: boolean;
|
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
onUpdate: (
|
onUpdate: (
|
||||||
index: number,
|
index: number,
|
||||||
@@ -289,7 +278,7 @@ function SortableOrderRow({
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
|
||||||
gap: 1,
|
gap: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -316,21 +305,6 @@ function SortableOrderRow({
|
|||||||
slotProps={{ htmlInput: { step: "any" } }}
|
slotProps={{ htmlInput: { step: "any" } }}
|
||||||
InputProps={{ readOnly }}
|
InputProps={{ readOnly }}
|
||||||
/>
|
/>
|
||||||
{apply_vat &&
|
|
||||||
(readOnly ? (
|
|
||||||
<TextField
|
|
||||||
label="DPH"
|
|
||||||
value={`${Number(item.vat_rate)}%`}
|
|
||||||
InputProps={{ readOnly: true }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Select
|
|
||||||
label="DPH"
|
|
||||||
value={String(item.vat_rate)}
|
|
||||||
onChange={(val) => onUpdate(index, "vat_rate", Number(val))}
|
|
||||||
options={VAT_OPTIONS}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -435,20 +409,6 @@ function SortableOrderRow({
|
|||||||
sx={{ "& input": { textAlign: "right" } }}
|
sx={{ "& input": { textAlign: "right" } }}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{apply_vat ? (
|
|
||||||
<TableCell>
|
|
||||||
{readOnly ? (
|
|
||||||
<Box sx={{ textAlign: "center" }}>{Number(item.vat_rate)}%</Box>
|
|
||||||
) : (
|
|
||||||
<Select
|
|
||||||
value={String(item.vat_rate)}
|
|
||||||
onChange={(val) => onUpdate(index, "vat_rate", Number(val))}
|
|
||||||
sx={{ minWidth: "4.5rem" }}
|
|
||||||
options={VAT_OPTIONS}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
) : null}
|
|
||||||
<TableCell
|
<TableCell
|
||||||
align="right"
|
align="right"
|
||||||
sx={{
|
sx={{
|
||||||
@@ -495,7 +455,6 @@ export default function IssuedOrderDetail() {
|
|||||||
quantity: 1,
|
quantity: 1,
|
||||||
unit: "ks",
|
unit: "ks",
|
||||||
unit_price: 0,
|
unit_price: 0,
|
||||||
vat_rate: 21,
|
|
||||||
}),
|
}),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@@ -512,14 +471,13 @@ export default function IssuedOrderDetail() {
|
|||||||
supplier_id: null,
|
supplier_id: null,
|
||||||
supplier_name: "",
|
supplier_name: "",
|
||||||
currency: "CZK",
|
currency: "CZK",
|
||||||
apply_vat: true,
|
|
||||||
vat_rate: 21,
|
|
||||||
order_date: todayLocalStr(),
|
order_date: todayLocalStr(),
|
||||||
delivery_date: "",
|
delivery_date: "",
|
||||||
language: "cs",
|
language: "cs",
|
||||||
delivery_terms: "",
|
delivery_terms: "",
|
||||||
payment_terms: "",
|
payment_terms: "",
|
||||||
issued_by: user?.fullName || "",
|
issued_by: user?.fullName || "",
|
||||||
|
order_text: "",
|
||||||
notes: "",
|
notes: "",
|
||||||
internal_notes: "",
|
internal_notes: "",
|
||||||
status: "draft",
|
status: "draft",
|
||||||
@@ -532,7 +490,6 @@ export default function IssuedOrderDetail() {
|
|||||||
quantity: 1,
|
quantity: 1,
|
||||||
unit: "ks",
|
unit: "ks",
|
||||||
unit_price: 0,
|
unit_price: 0,
|
||||||
vat_rate: 21,
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
@@ -613,14 +570,13 @@ export default function IssuedOrderDetail() {
|
|||||||
supplier_id: d.supplier_id ?? null,
|
supplier_id: d.supplier_id ?? null,
|
||||||
supplier_name: d.supplier_name ?? "",
|
supplier_name: d.supplier_name ?? "",
|
||||||
currency: d.currency || "CZK",
|
currency: d.currency || "CZK",
|
||||||
apply_vat: d.apply_vat !== false,
|
|
||||||
vat_rate: numberOr(d.vat_rate, 21),
|
|
||||||
order_date: normalizeDateStr(d.order_date),
|
order_date: normalizeDateStr(d.order_date),
|
||||||
delivery_date: normalizeDateStr(d.delivery_date),
|
delivery_date: normalizeDateStr(d.delivery_date),
|
||||||
language: d.language || "cs",
|
language: d.language || "cs",
|
||||||
delivery_terms: d.delivery_terms || "",
|
delivery_terms: d.delivery_terms || "",
|
||||||
payment_terms: d.payment_terms || "",
|
payment_terms: d.payment_terms || "",
|
||||||
issued_by: d.issued_by || "",
|
issued_by: d.issued_by || "",
|
||||||
|
order_text: d.order_text || "",
|
||||||
notes: d.notes || "",
|
notes: d.notes || "",
|
||||||
internal_notes: d.internal_notes || "",
|
internal_notes: d.internal_notes || "",
|
||||||
status: d.status,
|
status: d.status,
|
||||||
@@ -637,7 +593,6 @@ export default function IssuedOrderDetail() {
|
|||||||
quantity: numberOr(it.quantity, 1),
|
quantity: numberOr(it.quantity, 1),
|
||||||
unit: it.unit || "",
|
unit: it.unit || "",
|
||||||
unit_price: Number(it.unit_price) || 0,
|
unit_price: Number(it.unit_price) || 0,
|
||||||
vat_rate: numberOr(it.vat_rate, numberOr(d.vat_rate, 21)),
|
|
||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
if (mapped.length > 0) setItems(mapped);
|
if (mapped.length > 0) setItems(mapped);
|
||||||
@@ -680,21 +635,14 @@ export default function IssuedOrderDetail() {
|
|||||||
const editable = !isEdit || form.status === "draft" || form.status === "sent";
|
const editable = !isEdit || form.status === "draft" || form.status === "sent";
|
||||||
const canExport = hasPermission("orders.view");
|
const canExport = hasPermission("orders.view");
|
||||||
|
|
||||||
// ─── Totals (live) ───
|
// ─── Totals (live, NET only — issued orders carry no VAT) ───
|
||||||
const totals = useMemo(() => {
|
const totals = useMemo(() => {
|
||||||
let subtotal = 0;
|
let total = 0;
|
||||||
const vatByRate: Record<number, number> = {};
|
|
||||||
items.forEach((it) => {
|
items.forEach((it) => {
|
||||||
const line = (Number(it.quantity) || 0) * (Number(it.unit_price) || 0);
|
total += (Number(it.quantity) || 0) * (Number(it.unit_price) || 0);
|
||||||
subtotal += line;
|
|
||||||
if (form.apply_vat) {
|
|
||||||
const rate = Number(it.vat_rate) || 0;
|
|
||||||
vatByRate[rate] = (vatByRate[rate] || 0) + (line * rate) / 100;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
const totalVat = Object.values(vatByRate).reduce((s, v) => s + v, 0);
|
return { total };
|
||||||
return { subtotal, vatByRate, totalVat, total: subtotal + totalVat };
|
}, [items]);
|
||||||
}, [items, form.apply_vat]);
|
|
||||||
|
|
||||||
// ─── Mutations ───
|
// ─── Mutations ───
|
||||||
const saveMutation = useApiMutation<Record<string, unknown>, { id: number }>({
|
const saveMutation = useApiMutation<Record<string, unknown>, { id: number }>({
|
||||||
@@ -771,14 +719,13 @@ export default function IssuedOrderDetail() {
|
|||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
supplier_id: form.supplier_id,
|
supplier_id: form.supplier_id,
|
||||||
currency: form.currency,
|
currency: form.currency,
|
||||||
vat_rate: form.vat_rate,
|
|
||||||
apply_vat: form.apply_vat,
|
|
||||||
order_date: form.order_date,
|
order_date: form.order_date,
|
||||||
delivery_date: form.delivery_date || null,
|
delivery_date: form.delivery_date || null,
|
||||||
language: form.language,
|
language: form.language,
|
||||||
delivery_terms: form.delivery_terms,
|
delivery_terms: form.delivery_terms,
|
||||||
payment_terms: form.payment_terms,
|
payment_terms: form.payment_terms,
|
||||||
issued_by: form.issued_by,
|
issued_by: form.issued_by,
|
||||||
|
order_text: form.order_text || null,
|
||||||
notes: form.notes,
|
notes: form.notes,
|
||||||
internal_notes: form.internal_notes,
|
internal_notes: form.internal_notes,
|
||||||
items: items
|
items: items
|
||||||
@@ -791,7 +738,6 @@ export default function IssuedOrderDetail() {
|
|||||||
quantity: Number(it.quantity) || 0,
|
quantity: Number(it.quantity) || 0,
|
||||||
unit: it.unit,
|
unit: it.unit,
|
||||||
unit_price: Number(it.unit_price) || 0,
|
unit_price: Number(it.unit_price) || 0,
|
||||||
vat_rate: it.vat_rate,
|
|
||||||
position: i,
|
position: i,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
@@ -1171,7 +1117,7 @@ export default function IssuedOrderDetail() {
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr 1fr" },
|
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" },
|
||||||
gap: 2,
|
gap: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1198,31 +1144,6 @@ export default function IssuedOrderDetail() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Sazba DPH">
|
|
||||||
<Select
|
|
||||||
value={String(form.vat_rate)}
|
|
||||||
disabled={!editable || !form.apply_vat}
|
|
||||||
onChange={(val) =>
|
|
||||||
setForm((prev) => ({ ...prev, vat_rate: Number(val) }))
|
|
||||||
}
|
|
||||||
options={VAT_OPTIONS}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="DPH">
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", height: 40 }}>
|
|
||||||
<CheckboxField
|
|
||||||
label="Uplatnit DPH"
|
|
||||||
checked={form.apply_vat}
|
|
||||||
disabled={!editable}
|
|
||||||
onChange={(v) =>
|
|
||||||
setForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
apply_vat: v,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Field>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Field label="Vystavil">
|
<Field label="Vystavil">
|
||||||
@@ -1287,7 +1208,6 @@ export default function IssuedOrderDetail() {
|
|||||||
item={item}
|
item={item}
|
||||||
index={index}
|
index={index}
|
||||||
currency={form.currency}
|
currency={form.currency}
|
||||||
apply_vat={form.apply_vat}
|
|
||||||
readOnly={!editable}
|
readOnly={!editable}
|
||||||
onUpdate={updateItem}
|
onUpdate={updateItem}
|
||||||
onRemove={removeItem}
|
onRemove={removeItem}
|
||||||
@@ -1320,11 +1240,6 @@ export default function IssuedOrderDetail() {
|
|||||||
<TableCell sx={{ width: "8rem" }} align="center">
|
<TableCell sx={{ width: "8rem" }} align="center">
|
||||||
Jedn. cena
|
Jedn. cena
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{form.apply_vat ? (
|
|
||||||
<TableCell sx={{ width: "5rem" }} align="center">
|
|
||||||
DPH
|
|
||||||
</TableCell>
|
|
||||||
) : null}
|
|
||||||
<TableCell sx={{ width: "8rem" }} align="right">
|
<TableCell sx={{ width: "8rem" }} align="right">
|
||||||
Celkem
|
Celkem
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -1338,7 +1253,6 @@ export default function IssuedOrderDetail() {
|
|||||||
item={item}
|
item={item}
|
||||||
index={index}
|
index={index}
|
||||||
currency={form.currency}
|
currency={form.currency}
|
||||||
apply_vat={form.apply_vat}
|
|
||||||
readOnly={!editable}
|
readOnly={!editable}
|
||||||
onUpdate={updateItem}
|
onUpdate={updateItem}
|
||||||
onRemove={removeItem}
|
onRemove={removeItem}
|
||||||
@@ -1352,7 +1266,7 @@ export default function IssuedOrderDetail() {
|
|||||||
</SortableContext>
|
</SortableContext>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
|
|
||||||
{/* Totals */}
|
{/* Totals (NET only — issued orders are not tax documents) */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
mt: 2,
|
mt: 2,
|
||||||
@@ -1363,34 +1277,6 @@ export default function IssuedOrderDetail() {
|
|||||||
gap: 0.5,
|
gap: 0.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Mezisoučet:
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
|
|
||||||
>
|
|
||||||
{formatCurrency(totals.subtotal, form.currency)}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
{form.apply_vat &&
|
|
||||||
Object.entries(totals.vatByRate).map(([rate, amount]) => (
|
|
||||||
<Box
|
|
||||||
key={rate}
|
|
||||||
sx={{ display: "flex", justifyContent: "space-between" }}
|
|
||||||
>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
DPH {rate}%:
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
|
|
||||||
>
|
|
||||||
{formatCurrency(amount, form.currency)}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -1402,7 +1288,7 @@ export default function IssuedOrderDetail() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||||
Celkem:
|
Celkem bez DPH:
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
@@ -1419,6 +1305,16 @@ export default function IssuedOrderDetail() {
|
|||||||
|
|
||||||
{/* Notes & terms */}
|
{/* Notes & terms */}
|
||||||
<Card sx={{ mb: 3 }}>
|
<Card sx={{ mb: 3 }}>
|
||||||
|
<Field label="Text objednávky (na PDF)">
|
||||||
|
<TextField
|
||||||
|
value={form.order_text}
|
||||||
|
disabled={!editable}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((prev) => ({ ...prev, order_text: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Objednáváme si u Vás: (ponechte prázdné pro výchozí)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
<Field label="Dodací podmínky">
|
<Field label="Dodací podmínky">
|
||||||
<TextField
|
<TextField
|
||||||
value={form.delivery_terms}
|
value={form.delivery_terms}
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
|||||||
fontSize: "0.9rem",
|
fontSize: "0.9rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>Celkem:</span>
|
<span>Celkem bez DPH:</span>
|
||||||
<Box
|
<Box
|
||||||
component="span"
|
component="span"
|
||||||
sx={{ fontWeight: 700, color: "text.primary" }}
|
sx={{ fontWeight: 700, color: "text.primary" }}
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ export default function LeaveApproval() {
|
|||||||
>({
|
>({
|
||||||
url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
|
url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
|
||||||
method: () => "PUT",
|
method: () => "PUT",
|
||||||
invalidate: ["leave-requests", "leave", "attendance", "users"],
|
invalidate: ["leave-requests", "leave", "attendance", "users", "dashboard"],
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setApproveModal({ open: false, request: null });
|
setApproveModal({ open: false, request: null });
|
||||||
alert.success("Žádost byla schválena");
|
alert.success("Žádost byla schválena");
|
||||||
@@ -174,7 +174,7 @@ export default function LeaveApproval() {
|
|||||||
>({
|
>({
|
||||||
url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
|
url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
|
||||||
method: () => "PUT",
|
method: () => "PUT",
|
||||||
invalidate: ["leave-requests", "leave", "attendance", "users"],
|
invalidate: ["leave-requests", "leave", "attendance", "users", "dashboard"],
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setRejectModal({ open: false, request: null });
|
setRejectModal({ open: false, request: null });
|
||||||
setRejectNote("");
|
setRejectNote("");
|
||||||
|
|||||||
@@ -118,8 +118,6 @@ interface OfferForm {
|
|||||||
valid_until: string;
|
valid_until: string;
|
||||||
currency: string;
|
currency: string;
|
||||||
language: string;
|
language: string;
|
||||||
vat_rate: number;
|
|
||||||
apply_vat: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyForm: OfferForm = {
|
const emptyForm: OfferForm = {
|
||||||
@@ -131,8 +129,6 @@ const emptyForm: OfferForm = {
|
|||||||
valid_until: "",
|
valid_until: "",
|
||||||
currency: "CZK",
|
currency: "CZK",
|
||||||
language: "EN",
|
language: "EN",
|
||||||
vat_rate: 21,
|
|
||||||
apply_vat: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyScopeSection = (): ScopeSection => ({
|
const emptyScopeSection = (): ScopeSection => ({
|
||||||
@@ -585,10 +581,6 @@ export default function OfferDetail() {
|
|||||||
prev.currency === "CZK"
|
prev.currency === "CZK"
|
||||||
? companySettings.default_currency || "CZK"
|
? companySettings.default_currency || "CZK"
|
||||||
: prev.currency,
|
: prev.currency,
|
||||||
vat_rate:
|
|
||||||
prev.vat_rate === 21
|
|
||||||
? (companySettings.default_vat_rate ?? 21)
|
|
||||||
: prev.vat_rate,
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}, [companySettings, isEdit]);
|
}, [companySettings, isEdit]);
|
||||||
@@ -639,8 +631,6 @@ export default function OfferDetail() {
|
|||||||
valid_until: d.valid_until ? d.valid_until.substring(0, 10) : "",
|
valid_until: d.valid_until ? d.valid_until.substring(0, 10) : "",
|
||||||
currency: d.currency || companySettings?.default_currency || "CZK",
|
currency: d.currency || companySettings?.default_currency || "CZK",
|
||||||
language: d.language || "EN",
|
language: d.language || "EN",
|
||||||
vat_rate: d.vat_rate ?? companySettings?.default_vat_rate ?? 21,
|
|
||||||
apply_vat: !!d.apply_vat,
|
|
||||||
};
|
};
|
||||||
setForm(formData);
|
setForm(formData);
|
||||||
const mappedItems =
|
const mappedItems =
|
||||||
@@ -790,7 +780,8 @@ export default function OfferDetail() {
|
|||||||
setItems((prev) => prev.filter((_, i) => i !== index));
|
setItems((prev) => prev.filter((_, i) => i !== index));
|
||||||
};
|
};
|
||||||
|
|
||||||
const subtotal = items.reduce((sum, item) => {
|
// NET only — offers are not tax documents (no VAT on them).
|
||||||
|
const total = items.reduce((sum, item) => {
|
||||||
if (item.is_included_in_total) {
|
if (item.is_included_in_total) {
|
||||||
return (
|
return (
|
||||||
sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
|
sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
|
||||||
@@ -798,8 +789,6 @@ export default function OfferDetail() {
|
|||||||
}
|
}
|
||||||
return sum;
|
return sum;
|
||||||
}, 0);
|
}, 0);
|
||||||
const vatAmount = form.apply_vat ? subtotal * (form.vat_rate / 100) : 0;
|
|
||||||
const total = subtotal + vatAmount;
|
|
||||||
|
|
||||||
const handleSave = async (targetStatus?: string) => {
|
const handleSave = async (targetStatus?: string) => {
|
||||||
const newErrors: Record<string, string> = {};
|
const newErrors: Record<string, string> = {};
|
||||||
@@ -1389,44 +1378,6 @@ export default function OfferDetail() {
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" },
|
|
||||||
gap: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Field label="Sazba DPH">
|
|
||||||
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
|
|
||||||
<Select
|
|
||||||
value={String(form.vat_rate)}
|
|
||||||
onChange={(val) => updateForm("vat_rate", parseFloat(val) || 0)}
|
|
||||||
disabled={readOnly}
|
|
||||||
sx={{ flex: 1 }}
|
|
||||||
options={(
|
|
||||||
companySettings?.available_vat_rates || [0, 10, 12, 15, 21]
|
|
||||||
).map((r) => ({ value: String(r), label: `${r}%` }))}
|
|
||||||
/>
|
|
||||||
<Box
|
|
||||||
component="label"
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
cursor: readOnly ? "default" : "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
checked={form.apply_vat}
|
|
||||||
onChange={(e) => updateForm("apply_vat", e.target.checked)}
|
|
||||||
disabled={readOnly}
|
|
||||||
/>
|
|
||||||
<Box component="span">Uplatnit DPH</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Field>
|
|
||||||
</Box>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Items Section with drag-and-drop */}
|
{/* Items Section with drag-and-drop */}
|
||||||
@@ -1596,7 +1547,7 @@ export default function OfferDetail() {
|
|||||||
</SortableContext>
|
</SortableContext>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
|
|
||||||
{/* Totals */}
|
{/* Totals (NET only — offers are not tax documents) */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
mt: 2,
|
mt: 2,
|
||||||
@@ -1607,30 +1558,6 @@ export default function OfferDetail() {
|
|||||||
gap: 0.5,
|
gap: 0.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Mezisoučet:
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
|
|
||||||
>
|
|
||||||
{formatCurrency(subtotal, form.currency)}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
{form.apply_vat && (
|
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
DPH ({form.vat_rate}%):
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
|
|
||||||
>
|
|
||||||
{formatCurrency(vatAmount, form.currency)}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -1642,7 +1569,7 @@ export default function OfferDetail() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||||
Celkem:
|
Celkem bez DPH:
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
|
|||||||
@@ -906,7 +906,7 @@ export default function Offers() {
|
|||||||
fontSize: "0.9rem",
|
fontSize: "0.9rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>Celkem:</span>
|
<span>Celkem bez DPH:</span>
|
||||||
<Box
|
<Box
|
||||||
component="span"
|
component="span"
|
||||||
sx={{ fontWeight: 700, color: "text.primary" }}
|
sx={{ fontWeight: 700, color: "text.primary" }}
|
||||||
|
|||||||
@@ -144,9 +144,10 @@ export default function OrderDetail() {
|
|||||||
return () => window.removeEventListener("beforeunload", handler);
|
return () => window.removeEventListener("beforeunload", handler);
|
||||||
}, [isDirty]);
|
}, [isDirty]);
|
||||||
|
|
||||||
|
// NET only — received orders are not tax documents (no VAT on them).
|
||||||
const totals = useMemo(() => {
|
const totals = useMemo(() => {
|
||||||
if (!order?.items) return { subtotal: 0, vatAmount: 0, total: 0 };
|
if (!order?.items) return { total: 0 };
|
||||||
const subtotal = order.items.reduce((sum, item) => {
|
const total = order.items.reduce((sum, item) => {
|
||||||
if (Number(item.is_included_in_total)) {
|
if (Number(item.is_included_in_total)) {
|
||||||
return (
|
return (
|
||||||
sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
|
sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
|
||||||
@@ -154,10 +155,7 @@ export default function OrderDetail() {
|
|||||||
}
|
}
|
||||||
return sum;
|
return sum;
|
||||||
}, 0);
|
}, 0);
|
||||||
const vatAmount = Number(order.apply_vat)
|
return { total };
|
||||||
? subtotal * ((Number(order.vat_rate) || 0) / 100)
|
|
||||||
: 0;
|
|
||||||
return { subtotal, vatAmount, total: subtotal + vatAmount };
|
|
||||||
}, [order]);
|
}, [order]);
|
||||||
|
|
||||||
const statusMutation = useApiMutation<{ status: string }, unknown>({
|
const statusMutation = useApiMutation<{ status: string }, unknown>({
|
||||||
@@ -236,14 +234,12 @@ export default function OrderDetail() {
|
|||||||
|
|
||||||
const handleGenerateConfirmation = async (
|
const handleGenerateConfirmation = async (
|
||||||
lang: string,
|
lang: string,
|
||||||
applyVat: boolean,
|
|
||||||
customItems?: Array<{
|
customItems?: Array<{
|
||||||
description: string;
|
description: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
unit: string;
|
unit: string;
|
||||||
unit_price: number;
|
unit_price: number;
|
||||||
is_included_in_total: boolean;
|
is_included_in_total: boolean;
|
||||||
vat_rate: number;
|
|
||||||
}>,
|
}>,
|
||||||
) => {
|
) => {
|
||||||
setConfirmationLoading(true);
|
setConfirmationLoading(true);
|
||||||
@@ -253,7 +249,7 @@ export default function OrderDetail() {
|
|||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ lang, applyVat, items: customItems }),
|
body: JSON.stringify({ lang, items: customItems }),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -625,7 +621,7 @@ export default function OrderDetail() {
|
|||||||
empty={<EmptyState title="Žádné položky." />}
|
empty={<EmptyState title="Žádné položky." />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Totals */}
|
{/* Totals (NET only — orders are not tax documents) */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
mt: 2,
|
mt: 2,
|
||||||
@@ -636,30 +632,6 @@ export default function OrderDetail() {
|
|||||||
gap: 0.5,
|
gap: 0.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Mezisoučet:
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
|
|
||||||
>
|
|
||||||
{formatCurrency(totals.subtotal, order.currency)}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
{Number(order.apply_vat) > 0 && (
|
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
DPH ({order.vat_rate}%):
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
|
|
||||||
>
|
|
||||||
{formatCurrency(totals.vatAmount, order.currency)}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -671,7 +643,7 @@ export default function OrderDetail() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||||
Celkem k úhradě:
|
Celkem bez DPH:
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
@@ -829,11 +801,8 @@ export default function OrderDetail() {
|
|||||||
unit: it.unit || "",
|
unit: it.unit || "",
|
||||||
unit_price: Number(it.unit_price) || 0,
|
unit_price: Number(it.unit_price) || 0,
|
||||||
is_included_in_total: Number(it.is_included_in_total) !== 0,
|
is_included_in_total: Number(it.is_included_in_total) !== 0,
|
||||||
vat_rate: Number(order.vat_rate) || 21,
|
|
||||||
}))}
|
}))}
|
||||||
orderNumber={order.order_number}
|
orderNumber={order.order_number}
|
||||||
defaultVatRate={Number(order.vat_rate) || 21}
|
|
||||||
applyVat={!!order.apply_vat}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</PageEnter>
|
</PageEnter>
|
||||||
|
|||||||
@@ -184,8 +184,6 @@ export default function OrdersReceived({
|
|||||||
customer_id: "",
|
customer_id: "",
|
||||||
customer_order_number: "",
|
customer_order_number: "",
|
||||||
currency: "CZK",
|
currency: "CZK",
|
||||||
vat_rate: "21",
|
|
||||||
apply_vat: true,
|
|
||||||
scope_title: "",
|
scope_title: "",
|
||||||
scope_description: "",
|
scope_description: "",
|
||||||
notes: "",
|
notes: "",
|
||||||
@@ -219,8 +217,6 @@ export default function OrdersReceived({
|
|||||||
customer_id: "",
|
customer_id: "",
|
||||||
customer_order_number: "",
|
customer_order_number: "",
|
||||||
currency: "CZK",
|
currency: "CZK",
|
||||||
vat_rate: "21",
|
|
||||||
apply_vat: true,
|
|
||||||
scope_title: "",
|
scope_title: "",
|
||||||
scope_description: "",
|
scope_description: "",
|
||||||
notes: "",
|
notes: "",
|
||||||
@@ -255,8 +251,6 @@ export default function OrdersReceived({
|
|||||||
fd.append("customer_id", createForm.customer_id);
|
fd.append("customer_id", createForm.customer_id);
|
||||||
fd.append("customer_order_number", createForm.customer_order_number);
|
fd.append("customer_order_number", createForm.customer_order_number);
|
||||||
fd.append("currency", createForm.currency);
|
fd.append("currency", createForm.currency);
|
||||||
fd.append("vat_rate", createForm.vat_rate);
|
|
||||||
fd.append("apply_vat", createForm.apply_vat ? "1" : "0");
|
|
||||||
fd.append("scope_title", createForm.scope_title);
|
fd.append("scope_title", createForm.scope_title);
|
||||||
fd.append("scope_description", createForm.scope_description);
|
fd.append("scope_description", createForm.scope_description);
|
||||||
fd.append("notes", createForm.notes);
|
fd.append("notes", createForm.notes);
|
||||||
@@ -274,8 +268,6 @@ export default function OrdersReceived({
|
|||||||
: null,
|
: null,
|
||||||
customer_order_number: createForm.customer_order_number,
|
customer_order_number: createForm.customer_order_number,
|
||||||
currency: createForm.currency,
|
currency: createForm.currency,
|
||||||
vat_rate: createForm.vat_rate,
|
|
||||||
apply_vat: createForm.apply_vat,
|
|
||||||
scope_title: createForm.scope_title,
|
scope_title: createForm.scope_title,
|
||||||
scope_description: createForm.scope_description,
|
scope_description: createForm.scope_description,
|
||||||
notes: createForm.notes,
|
notes: createForm.notes,
|
||||||
@@ -571,7 +563,7 @@ export default function OrdersReceived({
|
|||||||
fontSize: "0.9rem",
|
fontSize: "0.9rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>Celkem:</span>
|
<span>Celkem bez DPH:</span>
|
||||||
<Box
|
<Box
|
||||||
component="span"
|
component="span"
|
||||||
sx={{ fontWeight: 700, color: "text.primary" }}
|
sx={{ fontWeight: 700, color: "text.primary" }}
|
||||||
@@ -669,19 +661,6 @@ export default function OrdersReceived({
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ flex: "1 1 200px" }}>
|
|
||||||
<Field label="Sazba DPH (%)">
|
|
||||||
<TextField
|
|
||||||
type="number"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={createForm.vat_rate}
|
|
||||||
onChange={(e) =>
|
|
||||||
setCreateForm({ ...createForm, vat_rate: e.target.value })
|
|
||||||
}
|
|
||||||
slotProps={{ htmlInput: { min: 0 } }}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
|
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
|
||||||
@@ -757,12 +736,6 @@ export default function OrdersReceived({
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<CheckboxField
|
|
||||||
label="Účtovat DPH"
|
|
||||||
checked={createForm.apply_vat}
|
|
||||||
onChange={(v) => setCreateForm({ ...createForm, apply_vat: v })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CheckboxField
|
<CheckboxField
|
||||||
label="Vytvořit propojený projekt"
|
label="Vytvořit propojený projekt"
|
||||||
checked={createForm.create_project}
|
checked={createForm.create_project}
|
||||||
|
|||||||
@@ -195,18 +195,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
buyer: "Odběratel",
|
buyer: "Odběratel",
|
||||||
issue_date: "Datum vystavení:",
|
issue_date: "Datum vystavení:",
|
||||||
delivery_date: "Požadované dodání:",
|
delivery_date: "Požadované dodání:",
|
||||||
billing: "Objednáváme u Vás:",
|
billing: "Objednáváme si u Vás:",
|
||||||
col_no: "Č.",
|
col_no: "Č.",
|
||||||
col_desc: "Popis",
|
col_desc: "Popis",
|
||||||
col_qty: "Množství",
|
col_qty: "Množství",
|
||||||
col_unit_price: "Jedn. cena",
|
col_unit_price: "Jedn. cena",
|
||||||
col_price: "Cena",
|
|
||||||
col_vat_pct: "%DPH",
|
|
||||||
col_vat: "DPH",
|
|
||||||
col_total: "Celkem",
|
col_total: "Celkem",
|
||||||
subtotal: "Mezisoučet:",
|
total_no_vat: "Celkem bez DPH",
|
||||||
vat_label: "DPH",
|
|
||||||
total: "Celkem",
|
|
||||||
amounts_in: "Částky jsou uvedeny v",
|
amounts_in: "Částky jsou uvedeny v",
|
||||||
notes: "Poznámky",
|
notes: "Poznámky",
|
||||||
delivery_terms: "Dodací podmínky:",
|
delivery_terms: "Dodací podmínky:",
|
||||||
@@ -227,13 +222,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
col_desc: "Description",
|
col_desc: "Description",
|
||||||
col_qty: "Quantity",
|
col_qty: "Quantity",
|
||||||
col_unit_price: "Unit price",
|
col_unit_price: "Unit price",
|
||||||
col_price: "Price",
|
|
||||||
col_vat_pct: "VAT%",
|
|
||||||
col_vat: "VAT",
|
|
||||||
col_total: "Total",
|
col_total: "Total",
|
||||||
subtotal: "Subtotal:",
|
total_no_vat: "Total excl. VAT",
|
||||||
vat_label: "VAT",
|
|
||||||
total: "Total",
|
|
||||||
amounts_in: "Amounts are in",
|
amounts_in: "Amounts are in",
|
||||||
notes: "Notes",
|
notes: "Notes",
|
||||||
delivery_terms: "Delivery terms:",
|
delivery_terms: "Delivery terms:",
|
||||||
@@ -249,12 +239,12 @@ interface IssuedOrderPdfData {
|
|||||||
order_date: Date | null;
|
order_date: Date | null;
|
||||||
delivery_date: Date | null;
|
delivery_date: Date | null;
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
apply_vat: boolean | null;
|
|
||||||
vat_rate: unknown;
|
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
delivery_terms: string | null;
|
delivery_terms: string | null;
|
||||||
payment_terms: string | null;
|
payment_terms: string | null;
|
||||||
issued_by: string | null;
|
issued_by: string | null;
|
||||||
|
// Editable heading above the items table; null → t.billing default.
|
||||||
|
order_text?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IssuedOrderPdfItem {
|
interface IssuedOrderPdfItem {
|
||||||
@@ -263,7 +253,6 @@ interface IssuedOrderPdfItem {
|
|||||||
quantity: unknown;
|
quantity: unknown;
|
||||||
unit: string | null;
|
unit: string | null;
|
||||||
unit_price: unknown;
|
unit_price: unknown;
|
||||||
vat_rate: unknown;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderIssuedOrderHtml(
|
export function renderIssuedOrderHtml(
|
||||||
@@ -275,9 +264,7 @@ export function renderIssuedOrderHtml(
|
|||||||
issuer: { name: string },
|
issuer: { name: string },
|
||||||
): string {
|
): string {
|
||||||
const t = translations[lang];
|
const t = translations[lang];
|
||||||
const applyVat = order.apply_vat !== false;
|
|
||||||
const currency = order.currency || "CZK";
|
const currency = order.currency || "CZK";
|
||||||
const docRate = order.vat_rate != null ? Number(order.vat_rate) : 21;
|
|
||||||
const poNumber = escapeHtml(order.po_number || "");
|
const poNumber = escapeHtml(order.po_number || "");
|
||||||
|
|
||||||
// Logo embedding (same logic as the confirmation template).
|
// Logo embedding (same logic as the confirmation template).
|
||||||
@@ -304,31 +291,15 @@ export function renderIssuedOrderHtml(
|
|||||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
// Items — NET + per-line VAT-on-top, rounded per line (same math the
|
// Items — NET only. A purchase order is not a tax document: no VAT columns,
|
||||||
// service computeIssuedOrderTotals uses; mirrors the confirmation loop).
|
// no VAT math (same as the service computeIssuedOrderTotals).
|
||||||
let subtotal = 0;
|
let total = 0;
|
||||||
let totalVat = 0;
|
|
||||||
const vatSummary: Record<string, { base: number; vat: number }> = {};
|
|
||||||
const itemsHtml = items
|
const itemsHtml = items
|
||||||
.map((it, i) => {
|
.map((it, i) => {
|
||||||
const qty = Number(it.quantity) || 0;
|
const qty = Number(it.quantity) || 0;
|
||||||
const unitPrice = Number(it.unit_price) || 0;
|
const unitPrice = Number(it.unit_price) || 0;
|
||||||
const rate =
|
const lineTotal = qty * unitPrice;
|
||||||
it.vat_rate != null && it.vat_rate !== ""
|
total += lineTotal;
|
||||||
? Number(it.vat_rate)
|
|
||||||
: docRate;
|
|
||||||
const lineSubtotal = qty * unitPrice;
|
|
||||||
const lineVat = applyVat
|
|
||||||
? Math.round(lineSubtotal * (rate / 100) * 100) / 100
|
|
||||||
: 0;
|
|
||||||
const lineTotal = lineSubtotal + lineVat;
|
|
||||||
|
|
||||||
subtotal += lineSubtotal;
|
|
||||||
totalVat += lineVat;
|
|
||||||
const key = String(rate);
|
|
||||||
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
|
|
||||||
vatSummary[key].base += lineSubtotal;
|
|
||||||
vatSummary[key].vat += lineVat;
|
|
||||||
|
|
||||||
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
|
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
|
||||||
const descHtml = `${escapeHtml(it.description)}${
|
const descHtml = `${escapeHtml(it.description)}${
|
||||||
@@ -341,30 +312,12 @@ export function renderIssuedOrderHtml(
|
|||||||
<td class="desc">${descHtml}</td>
|
<td class="desc">${descHtml}</td>
|
||||||
<td class="center">${formatNum(qty, qtyDecimals)}${it.unit ? ` / ${escapeHtml(it.unit)}` : ""}</td>
|
<td class="center">${formatNum(qty, qtyDecimals)}${it.unit ? ` / ${escapeHtml(it.unit)}` : ""}</td>
|
||||||
<td class="right">${formatNum(unitPrice)}</td>
|
<td class="right">${formatNum(unitPrice)}</td>
|
||||||
<td class="right">${formatNum(lineSubtotal)}</td>
|
|
||||||
<td class="center">${applyVat ? Math.floor(rate) : 0}%</td>
|
|
||||||
<td class="right">${formatNum(lineVat)}</td>
|
|
||||||
<td class="right total-cell">${formatNum(lineTotal)}</td>
|
<td class="right total-cell">${formatNum(lineTotal)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
subtotal = Math.round(subtotal * 100) / 100;
|
total = Math.round(total * 100) / 100;
|
||||||
totalVat = Math.round(totalVat * 100) / 100;
|
|
||||||
const totalToPay = Math.round((subtotal + totalVat) * 100) / 100;
|
|
||||||
|
|
||||||
let vatDetailHtml = "";
|
|
||||||
if (applyVat) {
|
|
||||||
for (const [rate, data] of Object.entries(vatSummary)) {
|
|
||||||
if (data.vat > 0) {
|
|
||||||
vatDetailHtml += `
|
|
||||||
<div class="row">
|
|
||||||
<span class="label">${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%:</span>
|
|
||||||
<span class="value">${formatNum(data.vat)} ${escapeHtml(currency)}</span>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const notesRaw = order.notes ?? "";
|
const notesRaw = order.notes ?? "";
|
||||||
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
|
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
|
||||||
@@ -764,18 +717,15 @@ ${indentCSS}
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Polozky -->
|
<!-- Polozky -->
|
||||||
<div class="billing-label">${escapeHtml(t.billing)}</div>
|
<div class="billing-label">${escapeHtml(order.order_text || t.billing)}</div>
|
||||||
<table class="items">
|
<table class="items">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
|
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
|
||||||
<th style="width:36%">${escapeHtml(t.col_desc)}</th>
|
<th style="width:56%">${escapeHtml(t.col_desc)}</th>
|
||||||
<th class="center" style="width:10%">${escapeHtml(t.col_qty)}</th>
|
<th class="center" style="width:10%">${escapeHtml(t.col_qty)}</th>
|
||||||
<th class="right" style="width:10%">${escapeHtml(t.col_unit_price)}</th>
|
<th class="right" style="width:10%">${escapeHtml(t.col_unit_price)}</th>
|
||||||
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>
|
<th class="right" style="width:21%">${escapeHtml(t.col_total)}</th>
|
||||||
<th class="center" style="width:5%">${escapeHtml(t.col_vat_pct)}</th>
|
|
||||||
<th class="right" style="width:10%">${escapeHtml(t.col_vat)}</th>
|
|
||||||
<th class="right" style="width:16%">${escapeHtml(t.col_total)}</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -783,18 +733,12 @@ ${indentCSS}
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Soucty -->
|
<!-- Soucty (jen souhrnny radek bez DPH - mezisoucet by ho jen opakoval) -->
|
||||||
<div class="totals-wrapper">
|
<div class="totals-wrapper">
|
||||||
<div class="totals">
|
<div class="totals">
|
||||||
<div class="detail-rows">
|
|
||||||
<div class="row">
|
|
||||||
<span class="label">${escapeHtml(t.subtotal)}</span>
|
|
||||||
<span class="value">${formatNum(subtotal)} ${escapeHtml(currency)}</span>
|
|
||||||
</div>${vatDetailHtml}
|
|
||||||
</div>
|
|
||||||
<div class="grand">
|
<div class="grand">
|
||||||
<span class="label">${escapeHtml(t.total)}</span>
|
<span class="label">${escapeHtml(t.total_no_vat)}</span>
|
||||||
<span class="value">${formatNum(totalToPay)} ${escapeHtml(currency)}</span>
|
<span class="value">${formatNum(total)} ${escapeHtml(currency)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
|
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -203,9 +203,7 @@ const TRANSLATIONS: Record<string, Record<string, string>> = {
|
|||||||
unit_price: { EN: "Unit Price", CZ: "Jedn. cena" },
|
unit_price: { EN: "Unit Price", CZ: "Jedn. cena" },
|
||||||
included: { EN: "Included", CZ: "Zahrnuto" },
|
included: { EN: "Included", CZ: "Zahrnuto" },
|
||||||
total: { EN: "Total", CZ: "Celkem" },
|
total: { EN: "Total", CZ: "Celkem" },
|
||||||
subtotal: { EN: "Subtotal", CZ: "Mezisou\u010Det" },
|
total_no_vat: { EN: "Total excl. VAT", CZ: "Celkem bez DPH" },
|
||||||
vat: { EN: "VAT", CZ: "DPH" },
|
|
||||||
total_to_pay: { EN: "Total to pay", CZ: "Celkem k \u00FAhrad\u011B" },
|
|
||||||
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" },
|
||||||
@@ -256,18 +254,15 @@ export default async function offersPdfRoutes(
|
|||||||
logoImg = `<img src="data:${escapeHtml(mime)};base64,${buf.toString("base64")}" class="logo" />`;
|
logoImg = `<img src="data:${escapeHtml(mime)};base64,${buf.toString("base64")}" class="logo" />`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Offers are NOT tax documents — the total is NET only (no VAT math).
|
||||||
const items = quotation.quotation_items;
|
const items = quotation.quotation_items;
|
||||||
let subtotal = 0;
|
let total = 0;
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.is_included_in_total !== false) {
|
if (item.is_included_in_total !== false) {
|
||||||
subtotal +=
|
total +=
|
||||||
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const applyVat = !!quotation.apply_vat;
|
|
||||||
const vatRate = Number(quotation.vat_rate) || 21;
|
|
||||||
const vatAmount = applyVat ? subtotal * (vatRate / 100) : 0;
|
|
||||||
const totalToPay = subtotal + vatAmount;
|
|
||||||
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()) {
|
||||||
@@ -318,22 +313,10 @@ export default async function offersPdfRoutes(
|
|||||||
</tr>`;
|
</tr>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
let totalsHtml = "";
|
// No Mezisoučet/VAT rows — they would only duplicate the net total.
|
||||||
if (applyVat) {
|
const totalsHtml = `<div class="grand">
|
||||||
totalsHtml += `<div class="detail-rows">
|
<span class="label">${escapeHtml(t("total_no_vat"))}</span>
|
||||||
<div class="row">
|
<span class="value">${formatCurrency(total, currency)}</span>
|
||||||
<span class="label">${escapeHtml(t("subtotal"))}:</span>
|
|
||||||
<span class="value">${formatCurrency(subtotal, currency)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<span class="label">${escapeHtml(t("vat"))} (${Math.round(vatRate)}%):</span>
|
|
||||||
<span class="value">${formatCurrency(vatAmount, currency)}</span>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
totalsHtml += `<div class="grand">
|
|
||||||
<span class="label">${escapeHtml(t("total_to_pay"))}</span>
|
|
||||||
<span class="value">${formatCurrency(totalToPay, currency)}</span>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
const quotationNumber = escapeHtml(quotation.quotation_number);
|
const quotationNumber = escapeHtml(quotation.quotation_number);
|
||||||
|
|
||||||
|
|||||||
@@ -20,16 +20,14 @@ const OrderPdfItemSchema = z.object({
|
|||||||
unit: z.string().max(255),
|
unit: z.string().max(255),
|
||||||
unit_price: z.number().min(0).finite(),
|
unit_price: z.number().min(0).finite(),
|
||||||
is_included_in_total: z.boolean().optional(),
|
is_included_in_total: z.boolean().optional(),
|
||||||
vat_rate: z.number().min(0).max(100).finite(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// `z.looseObject` is the Zod 4 replacement for the deprecated `.passthrough()`.
|
// `z.looseObject` is the Zod 4 replacement for the deprecated `.passthrough()`.
|
||||||
// `items` is strictly validated; `lang`/`applyVat` are typed explicitly so the
|
// `items` is strictly validated; `lang` is typed explicitly so the handler no
|
||||||
// handler no longer reads them as untyped passthrough keys.
|
// longer reads it as an untyped passthrough key.
|
||||||
const OrderPdfBodySchema = z.looseObject({
|
const OrderPdfBodySchema = z.looseObject({
|
||||||
items: z.array(OrderPdfItemSchema).optional(),
|
items: z.array(OrderPdfItemSchema).optional(),
|
||||||
lang: z.string().max(10).optional(),
|
lang: z.string().max(10).optional(),
|
||||||
applyVat: z.boolean().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ── Helpers ─────────────────────────────────────────────────────── */
|
/* ── Helpers ─────────────────────────────────────────────────────── */
|
||||||
@@ -193,13 +191,8 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
col_desc: "Popis",
|
col_desc: "Popis",
|
||||||
col_qty: "Množství",
|
col_qty: "Množství",
|
||||||
col_unit_price: "Jedn. cena",
|
col_unit_price: "Jedn. cena",
|
||||||
col_price: "Cena",
|
|
||||||
col_vat_pct: "%DPH",
|
|
||||||
col_vat: "DPH",
|
|
||||||
col_total: "Celkem",
|
col_total: "Celkem",
|
||||||
subtotal: "Mezisoučet:",
|
total_no_vat: "Celkem bez DPH",
|
||||||
vat_label: "DPH",
|
|
||||||
total: "Celkem",
|
|
||||||
amounts_in: "Částky jsou uvedeny v",
|
amounts_in: "Částky jsou uvedeny v",
|
||||||
notes: "Poznámky",
|
notes: "Poznámky",
|
||||||
issued_by: "Vystavil:",
|
issued_by: "Vystavil:",
|
||||||
@@ -221,13 +214,8 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
col_desc: "Description",
|
col_desc: "Description",
|
||||||
col_qty: "Quantity",
|
col_qty: "Quantity",
|
||||||
col_unit_price: "Unit price",
|
col_unit_price: "Unit price",
|
||||||
col_price: "Price",
|
|
||||||
col_vat_pct: "VAT%",
|
|
||||||
col_vat: "VAT",
|
|
||||||
col_total: "Total",
|
col_total: "Total",
|
||||||
subtotal: "Subtotal:",
|
total_no_vat: "Total excl. VAT",
|
||||||
vat_label: "VAT",
|
|
||||||
total: "Total",
|
|
||||||
amounts_in: "Amounts are in",
|
amounts_in: "Amounts are in",
|
||||||
notes: "Notes",
|
notes: "Notes",
|
||||||
issued_by: "Issued by:",
|
issued_by: "Issued by:",
|
||||||
@@ -238,207 +226,119 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ── Route ───────────────────────────────────────────────────────── */
|
/* ── Template ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
export default async function ordersPdfRoutes(
|
export interface OrderConfirmationPdfItem {
|
||||||
fastify: FastifyInstance,
|
description: string;
|
||||||
): Promise<void> {
|
quantity: number;
|
||||||
fastify.post<{ Params: { id: string }; Body: Record<string, unknown> }>(
|
unit: string;
|
||||||
"/:id/confirmation",
|
unit_price: number;
|
||||||
{ preHandler: requirePermission("orders.view") },
|
is_included_in_total: boolean;
|
||||||
async (request, reply) => {
|
}
|
||||||
const id = parseId(request.params.id, reply);
|
|
||||||
if (id === null) return;
|
|
||||||
const parsed = parseBody(OrderPdfBodySchema, request.body || {});
|
|
||||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
|
||||||
const body = parsed.data;
|
|
||||||
|
|
||||||
try {
|
interface OrderConfirmationPdfData {
|
||||||
const lang = body.lang === "en" ? "en" : "cs";
|
order_number: string | null;
|
||||||
const t = translations[lang];
|
customer_order_number: string | null;
|
||||||
|
created_at: Date | string | null;
|
||||||
|
currency: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
// Not a real orders column today — read defensively (PHP-era data carried it).
|
||||||
|
payment_method?: string | null;
|
||||||
|
customers?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
const order = await prisma.orders.findUnique({
|
/**
|
||||||
where: { id },
|
* Order-confirmation HTML. The confirmation is NOT a tax document — prices
|
||||||
// The confirmation PDF never renders the PO attachment — don't pull
|
* are NET only: no VAT columns or VAT summary, the grand total is labeled
|
||||||
// the blob just to read the order header/items.
|
* "Celkem bez DPH" and the totals box carries the fixed prices-excl.-VAT
|
||||||
omit: { attachment_data: true },
|
* notice. Exported for tests.
|
||||||
include: {
|
*/
|
||||||
customers: true,
|
export function renderOrderConfirmationHtml(
|
||||||
order_items: { orderBy: { position: "asc" } },
|
order: OrderConfirmationPdfData,
|
||||||
},
|
items: OrderConfirmationPdfItem[],
|
||||||
});
|
settings: Record<string, unknown> | null,
|
||||||
|
lang: "cs" | "en",
|
||||||
|
userName: string,
|
||||||
|
): string {
|
||||||
|
const t = translations[lang];
|
||||||
|
|
||||||
if (!order) {
|
let logoImg = "";
|
||||||
return reply
|
if (settings?.logo_data) {
|
||||||
.status(404)
|
const buf = Buffer.from(settings.logo_data as Buffer);
|
||||||
.type("text/html")
|
let mime = "image/png";
|
||||||
.send("<html><body><h1>Objednávka nenalezena</h1></body></html>");
|
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
|
||||||
}
|
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
|
||||||
|
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
|
||||||
|
const b64 = buf.toString("base64");
|
||||||
|
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
|
||||||
|
}
|
||||||
|
|
||||||
const settings = (await prisma.company_settings.findFirst()) as Record<
|
const currency = order.currency || "CZK";
|
||||||
string,
|
|
||||||
unknown
|
|
||||||
> | null;
|
|
||||||
|
|
||||||
let logoImg = "";
|
// NET-only total over the included lines — no VAT math anywhere.
|
||||||
if (settings?.logo_data) {
|
let total = 0;
|
||||||
const buf = Buffer.from(settings.logo_data as Buffer);
|
for (const item of items) {
|
||||||
let mime = "image/png";
|
if (item.is_included_in_total) total += item.quantity * item.unit_price;
|
||||||
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
|
}
|
||||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
|
total = Math.round(total * 100) / 100;
|
||||||
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
|
|
||||||
const b64 = buf.toString("base64");
|
|
||||||
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currency = order.currency || "CZK";
|
const supp = buildAddressLines(settings, true, t);
|
||||||
const applyVat =
|
const cust = buildAddressLines(
|
||||||
body.applyVat !== undefined ? !!body.applyVat : !!order.apply_vat;
|
(order.customers as Record<string, unknown>) || null,
|
||||||
const orderVatRate = Number(order.vat_rate) || 21;
|
false,
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
|
||||||
// The confirmation PDF can be rendered from client-supplied items (e.g.
|
const suppLinesHtml = supp.lines
|
||||||
// a live preview of unsaved edits on the detail page) OR from the
|
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||||
// stored order. Fabricating descriptions/prices that don't reflect the
|
.join("");
|
||||||
// stored order is an editing action, so the custom-items path requires
|
const custLinesHtml = cust.lines
|
||||||
// `orders.edit` (admins bypass). A view-only caller is silently served
|
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||||
// the STORED order items instead — preventing a `orders.view` holder
|
.join("");
|
||||||
// from producing an "official" confirmation with invented figures.
|
|
||||||
const authData = request.authData;
|
|
||||||
const canUseCustomItems =
|
|
||||||
authData?.roleName === "admin" ||
|
|
||||||
!!authData?.permissions.includes("orders.edit");
|
|
||||||
const customItemsRaw =
|
|
||||||
canUseCustomItems && Array.isArray(body.items) ? body.items : null;
|
|
||||||
|
|
||||||
let items: Array<{
|
const orderNumber = escapeHtml(order.order_number || "");
|
||||||
description: string;
|
const poNumber = escapeHtml(order.customer_order_number || "");
|
||||||
quantity: number;
|
const orderDateStr = formatDate(order.created_at);
|
||||||
unit: string;
|
|
||||||
unit_price: number;
|
|
||||||
is_included_in_total: boolean;
|
|
||||||
vat_rate: number;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
if (customItemsRaw && customItemsRaw.length > 0) {
|
const itemsHtml = items
|
||||||
items = customItemsRaw.map((it) => ({
|
.map((item, i) => {
|
||||||
description: it.description,
|
const lineTotal = item.quantity * item.unit_price;
|
||||||
quantity: it.quantity,
|
const qtyDecimals = Math.floor(item.quantity) === item.quantity ? 0 : 2;
|
||||||
unit: it.unit,
|
return `<tr>
|
||||||
unit_price: it.unit_price,
|
|
||||||
is_included_in_total: it.is_included_in_total !== false,
|
|
||||||
vat_rate: it.vat_rate,
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
items = order.order_items.map((it) => ({
|
|
||||||
description: it.description || "",
|
|
||||||
quantity: Number(it.quantity) || 0,
|
|
||||||
unit: it.unit || "",
|
|
||||||
unit_price: Number(it.unit_price) || 0,
|
|
||||||
is_included_in_total: !!it.is_included_in_total,
|
|
||||||
vat_rate: orderVatRate,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
let subtotal = 0;
|
|
||||||
let totalVat = 0;
|
|
||||||
const vatSummary: Record<string, { base: number; vat: number }> = {};
|
|
||||||
for (const item of items) {
|
|
||||||
if (item.is_included_in_total) {
|
|
||||||
const lineTotal = item.quantity * item.unit_price;
|
|
||||||
subtotal += lineTotal;
|
|
||||||
const rate = item.vat_rate;
|
|
||||||
const key = String(rate);
|
|
||||||
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
|
|
||||||
vatSummary[key].base += lineTotal;
|
|
||||||
if (applyVat) {
|
|
||||||
const lineVat = (lineTotal * rate) / 100;
|
|
||||||
vatSummary[key].vat += lineVat;
|
|
||||||
totalVat += lineVat;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const totalToPay = subtotal + totalVat;
|
|
||||||
|
|
||||||
const userName = request.authData
|
|
||||||
? `${request.authData.firstName || ""} ${request.authData.lastName || ""}`.trim()
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const supp = buildAddressLines(settings, true, t);
|
|
||||||
const cust = buildAddressLines(
|
|
||||||
(order.customers as Record<string, unknown>) || null,
|
|
||||||
false,
|
|
||||||
t,
|
|
||||||
);
|
|
||||||
|
|
||||||
const suppLinesHtml = supp.lines
|
|
||||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
|
||||||
.join("");
|
|
||||||
const custLinesHtml = cust.lines
|
|
||||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
const orderNumber = escapeHtml(order.order_number || "");
|
|
||||||
const poNumber = escapeHtml(order.customer_order_number || "");
|
|
||||||
const orderDateStr = formatDate(order.created_at);
|
|
||||||
|
|
||||||
const itemsHtml = items
|
|
||||||
.map((item, i) => {
|
|
||||||
const lineSubtotal = item.quantity * item.unit_price;
|
|
||||||
const lineVat = applyVat ? (lineSubtotal * item.vat_rate) / 100 : 0;
|
|
||||||
const lineTotal = lineSubtotal + lineVat;
|
|
||||||
const qtyDecimals =
|
|
||||||
Math.floor(item.quantity) === item.quantity ? 0 : 2;
|
|
||||||
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)}</td>
|
||||||
<td class="center">${formatNum(item.quantity, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
|
<td class="center">${formatNum(item.quantity, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
|
||||||
<td class="right">${formatNum(item.unit_price)}</td>
|
<td class="right">${formatNum(item.unit_price)}</td>
|
||||||
<td class="right">${formatNum(lineSubtotal)}</td>
|
|
||||||
<td class="center">${applyVat ? Math.floor(item.vat_rate) : 0}%</td>
|
|
||||||
<td class="right">${formatNum(lineVat)}</td>
|
|
||||||
<td class="right total-cell">${formatNum(lineTotal)}</td>
|
<td class="right total-cell">${formatNum(lineTotal)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
const paymentMethod =
|
const paymentMethod =
|
||||||
String((order as Record<string, unknown>).payment_method || "") ||
|
String(order.payment_method || "") ||
|
||||||
(lang === "cs" ? "převodem" : "Bank transfer");
|
(lang === "cs" ? "převodem" : "Bank transfer");
|
||||||
|
|
||||||
let vatDetailHtml = "";
|
const notesRaw = order.notes ?? "";
|
||||||
if (applyVat) {
|
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
|
||||||
for (const [rate, data] of Object.entries(vatSummary)) {
|
const notesHtml = notesStripped
|
||||||
if (data.vat > 0) {
|
? `
|
||||||
vatDetailHtml += `
|
|
||||||
<div class="row">
|
|
||||||
<span class="label">${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%:</span>
|
|
||||||
<span class="value">${formatNum(data.vat)} ${escapeHtml(currency)}</span>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const notesRaw = order.notes ?? "";
|
|
||||||
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
|
|
||||||
const notesHtml = notesStripped
|
|
||||||
? `
|
|
||||||
<div class="invoice-notes">
|
<div class="invoice-notes">
|
||||||
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div>
|
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div>
|
||||||
<div class="invoice-notes-content">${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}</div>
|
<div class="invoice-notes-content">${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}</div>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
// Quill indent CSS
|
// Quill indent CSS
|
||||||
let indentCSS = "";
|
let indentCSS = "";
|
||||||
for (let n = 1; n <= 9; n++) {
|
for (let n = 1; n <= 9; n++) {
|
||||||
const pad = n * 3;
|
const pad = n * 3;
|
||||||
const liPad = n * 3 + 1.5;
|
const liPad = n * 3 + 1.5;
|
||||||
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
|
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
|
||||||
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
|
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html lang="${escapeHtml(lang)}">
|
<html lang="${escapeHtml(lang)}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
@@ -686,47 +586,6 @@ export default async function ordersPdfRoutes(
|
|||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* DPH rekapitulace + QR */
|
|
||||||
.recap-section {
|
|
||||||
display: flex;
|
|
||||||
gap: 5mm;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin-top: 1mm;
|
|
||||||
}
|
|
||||||
.recap-section .qr {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 28mm;
|
|
||||||
}
|
|
||||||
.recap-section .qr img,
|
|
||||||
.recap-section .qr svg { width: 28mm; height: 28mm; }
|
|
||||||
|
|
||||||
.recap-section table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 9pt;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.recap-section table th {
|
|
||||||
font-size: 8pt;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #555;
|
|
||||||
padding: 3px 6px;
|
|
||||||
text-align: right;
|
|
||||||
border-bottom: 0.5pt solid #ccc;
|
|
||||||
}
|
|
||||||
.recap-section table td {
|
|
||||||
padding: 3px 6px;
|
|
||||||
text-align: right;
|
|
||||||
border-bottom: 0.5pt solid #eee;
|
|
||||||
}
|
|
||||||
.recap-section table td.center { text-align: center; }
|
|
||||||
.recap-section table td.cnb-rate {
|
|
||||||
font-size: 8pt;
|
|
||||||
color: #888;
|
|
||||||
text-align: right;
|
|
||||||
border-bottom: none;
|
|
||||||
padding-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Prevzal / razitko */
|
/* Prevzal / razitko */
|
||||||
.footer-row {
|
.footer-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -848,13 +707,10 @@ ${indentCSS}
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
|
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
|
||||||
<th style="width:36%">${escapeHtml(t.col_desc)}</th>
|
<th style="width:56%">${escapeHtml(t.col_desc)}</th>
|
||||||
<th class="center" style="width:10%">${escapeHtml(t.col_qty)}</th>
|
<th class="center" style="width:10%">${escapeHtml(t.col_qty)}</th>
|
||||||
<th class="right" style="width:10%">${escapeHtml(t.col_unit_price)}</th>
|
<th class="right" style="width:10%">${escapeHtml(t.col_unit_price)}</th>
|
||||||
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>
|
<th class="right" style="width:21%">${escapeHtml(t.col_total)}</th>
|
||||||
<th class="center" style="width:5%">${escapeHtml(t.col_vat_pct)}</th>
|
|
||||||
<th class="right" style="width:10%">${escapeHtml(t.col_vat)}</th>
|
|
||||||
<th class="right" style="width:16%">${escapeHtml(t.col_total)}</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -862,18 +718,12 @@ ${indentCSS}
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Soucty -->
|
<!-- Soucty (jen souhrnny radek bez DPH - mezisoucet by ho jen opakoval) -->
|
||||||
<div class="totals-wrapper">
|
<div class="totals-wrapper">
|
||||||
<div class="totals">
|
<div class="totals">
|
||||||
<div class="detail-rows">
|
|
||||||
<div class="row">
|
|
||||||
<span class="label">${escapeHtml(t.subtotal)}</span>
|
|
||||||
<span class="value">${formatNum(subtotal)} ${escapeHtml(currency)}</span>
|
|
||||||
</div>${vatDetailHtml}
|
|
||||||
</div>
|
|
||||||
<div class="grand">
|
<div class="grand">
|
||||||
<span class="label">${escapeHtml(t.total)}</span>
|
<span class="label">${escapeHtml(t.total_no_vat)}</span>
|
||||||
<span class="value">${formatNum(totalToPay)} ${escapeHtml(currency)}</span>
|
<span class="value">${formatNum(total)} ${escapeHtml(currency)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
|
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -900,9 +750,96 @@ ${indentCSS}
|
|||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Route ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export default async function ordersPdfRoutes(
|
||||||
|
fastify: FastifyInstance,
|
||||||
|
): Promise<void> {
|
||||||
|
fastify.post<{ Params: { id: string }; Body: Record<string, unknown> }>(
|
||||||
|
"/:id/confirmation",
|
||||||
|
{ preHandler: requirePermission("orders.view") },
|
||||||
|
async (request, reply) => {
|
||||||
|
const id = parseId(request.params.id, reply);
|
||||||
|
if (id === null) return;
|
||||||
|
const parsed = parseBody(OrderPdfBodySchema, request.body || {});
|
||||||
|
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||||
|
const body = parsed.data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lang = body.lang === "en" ? "en" : "cs";
|
||||||
|
|
||||||
|
const order = await prisma.orders.findUnique({
|
||||||
|
where: { id },
|
||||||
|
// The confirmation PDF never renders the PO attachment — don't pull
|
||||||
|
// the blob just to read the order header/items.
|
||||||
|
omit: { attachment_data: true },
|
||||||
|
include: {
|
||||||
|
customers: true,
|
||||||
|
order_items: { orderBy: { position: "asc" } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return reply
|
||||||
|
.status(404)
|
||||||
|
.type("text/html")
|
||||||
|
.send("<html><body><h1>Objednávka nenalezena</h1></body></html>");
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = (await prisma.company_settings.findFirst()) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
> | null;
|
||||||
|
|
||||||
|
// The confirmation PDF can be rendered from client-supplied items (e.g.
|
||||||
|
// a live preview of unsaved edits on the detail page) OR from the
|
||||||
|
// stored order. Fabricating descriptions/prices that don't reflect the
|
||||||
|
// stored order is an editing action, so the custom-items path requires
|
||||||
|
// `orders.edit` (admins bypass). A view-only caller is silently served
|
||||||
|
// the STORED order items instead — preventing a `orders.view` holder
|
||||||
|
// from producing an "official" confirmation with invented figures.
|
||||||
|
const authData = request.authData;
|
||||||
|
const canUseCustomItems =
|
||||||
|
authData?.roleName === "admin" ||
|
||||||
|
!!authData?.permissions.includes("orders.edit");
|
||||||
|
const customItemsRaw =
|
||||||
|
canUseCustomItems && Array.isArray(body.items) ? body.items : null;
|
||||||
|
|
||||||
|
let items: OrderConfirmationPdfItem[];
|
||||||
|
if (customItemsRaw && customItemsRaw.length > 0) {
|
||||||
|
items = customItemsRaw.map((it) => ({
|
||||||
|
description: it.description,
|
||||||
|
quantity: it.quantity,
|
||||||
|
unit: it.unit,
|
||||||
|
unit_price: it.unit_price,
|
||||||
|
is_included_in_total: it.is_included_in_total !== false,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
items = order.order_items.map((it) => ({
|
||||||
|
description: it.description || "",
|
||||||
|
quantity: Number(it.quantity) || 0,
|
||||||
|
unit: it.unit || "",
|
||||||
|
unit_price: Number(it.unit_price) || 0,
|
||||||
|
is_included_in_total: !!it.is_included_in_total,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const userName = request.authData
|
||||||
|
? `${request.authData.firstName || ""} ${request.authData.lastName || ""}`.trim()
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const html = renderOrderConfirmationHtml(
|
||||||
|
order,
|
||||||
|
items,
|
||||||
|
settings,
|
||||||
|
lang,
|
||||||
|
userName,
|
||||||
|
);
|
||||||
|
|
||||||
const pdfBuffer = await htmlToPdf(html);
|
const pdfBuffer = await htmlToPdf(html);
|
||||||
const filename = `Potvrzeni-${orderNumber || String(id)}.pdf`;
|
const filename = `Potvrzeni-${order.order_number || String(id)}.pdf`;
|
||||||
|
|
||||||
return reply
|
return reply
|
||||||
.type("application/pdf")
|
.type("application/pdf")
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
numberInRange,
|
|
||||||
nonNegativeNumberFromForm,
|
nonNegativeNumberFromForm,
|
||||||
positiveNumberFromForm,
|
positiveNumberFromForm,
|
||||||
nullableIntIdFromForm,
|
nullableIntIdFromForm,
|
||||||
booleanFromForm,
|
|
||||||
isoDateString,
|
isoDateString,
|
||||||
} from "./common";
|
} from "./common";
|
||||||
|
|
||||||
@@ -14,7 +12,6 @@ export const IssuedOrderItemSchema = z.object({
|
|||||||
quantity: positiveNumberFromForm.optional(),
|
quantity: positiveNumberFromForm.optional(),
|
||||||
unit: z.string().max(20).nullish(),
|
unit: z.string().max(20).nullish(),
|
||||||
unit_price: nonNegativeNumberFromForm.optional(),
|
unit_price: nonNegativeNumberFromForm.optional(),
|
||||||
vat_rate: numberInRange(0, 100).optional(),
|
|
||||||
position: z.number().int().nonnegative().optional(),
|
position: z.number().int().nonnegative().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -31,8 +28,6 @@ export const CreateIssuedOrderSchema = z.object({
|
|||||||
supplier_id: nullableIntIdFromForm.nullish(),
|
supplier_id: nullableIntIdFromForm.nullish(),
|
||||||
status: z.enum(ISSUED_ORDER_STATUSES).optional(),
|
status: z.enum(ISSUED_ORDER_STATUSES).optional(),
|
||||||
currency: z.string().max(10).optional(),
|
currency: z.string().max(10).optional(),
|
||||||
vat_rate: numberInRange(0, 100).optional(),
|
|
||||||
apply_vat: booleanFromForm.optional(),
|
|
||||||
exchange_rate: nonNegativeNumberFromForm.optional(),
|
exchange_rate: nonNegativeNumberFromForm.optional(),
|
||||||
order_date: isoDateString.nullish(),
|
order_date: isoDateString.nullish(),
|
||||||
delivery_date: isoDateString.nullish(),
|
delivery_date: isoDateString.nullish(),
|
||||||
@@ -40,6 +35,9 @@ export const CreateIssuedOrderSchema = z.object({
|
|||||||
delivery_terms: z.string().max(500).nullish(),
|
delivery_terms: z.string().max(500).nullish(),
|
||||||
payment_terms: z.string().max(500).nullish(),
|
payment_terms: z.string().max(500).nullish(),
|
||||||
issued_by: z.string().max(255).nullish(),
|
issued_by: z.string().max(255).nullish(),
|
||||||
|
// Editable heading above the PDF items table; empty/null falls back to the
|
||||||
|
// default "Objednáváme si u Vás:" (issued-orders-pdf t.billing).
|
||||||
|
order_text: z.string().max(500).nullish(),
|
||||||
notes: z.string().nullish(),
|
notes: z.string().nullish(),
|
||||||
internal_notes: z.string().nullish(),
|
internal_notes: z.string().nullish(),
|
||||||
items: z.array(IssuedOrderItemSchema).optional(),
|
items: z.array(IssuedOrderItemSchema).optional(),
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
numberFromForm,
|
numberFromForm,
|
||||||
numberInRange,
|
|
||||||
nonNegativeNumberFromForm,
|
nonNegativeNumberFromForm,
|
||||||
positiveNumberFromForm,
|
positiveNumberFromForm,
|
||||||
nullableIntIdFromForm,
|
nullableIntIdFromForm,
|
||||||
@@ -33,8 +32,6 @@ export const CreateQuotationSchema = z.object({
|
|||||||
valid_until: z.string().max(255).nullish(),
|
valid_until: z.string().max(255).nullish(),
|
||||||
currency: z.string().max(20).optional().default("CZK"),
|
currency: z.string().max(20).optional().default("CZK"),
|
||||||
language: z.string().max(20).optional().default("cs"),
|
language: z.string().max(20).optional().default("cs"),
|
||||||
vat_rate: numberInRange(0, 100).optional().default(21.0),
|
|
||||||
apply_vat: booleanFromForm.optional().default(true),
|
|
||||||
status: z
|
status: z
|
||||||
.enum(["draft", "active", "ordered", "invalidated"])
|
.enum(["draft", "active", "ordered", "invalidated"])
|
||||||
.optional()
|
.optional()
|
||||||
@@ -52,8 +49,6 @@ export const UpdateQuotationSchema = z.object({
|
|||||||
valid_until: z.union([z.string().max(255), z.null()]).optional(),
|
valid_until: z.union([z.string().max(255), z.null()]).optional(),
|
||||||
currency: z.string().max(20).optional(),
|
currency: z.string().max(20).optional(),
|
||||||
language: z.string().max(20).optional(),
|
language: z.string().max(20).optional(),
|
||||||
vat_rate: numberInRange(0, 100).optional(),
|
|
||||||
apply_vat: booleanFromForm.optional(),
|
|
||||||
status: z.enum(["draft", "active", "ordered", "invalidated"]).optional(),
|
status: z.enum(["draft", "active", "ordered", "invalidated"]).optional(),
|
||||||
scope_title: z.string().max(255).nullish(),
|
scope_title: z.string().max(255).nullish(),
|
||||||
scope_description: z.string().max(8000).nullish(),
|
scope_description: z.string().max(8000).nullish(),
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
numberFromForm,
|
numberFromForm,
|
||||||
numberInRange,
|
|
||||||
nonNegativeNumberFromForm,
|
nonNegativeNumberFromForm,
|
||||||
positiveNumberFromForm,
|
positiveNumberFromForm,
|
||||||
intIdFromForm,
|
intIdFromForm,
|
||||||
@@ -42,8 +41,6 @@ export const CreateOrderSchema = z.object({
|
|||||||
.default("prijata"),
|
.default("prijata"),
|
||||||
currency: z.string().max(20).optional().default("CZK"),
|
currency: z.string().max(20).optional().default("CZK"),
|
||||||
language: z.string().max(20).optional().default("cs"),
|
language: z.string().max(20).optional().default("cs"),
|
||||||
vat_rate: numberInRange(0, 100).optional().default(21.0),
|
|
||||||
apply_vat: booleanFromForm.optional().default(true),
|
|
||||||
exchange_rate: positiveNumberFromForm.optional().default(1.0),
|
exchange_rate: positiveNumberFromForm.optional().default(1.0),
|
||||||
scope_title: z.string().max(255).nullish(),
|
scope_title: z.string().max(255).nullish(),
|
||||||
scope_description: z.string().max(8000).nullish(),
|
scope_description: z.string().max(8000).nullish(),
|
||||||
@@ -62,8 +59,6 @@ export const UpdateOrderSchema = z.object({
|
|||||||
scope_description: z.string().max(8000).nullish(),
|
scope_description: z.string().max(8000).nullish(),
|
||||||
notes: z.string().max(8000).nullish(),
|
notes: z.string().max(8000).nullish(),
|
||||||
customer_id: nullableIntIdFromForm.optional(),
|
customer_id: nullableIntIdFromForm.optional(),
|
||||||
vat_rate: numberInRange(0, 100).optional(),
|
|
||||||
apply_vat: booleanFromForm.optional(),
|
|
||||||
items: z.array(OrderItemSchema).optional(),
|
items: z.array(OrderItemSchema).optional(),
|
||||||
sections: z.array(OrderSectionSchema).optional(),
|
sections: z.array(OrderSectionSchema).optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export interface IssuedOrderItemInput {
|
|||||||
quantity?: number | string | null;
|
quantity?: number | string | null;
|
||||||
unit?: string | null;
|
unit?: string | null;
|
||||||
unit_price?: number | string | null;
|
unit_price?: number | string | null;
|
||||||
vat_rate?: number | string | null;
|
|
||||||
position?: number | null;
|
position?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,8 +22,6 @@ export interface IssuedOrderInput {
|
|||||||
supplier_id?: number | string | null;
|
supplier_id?: number | string | null;
|
||||||
status?: string;
|
status?: string;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
vat_rate?: number | string | null;
|
|
||||||
apply_vat?: boolean | number | string;
|
|
||||||
exchange_rate?: number | string | null;
|
exchange_rate?: number | string | null;
|
||||||
order_date?: string | null;
|
order_date?: string | null;
|
||||||
delivery_date?: string | null;
|
delivery_date?: string | null;
|
||||||
@@ -32,6 +29,7 @@ export interface IssuedOrderInput {
|
|||||||
delivery_terms?: string | null;
|
delivery_terms?: string | null;
|
||||||
payment_terms?: string | null;
|
payment_terms?: string | null;
|
||||||
issued_by?: string | null;
|
issued_by?: string | null;
|
||||||
|
order_text?: string | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
internal_notes?: string | null;
|
internal_notes?: string | null;
|
||||||
items?: IssuedOrderItemInput[];
|
items?: IssuedOrderItemInput[];
|
||||||
@@ -105,32 +103,18 @@ const ALLOWED_SORT_FIELDS = [
|
|||||||
"currency",
|
"currency",
|
||||||
];
|
];
|
||||||
|
|
||||||
/** NET base + VAT-on-top, rounded per line before accumulation (mirrors invoices). */
|
/**
|
||||||
|
* NET-only total: issued orders (PO) are not tax documents — VAT never appears
|
||||||
|
* on them. The total is the plain sum of qty × unit_price, rounded to 2dp.
|
||||||
|
*/
|
||||||
export function computeIssuedOrderTotals(
|
export function computeIssuedOrderTotals(
|
||||||
items: Array<{ quantity: unknown; unit_price: unknown; vat_rate: unknown }>,
|
items: Array<{ quantity: unknown; unit_price: unknown }>,
|
||||||
applyVat: boolean | null,
|
|
||||||
defaultVatRate: unknown,
|
|
||||||
) {
|
) {
|
||||||
let subtotal = 0;
|
let total = 0;
|
||||||
let vat = 0;
|
|
||||||
for (const it of items) {
|
for (const it of items) {
|
||||||
const base = (Number(it.quantity) || 0) * (Number(it.unit_price) || 0);
|
total += (Number(it.quantity) || 0) * (Number(it.unit_price) || 0);
|
||||||
subtotal += base;
|
|
||||||
if (applyVat) {
|
|
||||||
const rate =
|
|
||||||
it.vat_rate != null && it.vat_rate !== ""
|
|
||||||
? Number(it.vat_rate)
|
|
||||||
: defaultVatRate != null
|
|
||||||
? Number(defaultVatRate)
|
|
||||||
: 21;
|
|
||||||
vat += Math.round(base * (rate / 100) * 100) / 100;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return {
|
return { total: Math.round(total * 100) / 100 };
|
||||||
subtotal: Math.round(subtotal * 100) / 100,
|
|
||||||
vat_amount: Math.round(vat * 100) / 100,
|
|
||||||
total: Math.round((subtotal + vat) * 100) / 100,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listIssuedOrders(params: ListIssuedOrdersParams) {
|
export async function listIssuedOrders(params: ListIssuedOrdersParams) {
|
||||||
@@ -162,11 +146,7 @@ export async function listIssuedOrders(params: ListIssuedOrdersParams) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const enriched = rows.map((o) => {
|
const enriched = rows.map((o) => {
|
||||||
const totals = computeIssuedOrderTotals(
|
const totals = computeIssuedOrderTotals(o.issued_order_items);
|
||||||
o.issued_order_items,
|
|
||||||
o.apply_vat,
|
|
||||||
o.vat_rate,
|
|
||||||
);
|
|
||||||
const { issued_order_items, ...rest } = o;
|
const { issued_order_items, ...rest } = o;
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
@@ -180,7 +160,7 @@ export async function listIssuedOrders(params: ListIssuedOrdersParams) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sum issued-order TOTAL (incl. VAT) per currency across the WHOLE filtered set
|
* Sum issued-order NET total per currency across the WHOLE filtered set
|
||||||
* (not a single page). Reuses `buildIssuedOrderWhere` so filters track the list,
|
* (not a single page). Reuses `buildIssuedOrderWhere` so filters track the list,
|
||||||
* and `computeIssuedOrderTotals` so per-order math matches the list/detail.
|
* and `computeIssuedOrderTotals` so per-order math matches the list/detail.
|
||||||
* Currency defaults to "CZK" when the column is null. Returns one entry per
|
* Currency defaults to "CZK" when the column is null. Returns one entry per
|
||||||
@@ -198,11 +178,7 @@ export async function getIssuedOrderTotals(
|
|||||||
|
|
||||||
const byCurrency: Record<string, number> = {};
|
const byCurrency: Record<string, number> = {};
|
||||||
for (const o of rows) {
|
for (const o of rows) {
|
||||||
const { total } = computeIssuedOrderTotals(
|
const { total } = computeIssuedOrderTotals(o.issued_order_items);
|
||||||
o.issued_order_items,
|
|
||||||
o.apply_vat,
|
|
||||||
o.vat_rate,
|
|
||||||
);
|
|
||||||
const cur = o.currency || "CZK";
|
const cur = o.currency || "CZK";
|
||||||
byCurrency[cur] = (byCurrency[cur] || 0) + (Number(total) || 0);
|
byCurrency[cur] = (byCurrency[cur] || 0) + (Number(total) || 0);
|
||||||
}
|
}
|
||||||
@@ -285,8 +261,6 @@ export async function createIssuedOrder(body: IssuedOrderInput) {
|
|||||||
supplier_id: supplierId,
|
supplier_id: supplierId,
|
||||||
status: status as $Enums.issued_orders_status,
|
status: status as $Enums.issued_orders_status,
|
||||||
currency: body.currency ? String(body.currency) : "CZK",
|
currency: body.currency ? String(body.currency) : "CZK",
|
||||||
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
|
|
||||||
apply_vat: body.apply_vat !== false,
|
|
||||||
exchange_rate:
|
exchange_rate:
|
||||||
body.exchange_rate != null ? Number(body.exchange_rate) : 1.0,
|
body.exchange_rate != null ? Number(body.exchange_rate) : 1.0,
|
||||||
// order_date is @db.Date (truncated to the UTC date part) — the
|
// order_date is @db.Date (truncated to the UTC date part) — the
|
||||||
@@ -304,6 +278,7 @@ export async function createIssuedOrder(body: IssuedOrderInput) {
|
|||||||
: null,
|
: null,
|
||||||
payment_terms: body.payment_terms ? String(body.payment_terms) : null,
|
payment_terms: body.payment_terms ? String(body.payment_terms) : null,
|
||||||
issued_by: body.issued_by ? String(body.issued_by) : null,
|
issued_by: body.issued_by ? String(body.issued_by) : null,
|
||||||
|
order_text: body.order_text ? String(body.order_text) : null,
|
||||||
notes: body.notes ? String(body.notes) : null,
|
notes: body.notes ? String(body.notes) : null,
|
||||||
internal_notes: body.internal_notes
|
internal_notes: body.internal_notes
|
||||||
? String(body.internal_notes)
|
? String(body.internal_notes)
|
||||||
@@ -320,7 +295,6 @@ export async function createIssuedOrder(body: IssuedOrderInput) {
|
|||||||
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,
|
||||||
vat_rate: item.vat_rate ?? 21.0,
|
|
||||||
position: item.position ?? i,
|
position: item.position ?? i,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
@@ -354,6 +328,7 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
|
|||||||
"delivery_terms",
|
"delivery_terms",
|
||||||
"payment_terms",
|
"payment_terms",
|
||||||
"issued_by",
|
"issued_by",
|
||||||
|
"order_text",
|
||||||
];
|
];
|
||||||
for (const f of strFields) {
|
for (const f of strFields) {
|
||||||
if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
|
if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
|
||||||
@@ -370,12 +345,6 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
|
|||||||
}
|
}
|
||||||
data.supplier_id = supplierId;
|
data.supplier_id = supplierId;
|
||||||
}
|
}
|
||||||
if (body.vat_rate !== undefined) data.vat_rate = Number(body.vat_rate);
|
|
||||||
if (body.apply_vat !== undefined)
|
|
||||||
data.apply_vat =
|
|
||||||
body.apply_vat === true ||
|
|
||||||
body.apply_vat === 1 ||
|
|
||||||
body.apply_vat === "1";
|
|
||||||
if (body.exchange_rate !== undefined)
|
if (body.exchange_rate !== undefined)
|
||||||
data.exchange_rate =
|
data.exchange_rate =
|
||||||
body.exchange_rate != null ? Number(body.exchange_rate) : null;
|
body.exchange_rate != null ? Number(body.exchange_rate) : null;
|
||||||
@@ -428,7 +397,6 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
|
|||||||
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,
|
||||||
vat_rate: item.vat_rate ?? 21.0,
|
|
||||||
position: item.position ?? i,
|
position: item.position ?? i,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -77,28 +77,22 @@ function buildOfferWhere(params: OfferFilterParams): Record<string, unknown> {
|
|||||||
return where;
|
return where;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Offers are NOT tax documents — totals are NET only (no VAT anywhere).
|
||||||
function enrichQuotation(q: any) {
|
function enrichQuotation(q: any) {
|
||||||
const subtotal = q.quotation_items
|
const total = q.quotation_items
|
||||||
.filter((i: any) => i.is_included_in_total !== false)
|
.filter((i: any) => i.is_included_in_total !== false)
|
||||||
.reduce(
|
.reduce(
|
||||||
(s: number, i: any) =>
|
(s: number, i: any) =>
|
||||||
s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
|
s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const vatAmount = q.apply_vat
|
|
||||||
? subtotal *
|
|
||||||
((q.vat_rate != null && q.vat_rate !== "" ? Number(q.vat_rate) : 21) /
|
|
||||||
100)
|
|
||||||
: 0;
|
|
||||||
const { quotation_items, scope_sections, ...rest } = q;
|
const { quotation_items, scope_sections, ...rest } = q;
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
items: quotation_items,
|
items: quotation_items,
|
||||||
sections: scope_sections,
|
sections: scope_sections,
|
||||||
customer_name: q.customers?.name || null,
|
customer_name: q.customers?.name || null,
|
||||||
subtotal: Math.round(subtotal * 100) / 100,
|
total: Math.round(total * 100) / 100,
|
||||||
vat_amount: Math.round(vatAmount * 100) / 100,
|
|
||||||
total: Math.round((subtotal + vatAmount) * 100) / 100,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +142,7 @@ export async function listOffers(params: ListOffersParams) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sum offer TOTAL (incl. VAT) per currency across the WHOLE filtered set (not a
|
* Sum offer NET total per currency across the WHOLE filtered set (not a
|
||||||
* single page). Reuses `buildOfferWhere` so the filters track the list, and
|
* single page). Reuses `buildOfferWhere` so the filters track the list, and
|
||||||
* `enrichQuotation` so the per-offer math matches the list/detail exactly.
|
* `enrichQuotation` so the per-offer math matches the list/detail exactly.
|
||||||
* Returns one entry per currency, rounded to 2dp, zero/empty totals dropped.
|
* Returns one entry per currency, rounded to 2dp, zero/empty totals dropped.
|
||||||
@@ -259,8 +253,6 @@ export async function createOffer(body: Record<string, any>) {
|
|||||||
: null,
|
: null,
|
||||||
currency: body.currency ? String(body.currency) : "CZK",
|
currency: body.currency ? String(body.currency) : "CZK",
|
||||||
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,
|
|
||||||
apply_vat: body.apply_vat !== false,
|
|
||||||
status,
|
status,
|
||||||
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
|
||||||
@@ -351,13 +343,6 @@ export async function updateOffer(id: number, body: Record<string, any>) {
|
|||||||
: undefined,
|
: undefined,
|
||||||
currency: body.currency !== undefined ? String(body.currency) : undefined,
|
currency: body.currency !== undefined ? String(body.currency) : undefined,
|
||||||
language: body.language !== undefined ? String(body.language) : undefined,
|
language: body.language !== undefined ? String(body.language) : undefined,
|
||||||
vat_rate: body.vat_rate !== undefined ? Number(body.vat_rate) : undefined,
|
|
||||||
apply_vat:
|
|
||||||
body.apply_vat !== undefined
|
|
||||||
? body.apply_vat === true ||
|
|
||||||
body.apply_vat === 1 ||
|
|
||||||
body.apply_vat === "1"
|
|
||||||
: 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
|
||||||
@@ -479,8 +464,6 @@ export async function duplicateOffer(id: number) {
|
|||||||
valid_until: null,
|
valid_until: null,
|
||||||
currency: original.currency,
|
currency: original.currency,
|
||||||
language: original.language,
|
language: original.language,
|
||||||
vat_rate: original.vat_rate,
|
|
||||||
apply_vat: original.apply_vat,
|
|
||||||
status: "active",
|
status: "active",
|
||||||
scope_title: original.scope_title,
|
scope_title: original.scope_title,
|
||||||
scope_description: original.scope_description,
|
scope_description: original.scope_description,
|
||||||
|
|||||||
@@ -72,23 +72,20 @@ async function syncProjectStatus(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⚠ Also called by getOrderTotals with a MINIMAL select (currency, apply_vat,
|
// ⚠ Also called by getOrderTotals with a MINIMAL select (currency, item
|
||||||
// vat_rate, item quantity/unit_price/is_included_in_total). If you read a NEW
|
// quantity/unit_price/is_included_in_total). If you read a NEW order/item
|
||||||
// order/item field here, add it to that select — a field missing from the
|
// field here, add it to that select — a field missing from the select is
|
||||||
// select is silently 0 in the per-currency totals.
|
// silently 0 in the per-currency totals.
|
||||||
|
//
|
||||||
|
// Orders are NOT tax documents — totals are NET only (no VAT anywhere).
|
||||||
function enrichOrder(o: any) {
|
function enrichOrder(o: any) {
|
||||||
const subtotal = o.order_items
|
const total = o.order_items
|
||||||
.filter((i: any) => i.is_included_in_total !== false)
|
.filter((i: any) => i.is_included_in_total !== false)
|
||||||
.reduce(
|
.reduce(
|
||||||
(s: number, i: any) =>
|
(s: number, i: any) =>
|
||||||
s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
|
s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const vatAmount = o.apply_vat
|
|
||||||
? subtotal *
|
|
||||||
((o.vat_rate != null && o.vat_rate !== "" ? Number(o.vat_rate) : 21) /
|
|
||||||
100)
|
|
||||||
: 0;
|
|
||||||
const { order_items, order_sections, ...rest } = o;
|
const { order_items, order_sections, ...rest } = o;
|
||||||
const invoice = o.invoices?.[0] || null;
|
const invoice = o.invoices?.[0] || null;
|
||||||
return {
|
return {
|
||||||
@@ -100,9 +97,7 @@ function enrichOrder(o: any) {
|
|||||||
project_code: o.quotations?.project_code || null,
|
project_code: o.quotations?.project_code || null,
|
||||||
invoice_id: invoice?.id || null,
|
invoice_id: invoice?.id || null,
|
||||||
invoice_number: invoice?.invoice_number || null,
|
invoice_number: invoice?.invoice_number || null,
|
||||||
subtotal: Math.round(subtotal * 100) / 100,
|
total: Math.round(total * 100) / 100,
|
||||||
vat_amount: Math.round(vatAmount * 100) / 100,
|
|
||||||
total: Math.round((subtotal + vatAmount) * 100) / 100,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +187,7 @@ export interface CurrencyAmount {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sum order TOTAL (incl. VAT) per currency across the WHOLE filtered set (not a
|
* Sum order NET total per currency across the WHOLE filtered set (not a
|
||||||
* single page). Reuses `buildOrderWhere` so the filters track the list, and
|
* single page). Reuses `buildOrderWhere` so the filters track the list, and
|
||||||
* `enrichOrder` so the per-order math matches the list/detail exactly. Returns
|
* `enrichOrder` so the per-order math matches the list/detail exactly. Returns
|
||||||
* one entry per currency, rounded to 2dp, zero/empty totals dropped.
|
* one entry per currency, rounded to 2dp, zero/empty totals dropped.
|
||||||
@@ -202,15 +197,13 @@ export async function getOrderTotals(
|
|||||||
): Promise<{ totals: CurrencyAmount[] }> {
|
): Promise<{ totals: CurrencyAmount[] }> {
|
||||||
const where = buildOrderWhere(params);
|
const where = buildOrderWhere(params);
|
||||||
|
|
||||||
// Select ONLY what the math needs (currency + VAT flags + item numbers).
|
// Select ONLY what the math needs (currency + item numbers).
|
||||||
// This runs over the WHOLE filtered set, so pulling attachment blobs,
|
// This runs over the WHOLE filtered set, so pulling attachment blobs,
|
||||||
// sections HTML or relations here would multiply the query size for nothing.
|
// sections HTML or relations here would multiply the query size for nothing.
|
||||||
const orders = await prisma.orders.findMany({
|
const orders = await prisma.orders.findMany({
|
||||||
where,
|
where,
|
||||||
select: {
|
select: {
|
||||||
currency: true,
|
currency: true,
|
||||||
apply_vat: true,
|
|
||||||
vat_rate: true,
|
|
||||||
order_items: {
|
order_items: {
|
||||||
select: {
|
select: {
|
||||||
quantity: true,
|
quantity: true,
|
||||||
@@ -335,8 +328,6 @@ export async function createOrderFromQuotation(
|
|||||||
status: "prijata",
|
status: "prijata",
|
||||||
currency: quotation.currency || "CZK",
|
currency: quotation.currency || "CZK",
|
||||||
language: quotation.language || "cs",
|
language: quotation.language || "cs",
|
||||||
vat_rate: quotation.vat_rate ?? 21.0,
|
|
||||||
apply_vat: quotation.apply_vat ?? true,
|
|
||||||
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
|
||||||
@@ -428,8 +419,6 @@ interface CreateOrderData {
|
|||||||
status: string;
|
status: string;
|
||||||
currency: string;
|
currency: string;
|
||||||
language: string;
|
language: string;
|
||||||
vat_rate: number;
|
|
||||||
apply_vat?: boolean;
|
|
||||||
exchange_rate?: number;
|
exchange_rate?: number;
|
||||||
scope_title?: string | null;
|
scope_title?: string | null;
|
||||||
scope_description?: string | null;
|
scope_description?: string | null;
|
||||||
@@ -469,8 +458,6 @@ export async function createOrder(
|
|||||||
status: body.status,
|
status: body.status,
|
||||||
currency: body.currency,
|
currency: body.currency,
|
||||||
language: body.language,
|
language: body.language,
|
||||||
vat_rate: body.vat_rate,
|
|
||||||
apply_vat: body.apply_vat !== false,
|
|
||||||
exchange_rate: body.exchange_rate,
|
exchange_rate: body.exchange_rate,
|
||||||
scope_title: body.scope_title ?? null,
|
scope_title: body.scope_title ?? null,
|
||||||
scope_description: body.scope_description ?? null,
|
scope_description: body.scope_description ?? null,
|
||||||
@@ -629,10 +616,6 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
|
|||||||
}
|
}
|
||||||
if (body.customer_id !== undefined)
|
if (body.customer_id !== undefined)
|
||||||
data.customer_id = body.customer_id ? Number(body.customer_id) : null;
|
data.customer_id = body.customer_id ? Number(body.customer_id) : null;
|
||||||
if (body.vat_rate !== undefined) data.vat_rate = Number(body.vat_rate);
|
|
||||||
if (body.apply_vat !== undefined)
|
|
||||||
data.apply_vat =
|
|
||||||
body.apply_vat === true || body.apply_vat === 1 || body.apply_vat === "1";
|
|
||||||
|
|
||||||
if (Array.isArray(body.items) || Array.isArray(body.sections)) {
|
if (Array.isArray(body.items) || Array.isArray(body.sections)) {
|
||||||
if (currentStatus !== "prijata" && currentStatus !== "v_realizaci") {
|
if (currentStatus !== "prijata" && currentStatus !== "v_realizaci") {
|
||||||
|
|||||||
Reference in New Issue
Block a user