Compare commits
4 Commits
v2.4.35
...
4674ff3389
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4674ff3389 | ||
|
|
4bb7de7247 | ||
|
|
719d1f2659 | ||
|
|
3787cc4dd0 |
@@ -0,0 +1,98 @@
|
||||
# Per-item Sleva (% discount) — Offers + Invoices
|
||||
|
||||
**Date:** 2026-06-13
|
||||
**Status:** Approved → implementing
|
||||
**Scope:** Add a per-line-item percentage discount ("Sleva") to **offers**
|
||||
(nabídky) and **issued invoices** (Faktury), on both the detail-page item
|
||||
editor and the PDF. Received invoices, issued orders and orders are NOT
|
||||
touched.
|
||||
|
||||
## Decisions (from brainstorming)
|
||||
|
||||
- **Type:** percentage per item (0–100), not an absolute amount.
|
||||
- **Totals/VAT:** the discount reduces the line **net**; on invoices VAT is
|
||||
computed on the **discounted** net. `net = qty × unit_price × (1 − discount/100)`.
|
||||
- **PDF column is conditional:** render the Sleva column only when at least one
|
||||
(included) item has `discount > 0`; otherwise the table is identical to today.
|
||||
- **Detail-page column is always visible** (it's the input).
|
||||
|
||||
## Data model
|
||||
|
||||
Add to `quotation_items` and `invoice_items`:
|
||||
|
||||
```
|
||||
discount Decimal? @default(0.00) @db.Decimal(5, 2) // percent, 0–100
|
||||
```
|
||||
|
||||
Nullable with default `0`, matching the existing money columns. "No sleva" =
|
||||
`0` or null. One tracked migration; apply to dev `app` and `app_test`, then
|
||||
`prisma generate`.
|
||||
|
||||
## Line math (single rule)
|
||||
|
||||
`lineNet = qty × unit_price × (1 − clamp(discount,0,100)/100)`
|
||||
|
||||
Add a shared backend helper so every site is identical:
|
||||
|
||||
```ts
|
||||
// src/utils/money.ts (new)
|
||||
export const lineNet = (qty: number, unitPrice: number, discountPct: number) =>
|
||||
qty * unitPrice * (1 - (discountPct || 0) / 100);
|
||||
```
|
||||
|
||||
## Backend
|
||||
|
||||
- **Schemas (Zod):** add `discount` to the offer-item and invoice-item schemas
|
||||
via `numberInRange(0, 100)` (shared coercer in `common.ts`), optional, default
|
||||
`0`.
|
||||
- **offers.service** (`enrichQuotation`): line total uses `lineNet`; document
|
||||
net ("Celkem bez DPH") sums discounted included lines. Persist `discount` on
|
||||
item create/update.
|
||||
- **invoices.service**: every `computeMoney` `getBase` callback
|
||||
(`computeInvoiceTotals`, monthly-stats `invoiceMonthlyAmount`, per-rate
|
||||
`vatMap`) returns `lineNet(...)` so subtotal **and** VAT use the discounted
|
||||
net. Persist `discount` on item create/update.
|
||||
- **DocumentItemsEditor's `documentItemsTotal`** (frontend) mirrors the same
|
||||
discounted net.
|
||||
|
||||
## Frontend (Section 1 — detail-page editor)
|
||||
|
||||
- **Offers** → shared `src/admin/components/document/DocumentItemsEditor.tsx`:
|
||||
`DocumentItem` gains `discount`; add a **"Sleva %"** numeric column (0–100);
|
||||
per-line total and the footer net total apply it. Mobile card layout gets the
|
||||
field. `emptyDocumentItem` defaults `discount: 0`. Hydration + save payloads
|
||||
carry `discount`.
|
||||
- **Invoices** → `src/admin/pages/InvoiceDetail.tsx` own editor
|
||||
(`SortableInvoiceRow` + create/edit rows): `InvoiceItem` gains `discount`;
|
||||
same column; net/VAT/gross totals apply it. Mobile layout too.
|
||||
|
||||
## PDF (Section 2 — conditional column)
|
||||
|
||||
`src/routes/admin/offers-pdf.ts` and `src/routes/admin/invoices-pdf.ts`:
|
||||
|
||||
- `const hasDiscount = items.some(i => includedInTotal(i) && Number(i.discount) > 0)`.
|
||||
- When `hasDiscount`: add a column — header **"Sleva"** (cs) / **"Discount"**
|
||||
(en, from the document's `language`), cell renders `"<n> %"` (escaped); adjust
|
||||
colspans of any full-width rows accordingly.
|
||||
- Line net / document total / VAT always use the discounted net regardless of
|
||||
`hasDiscount`.
|
||||
- `escapeHtml` every interpolation (string-built PDF templates).
|
||||
|
||||
## Tests
|
||||
|
||||
- `offer-invoice-totals` / invoice totals: net, VAT and gross with discounts
|
||||
(incl. a 100% line = 0, and VAT-on-discounted-net).
|
||||
- Schema: `discount` rejects <0 and >100, coerces form strings, defaults 0.
|
||||
- PDF: column present when an item has a discount; absent when none; values
|
||||
escaped.
|
||||
|
||||
## Non-goals (YAGNI)
|
||||
|
||||
- No separate "total discount" summary line, no document-level discount.
|
||||
- No changes to received invoices, issued orders, or orders.
|
||||
|
||||
## Migration etiquette
|
||||
|
||||
DB change → user stops the dev server first; create the migration via the
|
||||
non-interactive `prisma migrate diff` recipe, `migrate deploy` + `generate` on
|
||||
dev and `app_test`, commit schema + migration folder together.
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "2.4.35",
|
||||
"version": "2.4.38",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "app-ts",
|
||||
"version": "2.4.35",
|
||||
"version": "2.4.38",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.102.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "2.4.35",
|
||||
"version": "2.4.38",
|
||||
"description": "",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `invoice_items` ADD COLUMN `discount` DECIMAL(5, 2) NULL DEFAULT 0.00;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `quotation_items` ADD COLUMN `discount` DECIMAL(5, 2) NULL DEFAULT 0.00;
|
||||
|
||||
@@ -208,6 +208,7 @@ model invoice_items {
|
||||
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
|
||||
unit String? @db.VarChar(20)
|
||||
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
|
||||
discount Decimal? @default(0.00) @db.Decimal(5, 2)
|
||||
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
||||
position Int? @default(0)
|
||||
invoices invoices @relation(fields: [invoice_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "invoice_items_ibfk_1")
|
||||
@@ -505,6 +506,7 @@ model quotation_items {
|
||||
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
|
||||
unit String? @db.VarChar(20)
|
||||
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
|
||||
discount Decimal? @default(0.00) @db.Decimal(5, 2)
|
||||
is_included_in_total Boolean? @default(true)
|
||||
modified_at DateTime? @db.DateTime(0)
|
||||
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "quotation_items_ibfk_1")
|
||||
|
||||
46
src/__tests__/app-version-store.test.ts
Normal file
46
src/__tests__/app-version-store.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import {
|
||||
reportServerVersion,
|
||||
subscribeNewVersion,
|
||||
getNewVersion,
|
||||
BUILD_VERSION,
|
||||
__resetVersionStateForTest,
|
||||
} from "../admin/utils/appVersion";
|
||||
|
||||
/**
|
||||
* Client store backing the "new version available" banner. The server stamps
|
||||
* X-App-Version on every response; apiFetch feeds it here. A version that
|
||||
* differs from the one baked into THIS bundle (BUILD_VERSION) means a deploy
|
||||
* happened → latch it and notify subscribers (the banner).
|
||||
*/
|
||||
|
||||
describe("appVersion store", () => {
|
||||
beforeEach(() => __resetVersionStateForTest());
|
||||
|
||||
it("ignores the same version, empty, or null", () => {
|
||||
reportServerVersion(BUILD_VERSION);
|
||||
reportServerVersion("");
|
||||
reportServerVersion(null);
|
||||
reportServerVersion(undefined);
|
||||
expect(getNewVersion()).toBeNull();
|
||||
});
|
||||
|
||||
it("latches + notifies once when the server reports a different version", () => {
|
||||
let notified = 0;
|
||||
const unsub = subscribeNewVersion(() => {
|
||||
notified++;
|
||||
});
|
||||
|
||||
reportServerVersion(`${BUILD_VERSION}-next`);
|
||||
expect(getNewVersion()).toBe(`${BUILD_VERSION}-next`);
|
||||
expect(notified).toBe(1);
|
||||
|
||||
// Idempotent: once a new version is known, further reports don't re-notify
|
||||
// or overwrite (the banner is already showing).
|
||||
reportServerVersion("99.0.0");
|
||||
expect(getNewVersion()).toBe(`${BUILD_VERSION}-next`);
|
||||
expect(notified).toBe(1);
|
||||
|
||||
unsub();
|
||||
});
|
||||
});
|
||||
130
src/__tests__/item-discount-pdf.test.ts
Normal file
130
src/__tests__/item-discount-pdf.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
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 offersPdfRoutes from "../routes/admin/offers-pdf";
|
||||
import invoicesPdfRoutes from "../routes/admin/invoices-pdf";
|
||||
import { createInvoice } from "../services/invoices.service";
|
||||
|
||||
/**
|
||||
* The per-item Sleva column is CONDITIONAL on the PDF: render it only when at
|
||||
* least one item carries a discount > 0; otherwise the table looks exactly as
|
||||
* before. The discounted net always flows into the line/grand totals.
|
||||
*/
|
||||
|
||||
let app: ReturnType<typeof Fastify> | null = null;
|
||||
let adminToken = "";
|
||||
const quotationIds: number[] = [];
|
||||
const invoiceIds: number[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(offersPdfRoutes, { prefix: "/api/admin/offers-pdf" });
|
||||
await app.register(invoicesPdfRoutes, { prefix: "/api/admin/invoices-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 quotationIds)
|
||||
await prisma.quotations.deleteMany({ where: { id } });
|
||||
for (const id of invoiceIds)
|
||||
await prisma.invoices.deleteMany({ where: { id } });
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
const offerHtml = async (discount: number): Promise<string> => {
|
||||
const q = await prisma.quotations.create({
|
||||
data: {
|
||||
quotation_number: `Q-SLEVA-${discount}-${Date.now()}`,
|
||||
status: "active",
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
quotation_items: {
|
||||
create: [
|
||||
{
|
||||
description: "Položka",
|
||||
quantity: 2,
|
||||
unit: "ks",
|
||||
unit_price: 100,
|
||||
discount,
|
||||
is_included_in_total: true,
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
quotationIds.push(q.id);
|
||||
const res = await app!.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/offers-pdf/${q.id}`,
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
return res.body;
|
||||
};
|
||||
|
||||
describe("offer PDF Sleva column", () => {
|
||||
it("renders a Sleva column + discounted total when an item has a discount", async () => {
|
||||
const html = await offerHtml(10);
|
||||
expect(html).toContain("Sleva");
|
||||
// 2 × 100 with 10% off => 180,00 (no thousands separator at this size).
|
||||
expect(html).toContain("180,00");
|
||||
expect(html).not.toContain("200,00");
|
||||
});
|
||||
|
||||
it("omits the Sleva column when no item has a discount", async () => {
|
||||
const html = await offerHtml(0);
|
||||
expect(html).not.toContain("Sleva");
|
||||
expect(html).toContain("200,00");
|
||||
});
|
||||
});
|
||||
|
||||
const invoiceHtml = async (discount: number): Promise<string> => {
|
||||
const inv = await createInvoice({
|
||||
status: "draft",
|
||||
currency: "CZK",
|
||||
apply_vat: true,
|
||||
vat_rate: 21,
|
||||
language: "cs",
|
||||
items: [
|
||||
{
|
||||
description: "Položka",
|
||||
quantity: 2,
|
||||
unit_price: 1000,
|
||||
discount,
|
||||
vat_rate: 21,
|
||||
},
|
||||
],
|
||||
});
|
||||
invoiceIds.push(inv.id);
|
||||
const res = await app!.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/invoices-pdf/${inv.id}`,
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
return res.body;
|
||||
};
|
||||
|
||||
describe("invoice PDF Sleva column", () => {
|
||||
it("renders a Sleva column when an item has a discount", async () => {
|
||||
const html = await invoiceHtml(10);
|
||||
expect(html).toContain("Sleva");
|
||||
expect(html).toContain("10");
|
||||
});
|
||||
|
||||
it("omits the Sleva column when no item has a discount", async () => {
|
||||
const html = await invoiceHtml(0);
|
||||
expect(html).not.toContain("Sleva");
|
||||
});
|
||||
});
|
||||
55
src/__tests__/item-discount-schema.test.ts
Normal file
55
src/__tests__/item-discount-schema.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { CreateQuotationSchema } from "../schemas/offers.schema";
|
||||
import { CreateInvoiceSchema } from "../schemas/invoices.schema";
|
||||
|
||||
/**
|
||||
* The per-item Sleva is a percentage: the offer- and invoice-item schemas must
|
||||
* accept 0–100, default to 0 when absent, and reject out-of-range values.
|
||||
*/
|
||||
|
||||
describe("offer item discount schema", () => {
|
||||
it("accepts a 0–100 discount", () => {
|
||||
const r = CreateQuotationSchema.safeParse({
|
||||
items: [{ description: "X", quantity: 1, unit_price: 100, discount: 15 }],
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.success && r.data.items?.[0].discount).toBe(15);
|
||||
});
|
||||
|
||||
it("defaults discount to 0 when absent", () => {
|
||||
const r = CreateQuotationSchema.safeParse({
|
||||
items: [{ description: "X", quantity: 1, unit_price: 100 }],
|
||||
});
|
||||
expect(r.success && r.data.items?.[0].discount).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a discount above 100", () => {
|
||||
const r = CreateQuotationSchema.safeParse({
|
||||
items: [
|
||||
{ description: "X", quantity: 1, unit_price: 100, discount: 150 },
|
||||
],
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invoice item discount schema", () => {
|
||||
it("accepts a 0–100 discount and defaults to 0", () => {
|
||||
const ok = CreateInvoiceSchema.safeParse({
|
||||
items: [{ description: "X", quantity: 1, unit_price: 100, discount: 10 }],
|
||||
});
|
||||
expect(ok.success && ok.data.items?.[0].discount).toBe(10);
|
||||
|
||||
const def = CreateInvoiceSchema.safeParse({
|
||||
items: [{ description: "X", quantity: 1, unit_price: 100 }],
|
||||
});
|
||||
expect(def.success && def.data.items?.[0].discount).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a negative discount", () => {
|
||||
const r = CreateInvoiceSchema.safeParse({
|
||||
items: [{ description: "X", quantity: 1, unit_price: 100, discount: -5 }],
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
});
|
||||
94
src/__tests__/item-discount-totals.test.ts
Normal file
94
src/__tests__/item-discount-totals.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import prisma from "../config/database";
|
||||
import { createOffer, getOfferTotals } from "../services/offers.service";
|
||||
import {
|
||||
createInvoice,
|
||||
getInvoiceListTotals,
|
||||
} from "../services/invoices.service";
|
||||
|
||||
/**
|
||||
* Per-item Sleva (% discount) must reduce the line NET — and on invoices the
|
||||
* VAT is computed on the discounted net. Hits the real `app_test` DB through the
|
||||
* service layer (suite convention), mirroring offer-invoice-totals.test.ts.
|
||||
*/
|
||||
|
||||
const MARKER = "SLEVA-TOTALS-2096";
|
||||
const YEAR = 2096;
|
||||
const MONTH = 8; // invoice issue_date window [2096-08-01, 2096-09-01)
|
||||
const dateStr = `${YEAR}-0${MONTH}-15`;
|
||||
|
||||
const offerIds: number[] = [];
|
||||
const invoiceIds: number[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const id of offerIds)
|
||||
await prisma.quotations.deleteMany({ where: { id } });
|
||||
for (const id of invoiceIds)
|
||||
await prisma.invoices.deleteMany({ where: { id } });
|
||||
offerIds.length = 0;
|
||||
invoiceIds.length = 0;
|
||||
await prisma.number_sequences.deleteMany({
|
||||
where: { type: { in: ["offer", "invoice"] } },
|
||||
});
|
||||
});
|
||||
|
||||
const amountFor = (
|
||||
totals: { currency: string; amount: number }[],
|
||||
currency: string,
|
||||
): number | undefined => totals.find((t) => t.currency === currency)?.amount;
|
||||
|
||||
describe("offer per-item discount", () => {
|
||||
it("reduces the offer NET total by the per-item percentage", async () => {
|
||||
// 2 × 1000 with 10% off => 1800 net.
|
||||
const res = await createOffer({
|
||||
status: "draft",
|
||||
currency: "CZK",
|
||||
project_code: MARKER,
|
||||
items: [
|
||||
{ description: "X", quantity: 2, unit_price: 1000, discount: 10 },
|
||||
],
|
||||
});
|
||||
if ("error" in res) throw new Error(JSON.stringify(res));
|
||||
offerIds.push(res.id);
|
||||
|
||||
const { totals } = await getOfferTotals({ search: MARKER });
|
||||
expect(amountFor(totals, "CZK")).toBe(1800);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invoice per-item discount", () => {
|
||||
it("computes VAT on the discounted net (gross = discounted net + VAT)", async () => {
|
||||
// 2 × 1000 with 10% off => net 1800, VAT@21% = 378, gross 2178.
|
||||
const inv = await createInvoice({
|
||||
status: "draft",
|
||||
currency: "CZK",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
issue_date: dateStr,
|
||||
items: [
|
||||
{ description: "X", quantity: 2, unit_price: 1000, discount: 10 },
|
||||
],
|
||||
});
|
||||
invoiceIds.push(inv.id);
|
||||
|
||||
const { totals } = await getInvoiceListTotals({ month: MONTH, year: YEAR });
|
||||
expect(amountFor(totals, "CZK")).toBe(2178);
|
||||
});
|
||||
|
||||
it("treats a 100% discount line as zero", async () => {
|
||||
const inv = await createInvoice({
|
||||
status: "draft",
|
||||
currency: "CZK",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
issue_date: dateStr,
|
||||
items: [
|
||||
{ description: "Free", quantity: 5, unit_price: 200, discount: 100 },
|
||||
],
|
||||
});
|
||||
invoiceIds.push(inv.id);
|
||||
|
||||
const { totals } = await getInvoiceListTotals({ month: MONTH, year: YEAR });
|
||||
expect(amountFor(totals, "CZK")).toBeUndefined(); // zero totals are dropped
|
||||
});
|
||||
});
|
||||
22
src/__tests__/money.test.ts
Normal file
22
src/__tests__/money.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { lineNet } from "../utils/money";
|
||||
|
||||
describe("lineNet (per-item discounted net)", () => {
|
||||
it("returns qty × unit_price when there is no discount", () => {
|
||||
expect(lineNet(2, 1000, 0)).toBe(2000);
|
||||
});
|
||||
|
||||
it("applies a percentage discount to the line net", () => {
|
||||
expect(lineNet(2, 1000, 10)).toBe(1800);
|
||||
});
|
||||
|
||||
it("treats a 100% discount as zero", () => {
|
||||
expect(lineNet(3, 500, 100)).toBe(0);
|
||||
});
|
||||
|
||||
it("treats null/undefined/NaN discount as no discount", () => {
|
||||
expect(lineNet(2, 1000, null)).toBe(2000);
|
||||
expect(lineNet(2, 1000, undefined)).toBe(2000);
|
||||
expect(lineNet(2, 1000, NaN)).toBe(2000);
|
||||
});
|
||||
});
|
||||
24
src/__tests__/version-header.test.ts
Normal file
24
src/__tests__/version-header.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import { registerVersionHeader, readAppVersion } from "../middleware/version";
|
||||
|
||||
/**
|
||||
* The server stamps every response with X-App-Version so the SPA can detect a
|
||||
* deploy (the client compares it to the version baked into its bundle).
|
||||
*/
|
||||
|
||||
describe("registerVersionHeader", () => {
|
||||
it("adds X-App-Version to responses", async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
registerVersionHeader(app, "9.9.9");
|
||||
app.get("/ping", async () => ({ ok: true }));
|
||||
|
||||
const res = await app.inject({ method: "GET", url: "/ping" });
|
||||
expect(res.headers["x-app-version"]).toBe("9.9.9");
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("defaults to the package.json version (semver string)", () => {
|
||||
expect(readAppVersion()).toMatch(/^\d+\.\d+\.\d+/);
|
||||
});
|
||||
});
|
||||
@@ -7,65 +7,97 @@ import { queryClient } from "./lib/queryClient";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import AppShell from "./ui/AppShell";
|
||||
import AlertContainer from "./components/AlertContainer";
|
||||
import UpdateBanner from "./components/UpdateBanner";
|
||||
import { lazyWithReload } from "./utils/lazyWithReload";
|
||||
import MuiProvider from "./ui/MuiProvider";
|
||||
import { LoadingState } from "./ui";
|
||||
import Login from "./pages/Login";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
|
||||
const Odin = lazy(() => import("./pages/Odin"));
|
||||
const Users = lazy(() => import("./pages/Users"));
|
||||
const Attendance = lazy(() => import("./pages/Attendance"));
|
||||
const AttendanceHistory = lazy(() => import("./pages/AttendanceHistory"));
|
||||
const AttendanceAdmin = lazy(() => import("./pages/AttendanceAdmin"));
|
||||
const AttendanceBalances = lazy(() => import("./pages/AttendanceBalances"));
|
||||
const AttendanceCreate = lazy(() => import("./pages/AttendanceCreate"));
|
||||
const LeaveRequests = lazy(() => import("./pages/LeaveRequests"));
|
||||
const LeaveApproval = lazy(() => import("./pages/LeaveApproval"));
|
||||
const AttendanceLocation = lazy(() => import("./pages/AttendanceLocation"));
|
||||
const PlanWork = lazy(() => import("./pages/PlanWork"));
|
||||
const Trips = lazy(() => import("./pages/Trips"));
|
||||
const TripsHistory = lazy(() => import("./pages/TripsHistory"));
|
||||
const TripsAdmin = lazy(() => import("./pages/TripsAdmin"));
|
||||
const Vehicles = lazy(() => import("./pages/Vehicles"));
|
||||
const Offers = lazy(() => import("./pages/Offers"));
|
||||
const OfferDetail = lazy(() => import("./pages/OfferDetail"));
|
||||
const OffersCustomers = lazy(() => import("./pages/OffersCustomers"));
|
||||
const OffersTemplates = lazy(() => import("./pages/OffersTemplates"));
|
||||
const Orders = lazy(() => import("./pages/Orders"));
|
||||
const OrderDetail = lazy(() => import("./pages/OrderDetail"));
|
||||
const IssuedOrderDetail = lazy(() => import("./pages/IssuedOrderDetail"));
|
||||
const Projects = lazy(() => import("./pages/Projects"));
|
||||
const ProjectDetail = lazy(() => import("./pages/ProjectDetail"));
|
||||
const Invoices = lazy(() => import("./pages/Invoices"));
|
||||
const InvoiceDetail = lazy(() => import("./pages/InvoiceDetail"));
|
||||
const Settings = lazy(() => import("./pages/Settings"));
|
||||
const AuditLog = lazy(() => import("./pages/AuditLog"));
|
||||
const NotFound = lazy(() => import("./pages/NotFound"));
|
||||
const Warehouse = lazy(() => import("./pages/Warehouse"));
|
||||
const WarehouseItems = lazy(() => import("./pages/WarehouseItems"));
|
||||
const WarehouseItemDetail = lazy(() => import("./pages/WarehouseItemDetail"));
|
||||
const WarehouseReceipts = lazy(() => import("./pages/WarehouseReceipts"));
|
||||
const WarehouseReceiptDetail = lazy(
|
||||
const Odin = lazyWithReload(() => import("./pages/Odin"));
|
||||
const Users = lazyWithReload(() => import("./pages/Users"));
|
||||
const Attendance = lazyWithReload(() => import("./pages/Attendance"));
|
||||
const AttendanceHistory = lazyWithReload(
|
||||
() => import("./pages/AttendanceHistory"),
|
||||
);
|
||||
const AttendanceAdmin = lazyWithReload(() => import("./pages/AttendanceAdmin"));
|
||||
const AttendanceBalances = lazyWithReload(
|
||||
() => import("./pages/AttendanceBalances"),
|
||||
);
|
||||
const AttendanceCreate = lazyWithReload(
|
||||
() => import("./pages/AttendanceCreate"),
|
||||
);
|
||||
const LeaveRequests = lazyWithReload(() => import("./pages/LeaveRequests"));
|
||||
const LeaveApproval = lazyWithReload(() => import("./pages/LeaveApproval"));
|
||||
const AttendanceLocation = lazyWithReload(
|
||||
() => import("./pages/AttendanceLocation"),
|
||||
);
|
||||
const PlanWork = lazyWithReload(() => import("./pages/PlanWork"));
|
||||
const Trips = lazyWithReload(() => import("./pages/Trips"));
|
||||
const TripsHistory = lazyWithReload(() => import("./pages/TripsHistory"));
|
||||
const TripsAdmin = lazyWithReload(() => import("./pages/TripsAdmin"));
|
||||
const Vehicles = lazyWithReload(() => import("./pages/Vehicles"));
|
||||
const Offers = lazyWithReload(() => import("./pages/Offers"));
|
||||
const OfferDetail = lazyWithReload(() => import("./pages/OfferDetail"));
|
||||
const OffersCustomers = lazyWithReload(() => import("./pages/OffersCustomers"));
|
||||
const OffersTemplates = lazyWithReload(() => import("./pages/OffersTemplates"));
|
||||
const Orders = lazyWithReload(() => import("./pages/Orders"));
|
||||
const OrderDetail = lazyWithReload(() => import("./pages/OrderDetail"));
|
||||
const IssuedOrderDetail = lazyWithReload(
|
||||
() => import("./pages/IssuedOrderDetail"),
|
||||
);
|
||||
const Projects = lazyWithReload(() => import("./pages/Projects"));
|
||||
const ProjectDetail = lazyWithReload(() => import("./pages/ProjectDetail"));
|
||||
const Invoices = lazyWithReload(() => import("./pages/Invoices"));
|
||||
const InvoiceDetail = lazyWithReload(() => import("./pages/InvoiceDetail"));
|
||||
const Settings = lazyWithReload(() => import("./pages/Settings"));
|
||||
const AuditLog = lazyWithReload(() => import("./pages/AuditLog"));
|
||||
const NotFound = lazyWithReload(() => import("./pages/NotFound"));
|
||||
const Warehouse = lazyWithReload(() => import("./pages/Warehouse"));
|
||||
const WarehouseItems = lazyWithReload(() => import("./pages/WarehouseItems"));
|
||||
const WarehouseItemDetail = lazyWithReload(
|
||||
() => import("./pages/WarehouseItemDetail"),
|
||||
);
|
||||
const WarehouseReceipts = lazyWithReload(
|
||||
() => import("./pages/WarehouseReceipts"),
|
||||
);
|
||||
const WarehouseReceiptDetail = lazyWithReload(
|
||||
() => import("./pages/WarehouseReceiptDetail"),
|
||||
);
|
||||
const WarehouseReceiptForm = lazy(() => import("./pages/WarehouseReceiptForm"));
|
||||
const WarehouseIssues = lazy(() => import("./pages/WarehouseIssues"));
|
||||
const WarehouseIssueDetail = lazy(() => import("./pages/WarehouseIssueDetail"));
|
||||
const WarehouseIssueForm = lazy(() => import("./pages/WarehouseIssueForm"));
|
||||
const WarehouseReservations = lazy(
|
||||
const WarehouseReceiptForm = lazyWithReload(
|
||||
() => import("./pages/WarehouseReceiptForm"),
|
||||
);
|
||||
const WarehouseIssues = lazyWithReload(() => import("./pages/WarehouseIssues"));
|
||||
const WarehouseIssueDetail = lazyWithReload(
|
||||
() => import("./pages/WarehouseIssueDetail"),
|
||||
);
|
||||
const WarehouseIssueForm = lazyWithReload(
|
||||
() => import("./pages/WarehouseIssueForm"),
|
||||
);
|
||||
const WarehouseReservations = lazyWithReload(
|
||||
() => import("./pages/WarehouseReservations"),
|
||||
);
|
||||
const WarehouseInventory = lazy(() => import("./pages/WarehouseInventory"));
|
||||
const WarehouseInventoryDetail = lazy(
|
||||
const WarehouseInventory = lazyWithReload(
|
||||
() => import("./pages/WarehouseInventory"),
|
||||
);
|
||||
const WarehouseInventoryDetail = lazyWithReload(
|
||||
() => import("./pages/WarehouseInventoryDetail"),
|
||||
);
|
||||
const WarehouseInventoryForm = lazy(
|
||||
const WarehouseInventoryForm = lazyWithReload(
|
||||
() => import("./pages/WarehouseInventoryForm"),
|
||||
);
|
||||
const WarehouseReports = lazy(() => import("./pages/WarehouseReports"));
|
||||
const WarehouseSuppliers = lazy(() => import("./pages/WarehouseSuppliers"));
|
||||
const WarehouseLocations = lazy(() => import("./pages/WarehouseLocations"));
|
||||
const WarehouseCategories = lazy(() => import("./pages/WarehouseCategories"));
|
||||
const WarehouseReports = lazyWithReload(
|
||||
() => import("./pages/WarehouseReports"),
|
||||
);
|
||||
const WarehouseSuppliers = lazyWithReload(
|
||||
() => import("./pages/WarehouseSuppliers"),
|
||||
);
|
||||
const WarehouseLocations = lazyWithReload(
|
||||
() => import("./pages/WarehouseLocations"),
|
||||
);
|
||||
const WarehouseCategories = lazyWithReload(
|
||||
() => import("./pages/WarehouseCategories"),
|
||||
);
|
||||
const UiKit = import.meta.env.DEV ? lazy(() => import("./pages/UiKit")) : null;
|
||||
|
||||
export default function AdminApp() {
|
||||
@@ -75,6 +107,7 @@ export default function AdminApp() {
|
||||
<AlertProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AlertContainer />
|
||||
<UpdateBanner />
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<LoadingState />}>
|
||||
<Routes>
|
||||
|
||||
35
src/admin/components/UpdateBanner.tsx
Normal file
35
src/admin/components/UpdateBanner.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import Snackbar from "@mui/material/Snackbar";
|
||||
import Button from "@mui/material/Button";
|
||||
import { subscribeNewVersion, getNewVersion } from "../utils/appVersion";
|
||||
|
||||
/**
|
||||
* Shows a persistent banner when the server reports a newer build than the one
|
||||
* this tab is running (detected via the X-App-Version header in apiFetch). The
|
||||
* reload is user-initiated — we never force it, so an in-progress form is safe.
|
||||
*/
|
||||
export default function UpdateBanner() {
|
||||
const newVersion = useSyncExternalStore(
|
||||
subscribeNewVersion,
|
||||
getNewVersion,
|
||||
() => null,
|
||||
);
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
open={Boolean(newVersion)}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
|
||||
message="Je k dispozici nová verze aplikace."
|
||||
action={
|
||||
<Button
|
||||
color="primary"
|
||||
size="small"
|
||||
variant="contained"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Obnovit
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -262,8 +262,9 @@ export default function DashProfile({
|
||||
initial={reduce ? false : { opacity: 0, y: 12 }}
|
||||
animate={reduce ? undefined : { opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.15 }}
|
||||
style={{ height: "100%" }}
|
||||
>
|
||||
<Card>
|
||||
<Card sx={{ height: "100%" }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
|
||||
@@ -132,8 +132,9 @@ export default function DashSessions() {
|
||||
initial={reduce ? false : { opacity: 0, y: 12 }}
|
||||
animate={reduce ? undefined : { opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.15 }}
|
||||
style={{ height: "100%" }}
|
||||
>
|
||||
<Card sx={{ display: "flex", flexDirection: "column" }}>
|
||||
<Card sx={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
|
||||
@@ -49,10 +49,31 @@ export interface DocumentItem {
|
||||
quantity: string | number;
|
||||
unit: string;
|
||||
unit_price: string | number;
|
||||
/** Per-item discount as a percentage (0–100). Offers only; 0 elsewhere. */
|
||||
discount: string | number;
|
||||
/** Offers only — undefined (issued orders) counts as included. */
|
||||
is_included_in_total?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discount input styling: centered, and with the native number spinners hidden
|
||||
* so a 3-digit value ("100") isn't clipped under the up/down arrows.
|
||||
*/
|
||||
const discountInputSx = {
|
||||
"& input": { textAlign: "center" },
|
||||
"& input[type=number]": { MozAppearance: "textfield" as const },
|
||||
"& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button": {
|
||||
WebkitAppearance: "none",
|
||||
margin: 0,
|
||||
},
|
||||
};
|
||||
|
||||
/** NET of one line after its per-item percentage discount. */
|
||||
const discountedLineNet = (item: DocumentItem): number =>
|
||||
(Number(item.quantity) || 0) *
|
||||
(Number(item.unit_price) || 0) *
|
||||
(1 - (Number(item.discount) || 0) / 100);
|
||||
|
||||
let documentItemKeyCounter = 0;
|
||||
|
||||
/** Unique client-side key for a (new or hydrated) document item row. */
|
||||
@@ -66,6 +87,7 @@ export const emptyDocumentItem = (): DocumentItem => ({
|
||||
quantity: 1,
|
||||
unit: "ks",
|
||||
unit_price: 0,
|
||||
discount: 0,
|
||||
is_included_in_total: true,
|
||||
});
|
||||
|
||||
@@ -73,9 +95,7 @@ export const emptyDocumentItem = (): DocumentItem => ({
|
||||
export const documentItemsTotal = (items: DocumentItem[]): number =>
|
||||
items.reduce(
|
||||
(sum, it) =>
|
||||
it.is_included_in_total === false
|
||||
? sum
|
||||
: sum + (Number(it.quantity) || 0) * (Number(it.unit_price) || 0),
|
||||
it.is_included_in_total === false ? sum : sum + discountedLineNet(it),
|
||||
0,
|
||||
);
|
||||
|
||||
@@ -111,6 +131,7 @@ function SortableItemRow({
|
||||
readOnly,
|
||||
canDelete,
|
||||
showIncludedInTotal,
|
||||
showDiscount,
|
||||
itemDescriptionMaxLength,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
@@ -121,6 +142,7 @@ function SortableItemRow({
|
||||
readOnly: boolean;
|
||||
canDelete: boolean;
|
||||
showIncludedInTotal: boolean;
|
||||
showDiscount: boolean;
|
||||
itemDescriptionMaxLength: number;
|
||||
onUpdate: (field: keyof DocumentItem, value: string | boolean) => void;
|
||||
onRemove: () => void;
|
||||
@@ -143,8 +165,7 @@ function SortableItemRow({
|
||||
position: "relative" as const,
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
};
|
||||
const lineTotal =
|
||||
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
||||
const lineTotal = discountedLineNet(item);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
@@ -253,6 +274,17 @@ function SortableItemRow({
|
||||
InputProps={{ readOnly }}
|
||||
sx={{ "& input": { textAlign: "right" } }}
|
||||
/>
|
||||
{showDiscount && (
|
||||
<TextField
|
||||
label="Sleva %"
|
||||
type="number"
|
||||
value={item.discount ?? ""}
|
||||
onChange={(e) => onUpdate("discount", e.target.value)}
|
||||
slotProps={{ htmlInput: { min: "0", max: "100", step: "any" } }}
|
||||
InputProps={{ readOnly }}
|
||||
sx={discountInputSx}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
@@ -396,6 +428,18 @@ function SortableItemRow({
|
||||
sx={{ "& input": { textAlign: "right" } }}
|
||||
/>
|
||||
</TableCell>
|
||||
{showDiscount && (
|
||||
<TableCell>
|
||||
<TextField
|
||||
type="number"
|
||||
value={item.discount ?? ""}
|
||||
onChange={(e) => onUpdate("discount", e.target.value)}
|
||||
slotProps={{ htmlInput: { min: "0", max: "100", step: "any" } }}
|
||||
InputProps={{ readOnly }}
|
||||
sx={discountInputSx}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{showIncludedInTotal && (
|
||||
<TableCell align="center">
|
||||
<Checkbox
|
||||
@@ -442,6 +486,8 @@ interface DocumentItemsEditorProps {
|
||||
error?: string;
|
||||
/** Render the offers-only "V ceně" (is_included_in_total) column. */
|
||||
showIncludedInTotal?: boolean;
|
||||
/** Render the per-item "Sleva %" discount column (offers only). */
|
||||
showDiscount?: boolean;
|
||||
/** Schema cap for the detailed description (offers 8000, issued orders 5000). */
|
||||
itemDescriptionMaxLength?: number;
|
||||
/** Optional page-specific control (e.g. item-template Select) next to the add button. */
|
||||
@@ -460,6 +506,7 @@ export default function DocumentItemsEditor({
|
||||
readOnly,
|
||||
error,
|
||||
showIncludedInTotal = false,
|
||||
showDiscount = false,
|
||||
itemDescriptionMaxLength = 5000,
|
||||
templatesSlot,
|
||||
}: DocumentItemsEditorProps) {
|
||||
@@ -563,6 +610,7 @@ export default function DocumentItemsEditor({
|
||||
readOnly={readOnly}
|
||||
canDelete={items.length > 1}
|
||||
showIncludedInTotal={showIncludedInTotal}
|
||||
showDiscount={showDiscount}
|
||||
itemDescriptionMaxLength={itemDescriptionMaxLength}
|
||||
onUpdate={(field, value) => updateItem(index, field, value)}
|
||||
onRemove={() => removeItem(index)}
|
||||
@@ -594,6 +642,11 @@ export default function DocumentItemsEditor({
|
||||
<TableCell sx={{ width: "8rem" }} align="center">
|
||||
Jedn. cena
|
||||
</TableCell>
|
||||
{showDiscount && (
|
||||
<TableCell sx={{ width: "7rem" }} align="center">
|
||||
Sleva %
|
||||
</TableCell>
|
||||
)}
|
||||
{showIncludedInTotal && (
|
||||
<TableCell sx={{ width: "4rem" }} align="center">
|
||||
V ceně
|
||||
@@ -615,6 +668,7 @@ export default function DocumentItemsEditor({
|
||||
readOnly={readOnly}
|
||||
canDelete={items.length > 1}
|
||||
showIncludedInTotal={showIncludedInTotal}
|
||||
showDiscount={showDiscount}
|
||||
itemDescriptionMaxLength={itemDescriptionMaxLength}
|
||||
onUpdate={(field, value) =>
|
||||
updateItem(index, field, value)
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface InvoiceItem {
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unit_price: number;
|
||||
discount?: number;
|
||||
vat_rate?: number;
|
||||
is_included_in_total: boolean;
|
||||
position?: number;
|
||||
|
||||
@@ -143,6 +143,7 @@ export interface OfferItemData {
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unit_price: number;
|
||||
discount?: number;
|
||||
is_included_in_total: boolean;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
@@ -348,7 +348,9 @@ export default function Dashboard() {
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr 1fr" },
|
||||
gap: 3,
|
||||
alignItems: "start",
|
||||
// stretch (not start) so the cards in a row share one height —
|
||||
// even bottoms instead of ragged content-height cards.
|
||||
alignItems: "stretch",
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
@@ -360,10 +362,18 @@ export default function Dashboard() {
|
||||
<DashAttendanceToday attendance={dashData?.attendance ?? null} />
|
||||
)}
|
||||
|
||||
{/* Pravy sloupec: projekty + nabidky */}
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||||
{/* Pravy sloupec: projekty + nabidky — both grow so the stacked
|
||||
cards fill the column to match the neighbouring cards' height. */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 3,
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
{dashData?.projects && (
|
||||
<Card sx={{ display: "flex", flexDirection: "column" }}>
|
||||
<Card sx={{ display: "flex", flexDirection: "column", flex: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
@@ -421,7 +431,7 @@ export default function Dashboard() {
|
||||
)}
|
||||
|
||||
{dashData?.offers && (
|
||||
<Card sx={{ display: "flex", flexDirection: "column" }}>
|
||||
<Card sx={{ display: "flex", flexDirection: "column", flex: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
@@ -498,7 +508,7 @@ export default function Dashboard() {
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" },
|
||||
gap: 3,
|
||||
alignItems: "start",
|
||||
alignItems: "stretch",
|
||||
}}
|
||||
>
|
||||
<DashProfile
|
||||
|
||||
@@ -185,9 +185,34 @@ interface InvoiceItem {
|
||||
quantity: string | number;
|
||||
unit: string;
|
||||
unit_price: string | number;
|
||||
/** Per-item discount as a percentage (0–100). */
|
||||
discount: string | number;
|
||||
vat_rate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discount input styling: centered, native number spinners hidden so a 3-digit
|
||||
* value ("100") isn't clipped under the up/down arrows.
|
||||
*/
|
||||
const discountInputSx = {
|
||||
"& input": { textAlign: "center" },
|
||||
"& input[type=number]": { MozAppearance: "textfield" as const },
|
||||
"& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button": {
|
||||
WebkitAppearance: "none",
|
||||
margin: 0,
|
||||
},
|
||||
};
|
||||
|
||||
/** NET of one invoice line after its per-item percentage discount. */
|
||||
const invoiceLineNet = (item: {
|
||||
quantity: string | number;
|
||||
unit_price: string | number;
|
||||
discount?: string | number | null;
|
||||
}): number =>
|
||||
(Number(item.quantity) || 0) *
|
||||
(Number(item.unit_price) || 0) *
|
||||
(1 - (Number(item.discount) || 0) / 100);
|
||||
|
||||
interface InvoiceForm {
|
||||
customer_id: number | null;
|
||||
customer_name: string;
|
||||
@@ -253,8 +278,7 @@ function SortableInvoiceRow({
|
||||
position: "relative" as const,
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
};
|
||||
const lineTotal =
|
||||
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
||||
const lineTotal = invoiceLineNet(item);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
@@ -349,6 +373,14 @@ function SortableInvoiceRow({
|
||||
onChange={(e) => onUpdate(index, "unit_price", e.target.value)}
|
||||
slotProps={{ htmlInput: { step: "any" } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Sleva %"
|
||||
type="number"
|
||||
value={item.discount ?? ""}
|
||||
onChange={(e) => onUpdate(index, "discount", e.target.value)}
|
||||
slotProps={{ htmlInput: { min: "0", max: "100", step: "any" } }}
|
||||
sx={discountInputSx}
|
||||
/>
|
||||
{apply_vat && (
|
||||
<Select
|
||||
label="DPH"
|
||||
@@ -458,6 +490,15 @@ function SortableInvoiceRow({
|
||||
sx={{ "& input": { textAlign: "right" } }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextField
|
||||
type="number"
|
||||
value={item.discount ?? ""}
|
||||
onChange={(e) => onUpdate(index, "discount", e.target.value)}
|
||||
slotProps={{ htmlInput: { min: "0", max: "100", step: "any" } }}
|
||||
sx={discountInputSx}
|
||||
/>
|
||||
</TableCell>
|
||||
{apply_vat ? (
|
||||
<TableCell>
|
||||
<Select
|
||||
@@ -513,6 +554,7 @@ export default function InvoiceDetail() {
|
||||
quantity: 1,
|
||||
unit: "ks",
|
||||
unit_price: 0,
|
||||
discount: 0,
|
||||
vat_rate: 21,
|
||||
}),
|
||||
[],
|
||||
@@ -577,6 +619,7 @@ export default function InvoiceDetail() {
|
||||
quantity: 1,
|
||||
unit: "ks",
|
||||
unit_price: 0,
|
||||
discount: 0,
|
||||
vat_rate: 21,
|
||||
},
|
||||
]);
|
||||
@@ -628,6 +671,10 @@ export default function InvoiceDetail() {
|
||||
|
||||
const invoiceQuery = useQuery(invoiceDetailOptions(id));
|
||||
const invoice = invoiceQuery.data ?? null;
|
||||
// Read-only view: show the Sleva column only when some line carries a discount.
|
||||
const hasItemDiscount = (invoice?.items ?? []).some(
|
||||
(it) => Number(it.discount) > 0,
|
||||
);
|
||||
|
||||
const nextNumberQuery = useQuery({
|
||||
queryKey: ["invoices", "next-number"],
|
||||
@@ -751,6 +798,7 @@ export default function InvoiceDetail() {
|
||||
quantity: Number(item.quantity) || 1,
|
||||
unit: item.unit || "",
|
||||
unit_price: Number(item.unit_price) || 0,
|
||||
discount: Number(item.discount) || 0,
|
||||
// Per-line VAT: hydrate from the ITEM's own stored rate (a real DB
|
||||
// column returned by the detail endpoint) — falling back to the
|
||||
// invoice-level rate only when the line carries none. Hydrating
|
||||
@@ -848,6 +896,7 @@ export default function InvoiceDetail() {
|
||||
quantity: Number(item.quantity) || 1,
|
||||
unit: (item.unit as string) || "",
|
||||
unit_price: Number(item.unit_price) || 0,
|
||||
discount: Number(item.discount) || 0,
|
||||
vat_rate: vatRate,
|
||||
})),
|
||||
);
|
||||
@@ -967,8 +1016,7 @@ export default function InvoiceDetail() {
|
||||
const vatByRate: Record<number, number> = {};
|
||||
|
||||
items.forEach((item) => {
|
||||
const lineTotal =
|
||||
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
||||
const lineTotal = invoiceLineNet(item);
|
||||
subtotal += lineTotal;
|
||||
|
||||
if (form.apply_vat) {
|
||||
@@ -1038,6 +1086,7 @@ export default function InvoiceDetail() {
|
||||
// field never reaches the server as NaN (mirrors createTotals).
|
||||
quantity: Number(item.quantity) || 0,
|
||||
unit_price: Number(item.unit_price) || 0,
|
||||
discount: Number(item.discount) || 0,
|
||||
position: i,
|
||||
})),
|
||||
sections: sections.map((s, i) => ({
|
||||
@@ -1361,9 +1410,7 @@ export default function InvoiceDetail() {
|
||||
isMobile ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||
{invoice.items.map((item, index) => {
|
||||
const lineSubtotal =
|
||||
(Number(item.quantity) || 0) *
|
||||
(Number(item.unit_price) || 0);
|
||||
const lineSubtotal = invoiceLineNet(item);
|
||||
const lineVat = Number(invoice.apply_vat)
|
||||
? (lineSubtotal * (Number(item.vat_rate) || 0)) / 100
|
||||
: 0;
|
||||
@@ -1494,6 +1541,11 @@ export default function InvoiceDetail() {
|
||||
<TableCell sx={{ width: "8rem" }} align="center">
|
||||
Jedn. cena
|
||||
</TableCell>
|
||||
{hasItemDiscount && (
|
||||
<TableCell sx={{ width: "4rem" }} align="center">
|
||||
Sleva %
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell sx={{ width: "4rem" }} align="center">
|
||||
DPH
|
||||
</TableCell>
|
||||
@@ -1504,9 +1556,7 @@ export default function InvoiceDetail() {
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{invoice.items.map((item, index) => {
|
||||
const lineSubtotal =
|
||||
(Number(item.quantity) || 0) *
|
||||
(Number(item.unit_price) || 0);
|
||||
const lineSubtotal = invoiceLineNet(item);
|
||||
const lineVat = Number(invoice.apply_vat)
|
||||
? (lineSubtotal * (Number(item.vat_rate) || 0)) / 100
|
||||
: 0;
|
||||
@@ -1555,6 +1605,13 @@ export default function InvoiceDetail() {
|
||||
>
|
||||
{formatCurrency(item.unit_price, invoice.currency)}
|
||||
</TableCell>
|
||||
{hasItemDiscount && (
|
||||
<TableCell align="center">
|
||||
{Number(item.discount) > 0
|
||||
? `${Number(item.discount)} %`
|
||||
: "—"}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell align="center">
|
||||
{Number(invoice.apply_vat)
|
||||
? Number(item.vat_rate)
|
||||
@@ -2238,6 +2295,9 @@ export default function InvoiceDetail() {
|
||||
<TableCell sx={{ width: "8rem" }} align="center">
|
||||
Jedn. cena
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: "7rem" }} align="center">
|
||||
Sleva %
|
||||
</TableCell>
|
||||
{form.apply_vat ? (
|
||||
<TableCell sx={{ width: "5rem" }} align="center">
|
||||
DPH
|
||||
|
||||
@@ -293,6 +293,8 @@ export default function IssuedOrderDetail() {
|
||||
quantity: numberOr(it.quantity, 1),
|
||||
unit: it.unit || "",
|
||||
unit_price: Number(it.unit_price) || 0,
|
||||
// Issued orders have no Sleva column; keep the shared item shape happy.
|
||||
discount: 0,
|
||||
}))
|
||||
: [emptyDocumentItem()];
|
||||
setItems(mappedItems);
|
||||
|
||||
@@ -93,6 +93,7 @@ interface OfferItemPayload {
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unit_price: number;
|
||||
discount: number;
|
||||
is_included_in_total: boolean;
|
||||
position: number;
|
||||
}
|
||||
@@ -303,6 +304,7 @@ export default function OfferDetail() {
|
||||
quantity: numberOr(it.quantity, 1),
|
||||
unit: it.unit || "",
|
||||
unit_price: Number(it.unit_price) || 0,
|
||||
discount: Number(it.discount) || 0,
|
||||
is_included_in_total: it.is_included_in_total !== false,
|
||||
}))
|
||||
: [emptyDocumentItem()];
|
||||
@@ -433,6 +435,7 @@ export default function OfferDetail() {
|
||||
quantity: Number(item.quantity) || 0,
|
||||
unit: item.unit,
|
||||
unit_price: Number(item.unit_price) || 0,
|
||||
discount: Number(item.discount) || 0,
|
||||
is_included_in_total: item.is_included_in_total !== false,
|
||||
position: i,
|
||||
})),
|
||||
@@ -936,6 +939,7 @@ export default function OfferDetail() {
|
||||
readOnly={readOnly}
|
||||
error={errors.items}
|
||||
showIncludedInTotal
|
||||
showDiscount
|
||||
itemDescriptionMaxLength={8000}
|
||||
templatesSlot={
|
||||
itemTemplates && itemTemplates.length > 0 ? (
|
||||
@@ -957,6 +961,7 @@ export default function OfferDetail() {
|
||||
quantity: 1,
|
||||
unit: "ks",
|
||||
unit_price: template.default_price || 0,
|
||||
discount: 0,
|
||||
is_included_in_total: true,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { reportServerVersion } from "./appVersion";
|
||||
|
||||
class ApiState {
|
||||
showSessionExpiredAlert = false;
|
||||
showLogoutAlert = false;
|
||||
@@ -120,6 +122,8 @@ export const apiFetch = async (
|
||||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
// Detect a new deploy: compare the server's build version to ours.
|
||||
reportServerVersion(response.headers.get("X-App-Version"));
|
||||
|
||||
if (response.status === 401 && state.refreshFn) {
|
||||
// If the caller already unmounted (TanStack Query aborted the request),
|
||||
|
||||
48
src/admin/utils/appVersion.ts
Normal file
48
src/admin/utils/appVersion.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* New-deploy detection for the SPA.
|
||||
*
|
||||
* The server stamps every response with `X-App-Version`. `apiFetch` feeds that
|
||||
* header here via `reportServerVersion`. When it differs from the version baked
|
||||
* into THIS bundle at build time (`BUILD_VERSION`), a deploy has happened — we
|
||||
* latch it and notify subscribers (the refresh banner). We never force a
|
||||
* reload; the user picks the moment (they may be mid-form).
|
||||
*/
|
||||
|
||||
// Injected by Vite `define` at build time; falls back to "dev" in tests / when
|
||||
// running unbuilt. `typeof` guard avoids a ReferenceError when undefined.
|
||||
declare const __APP_VERSION__: string;
|
||||
export const BUILD_VERSION: string =
|
||||
typeof __APP_VERSION__ !== "undefined" ? __APP_VERSION__ : "dev";
|
||||
|
||||
let newVersion: string | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
/**
|
||||
* Compare a server-reported version against this bundle's build version. Latches
|
||||
* the first genuine mismatch and notifies subscribers exactly once.
|
||||
*/
|
||||
export function reportServerVersion(
|
||||
serverVersion: string | null | undefined,
|
||||
): void {
|
||||
if (newVersion) return; // already latched — banner is up
|
||||
if (!serverVersion || serverVersion === BUILD_VERSION) return;
|
||||
newVersion = serverVersion;
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
export function subscribeNewVersion(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function getNewVersion(): string | null {
|
||||
return newVersion;
|
||||
}
|
||||
|
||||
/** Test-only: reset the module singleton between cases. */
|
||||
export function __resetVersionStateForTest(): void {
|
||||
newVersion = null;
|
||||
listeners.clear();
|
||||
}
|
||||
35
src/admin/utils/lazyWithReload.ts
Normal file
35
src/admin/utils/lazyWithReload.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { lazy } from "react";
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
const RELOAD_KEY = "spa-chunk-reloaded";
|
||||
|
||||
/**
|
||||
* React.lazy that survives a deploy. If a page's dynamic import fails because
|
||||
* its hashed chunk was removed by a newer build, reload once to fetch the fresh
|
||||
* bundle (guarded by sessionStorage so a genuine load failure can't loop). On a
|
||||
* successful load the guard is cleared so the next deploy can recover too.
|
||||
*
|
||||
* The generic mirrors React.lazy's own `ComponentType<any>` constraint — there
|
||||
* is no non-any equivalent that satisfies it, so the `any` is unavoidable here.
|
||||
*/
|
||||
export function lazyWithReload<
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
T extends ComponentType<any>,
|
||||
>(factory: () => Promise<{ default: T }>) {
|
||||
return lazy(() =>
|
||||
factory()
|
||||
.then((mod) => {
|
||||
sessionStorage.removeItem(RELOAD_KEY);
|
||||
return mod;
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!sessionStorage.getItem(RELOAD_KEY)) {
|
||||
sessionStorage.setItem(RELOAD_KEY, "1");
|
||||
window.location.reload();
|
||||
// Never resolves — the reload replaces the document.
|
||||
return new Promise<{ default: T }>(() => {});
|
||||
}
|
||||
throw err;
|
||||
}),
|
||||
);
|
||||
}
|
||||
33
src/middleware/version.ts
Normal file
33
src/middleware/version.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
/**
|
||||
* The build version (from package.json), read once. A static `import` of the
|
||||
* root package.json would pull a file outside `src` into the program and expand
|
||||
* tsc's rootDir, so resolve it at runtime relative to the compiled file.
|
||||
*/
|
||||
export function readAppVersion(): string {
|
||||
try {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "../../package.json"), "utf8"),
|
||||
) as { version?: string };
|
||||
return pkg.version ?? "0.0.0";
|
||||
} catch {
|
||||
return "0.0.0";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp every response with `X-App-Version` so the SPA can detect a new deploy
|
||||
* (the client compares it to the version baked into its own bundle and prompts
|
||||
* a refresh on mismatch).
|
||||
*/
|
||||
export function registerVersionHeader(
|
||||
app: FastifyInstance,
|
||||
version: string = readAppVersion(),
|
||||
): void {
|
||||
app.addHook("onSend", async (_request, reply) => {
|
||||
reply.header("X-App-Version", version);
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { htmlToPdf } from "../../utils/html-to-pdf";
|
||||
import { getRate } from "../../services/exchange-rates";
|
||||
import { localDateStr } from "../../utils/date";
|
||||
import { parseId, success } from "../../utils/response";
|
||||
import { lineNet } from "../../utils/money";
|
||||
import {
|
||||
formatDate,
|
||||
formatNum,
|
||||
@@ -161,6 +162,7 @@ const translations: Record<string, Record<string, string>> = {
|
||||
col_desc: "Popis",
|
||||
col_qty: "Množství",
|
||||
col_unit_price: "Jedn. cena",
|
||||
col_discount: "Sleva",
|
||||
col_price: "Cena",
|
||||
col_vat_pct: "%DPH",
|
||||
col_vat: "DPH",
|
||||
@@ -208,6 +210,7 @@ const translations: Record<string, Record<string, string>> = {
|
||||
col_desc: "Description",
|
||||
col_qty: "Quantity",
|
||||
col_unit_price: "Unit price",
|
||||
col_discount: "Discount",
|
||||
col_price: "Price",
|
||||
col_vat_pct: "VAT%",
|
||||
col_vat: "VAT",
|
||||
@@ -312,6 +315,8 @@ export default async function invoicesPdfRoutes(
|
||||
where: { invoice_id: id },
|
||||
orderBy: [{ position: "asc" }, { id: "asc" }],
|
||||
});
|
||||
// Sleva column is rendered only when at least one item has a discount.
|
||||
const hasDiscount = items.some((it) => Number(it.discount) > 0);
|
||||
|
||||
let customer: Record<string, unknown> | null = null;
|
||||
if (invoice.customer_id) {
|
||||
@@ -362,7 +367,11 @@ export default async function invoicesPdfRoutes(
|
||||
let subtotal = 0;
|
||||
|
||||
for (const item of items) {
|
||||
const lineSubtotal = Number(item.quantity) * Number(item.unit_price);
|
||||
const lineSubtotal = lineNet(
|
||||
Number(item.quantity),
|
||||
Number(item.unit_price),
|
||||
Number(item.discount),
|
||||
);
|
||||
subtotal += lineSubtotal;
|
||||
const rate = Number(item.vat_rate);
|
||||
const key = String(rate);
|
||||
@@ -489,11 +498,16 @@ export default async function invoicesPdfRoutes(
|
||||
.map((item, i) => {
|
||||
const qty = Number(item.quantity);
|
||||
const unitPrice = Number(item.unit_price);
|
||||
const lineSubtotal = qty * unitPrice;
|
||||
const disc = Number(item.discount) || 0;
|
||||
const lineSubtotal = lineNet(qty, unitPrice, disc);
|
||||
const vatRate = Number(item.vat_rate);
|
||||
const lineVat = applyVat ? (lineSubtotal * vatRate) / 100 : 0;
|
||||
const lineTotal = lineSubtotal + lineVat;
|
||||
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
|
||||
const discDecimals = Math.floor(disc) === disc ? 0 : 2;
|
||||
const discCell = hasDiscount
|
||||
? `<td class="center">${disc > 0 ? `${formatNum(disc, discDecimals)} %` : "—"}</td>`
|
||||
: "";
|
||||
const subDesc = item.item_description || "";
|
||||
|
||||
return `<tr>
|
||||
@@ -501,6 +515,7 @@ export default async function invoicesPdfRoutes(
|
||||
<td class="desc">${escapeHtml(item.description)}${subDesc ? `<div class="item-subdesc">${escapeHtml(subDesc)}</div>` : ""}</td>
|
||||
<td class="center">${formatNum(qty, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
|
||||
<td class="right">${formatNum(unitPrice)}</td>
|
||||
${discCell}
|
||||
<td class="right">${formatNum(lineSubtotal)}</td>
|
||||
<td class="center">${applyVat ? Math.floor(vatRate) : 0}%</td>
|
||||
<td class="right">${formatNum(lineVat)}</td>
|
||||
@@ -1052,9 +1067,10 @@ ${indentCSS}
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
|
||||
<th style="width:36%">${escapeHtml(t.col_desc)}</th>
|
||||
<th style="width:${hasDiscount ? 29 : 36}%">${escapeHtml(t.col_desc)}</th>
|
||||
<th class="center" style="width:10%">${escapeHtml(t.col_qty)}</th>
|
||||
<th class="right" style="width:10%">${escapeHtml(t.col_unit_price)}</th>
|
||||
${hasDiscount ? `<th class="center" style="width:7%">${escapeHtml(t.col_discount)}</th>` : ""}
|
||||
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>
|
||||
<th class="center" style="width:5%">${escapeHtml(t.col_vat_pct)}</th>
|
||||
<th class="right" style="width:10%">${escapeHtml(t.col_vat)}</th>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { requirePermission } from "../../middleware/auth";
|
||||
import { nasOffersManager } from "../../services/nas-offers-manager";
|
||||
import { htmlToPdf } from "../../utils/html-to-pdf";
|
||||
import { parseId, success } from "../../utils/response";
|
||||
import { lineNet } from "../../utils/money";
|
||||
import {
|
||||
formatDate,
|
||||
formatNum,
|
||||
@@ -123,6 +124,7 @@ const TRANSLATIONS: Record<string, Record<string, string>> = {
|
||||
description: { EN: "Description", CZ: "Popis" },
|
||||
qty: { EN: "Qty", CZ: "Mn." },
|
||||
unit_price: { EN: "Unit Price", CZ: "Jedn. cena" },
|
||||
discount: { EN: "Discount", CZ: "Sleva" },
|
||||
included: { EN: "Included", CZ: "Zahrnuto" },
|
||||
total: { EN: "Total", CZ: "Celkem" },
|
||||
total_no_vat: { EN: "Total excl. VAT", CZ: "Celkem bez DPH" },
|
||||
@@ -170,9 +172,15 @@ export function renderOfferHtml(
|
||||
let total = 0;
|
||||
for (const item of items) {
|
||||
if (item.is_included_in_total !== false) {
|
||||
total += (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
||||
total += lineNet(
|
||||
Number(item.quantity) || 0,
|
||||
Number(item.unit_price) || 0,
|
||||
Number(item.discount),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Sleva column is rendered only when at least one item carries a discount.
|
||||
const hasDiscount = items.some((it) => Number(it.discount) > 0);
|
||||
// Visibility uses the SAME per-language title rule as the render loop plus
|
||||
// a tag-stripped content check (an empty-Quill "<p><br></p>" doesn't count)
|
||||
// — otherwise a section with only the other language's title would count as
|
||||
@@ -217,10 +225,15 @@ export function renderOfferHtml(
|
||||
let itemsHtml = "";
|
||||
items.forEach((item, i) => {
|
||||
const qty = Number(item.quantity) || 0;
|
||||
const lineTotal = qty * (Number(item.unit_price) || 0);
|
||||
const disc = Number(item.discount) || 0;
|
||||
const lineTotal = lineNet(qty, Number(item.unit_price) || 0, disc);
|
||||
// Integers render without decimals; fractional quantities keep 2 decimals
|
||||
// (the old toFixed(0) ROUNDED 1.5 → 2 on the printed document).
|
||||
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
|
||||
const discDecimals = Math.floor(disc) === disc ? 0 : 2;
|
||||
const discCell = hasDiscount
|
||||
? `<td class="center">${disc > 0 ? `${formatNum(disc, discDecimals)} %` : "—"}</td>`
|
||||
: "";
|
||||
const subDesc = item.item_description || "";
|
||||
const evenClass = i % 2 === 1 ? ' class="even"' : "";
|
||||
itemsHtml += `<tr${evenClass}>
|
||||
@@ -228,6 +241,7 @@ export function renderOfferHtml(
|
||||
<td class="desc">${escapeHtml(item.description)}${subDesc ? `<div class="item-subdesc">${escapeHtml(subDesc)}</div>` : ""}</td>
|
||||
<td class="center">${formatNum(qty, qtyDecimals)}${(item.unit || "").trim() ? ` / ${escapeHtml((item.unit || "").trim())}` : ""}</td>
|
||||
<td class="right">${formatCurrency(Number(item.unit_price) || 0, currency)}</td>
|
||||
${discCell}
|
||||
<td class="right total-cell">${formatCurrency(lineTotal, currency)}</td>
|
||||
</tr>`;
|
||||
});
|
||||
@@ -621,10 +635,11 @@ ${indentCSS}
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="center" style="width:5%">${escapeHtml(t("no"))}</th>
|
||||
<th style="width:44%">${escapeHtml(t("description"))}</th>
|
||||
<th class="center" style="width:13%">${escapeHtml(t("qty"))}</th>
|
||||
<th class="right" style="width:18%">${escapeHtml(t("unit_price"))}</th>
|
||||
<th class="right" style="width:20%">${escapeHtml(t("total"))}</th>
|
||||
<th style="width:${hasDiscount ? 36 : 44}%">${escapeHtml(t("description"))}</th>
|
||||
<th class="center" style="width:12%">${escapeHtml(t("qty"))}</th>
|
||||
<th class="right" style="width:16%">${escapeHtml(t("unit_price"))}</th>
|
||||
${hasDiscount ? `<th class="center" style="width:9%">${escapeHtml(t("discount"))}</th>` : ""}
|
||||
<th class="right" style="width:18%">${escapeHtml(t("total"))}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -16,6 +16,8 @@ const InvoiceItemSchema = z.object({
|
||||
quantity: positiveNumberFromForm.default(1),
|
||||
unit: z.string().max(20).nullish(),
|
||||
unit_price: numberFromForm.default(0),
|
||||
// Per-item discount as a percentage (0–100); column is Decimal(5,2).
|
||||
discount: numberInRange(0, 100).optional().default(0),
|
||||
vat_rate: numberInRange(0, 100).optional().default(21.0),
|
||||
position: nonNegativeNumberFromForm.optional(),
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
nonNegativeIntFromForm,
|
||||
nonNegativeNumberFromForm,
|
||||
positiveNumberFromForm,
|
||||
numberInRange,
|
||||
nullableIntIdFromForm,
|
||||
booleanFromForm,
|
||||
isoDateString,
|
||||
@@ -17,6 +18,8 @@ const QuotationItemSchema = z.object({
|
||||
quantity: positiveNumberFromForm.default(1),
|
||||
unit: z.string().max(20).nullish(),
|
||||
unit_price: nonNegativeNumberFromForm.default(0),
|
||||
// Per-item discount as a percentage (0–100); column is Decimal(5,2).
|
||||
discount: numberInRange(0, 100).optional().default(0),
|
||||
is_included_in_total: booleanFromForm.optional().default(true),
|
||||
// Int column — a float position would 500 at Prisma.
|
||||
position: nonNegativeIntFromForm.optional(),
|
||||
|
||||
@@ -7,6 +7,7 @@ import rateLimit from "@fastify/rate-limit";
|
||||
import path from "path";
|
||||
import { config } from "./config/env";
|
||||
import { securityHeaders } from "./middleware/security";
|
||||
import { registerVersionHeader } from "./middleware/version";
|
||||
import prisma from "./config/database";
|
||||
|
||||
import authRoutes from "./routes/admin/auth";
|
||||
@@ -100,6 +101,9 @@ async function start() {
|
||||
// --- Security headers ---
|
||||
app.addHook("onRequest", securityHeaders);
|
||||
|
||||
// --- Version header: lets the SPA detect a new deploy and prompt a refresh ---
|
||||
registerVersionHeader(app);
|
||||
|
||||
// --- Global error handler — consistent { success, error } format ---
|
||||
app.setErrorHandler(
|
||||
(err: Error & { statusCode?: number }, request, reply) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import prisma from "../config/database";
|
||||
import { utcMidnightOfLocalDay } from "../utils/date";
|
||||
import { lineNet } from "../utils/money";
|
||||
import { toCzk } from "./exchange-rates";
|
||||
import {
|
||||
generateInvoiceNumber,
|
||||
@@ -31,6 +32,8 @@ interface InvoiceItemInput {
|
||||
quantity?: number;
|
||||
unit?: string | null;
|
||||
unit_price?: number;
|
||||
/** Per-item discount as a percentage (0–100). */
|
||||
discount?: number;
|
||||
vat_rate?: number;
|
||||
position?: number;
|
||||
}
|
||||
@@ -165,14 +168,24 @@ function buildInvoiceWhere(
|
||||
}
|
||||
|
||||
function computeInvoiceTotals(
|
||||
items: Array<{ quantity: unknown; unit_price: unknown; vat_rate: unknown }>,
|
||||
items: Array<{
|
||||
quantity: unknown;
|
||||
unit_price: unknown;
|
||||
discount?: unknown;
|
||||
vat_rate: unknown;
|
||||
}>,
|
||||
applyVat: boolean | null,
|
||||
defaultVatRate: unknown,
|
||||
) {
|
||||
const { subtotal, vat: vatAmount } = computeMoney(
|
||||
items,
|
||||
applyVat,
|
||||
(i) => (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
|
||||
(i) =>
|
||||
lineNet(
|
||||
Number(i.quantity) || 0,
|
||||
Number(i.unit_price) || 0,
|
||||
Number(i.discount),
|
||||
),
|
||||
// Preserve original semantics: a line vat_rate of 0 (non-null, non-"") IS used.
|
||||
(i) =>
|
||||
i.vat_rate != null && i.vat_rate !== ""
|
||||
@@ -305,6 +318,7 @@ export function invoiceTotalWithVat(inv: {
|
||||
invoice_items: Array<{
|
||||
quantity: { toNumber(): number } | null;
|
||||
unit_price: { toNumber(): number } | null;
|
||||
discount?: { toNumber(): number } | null;
|
||||
vat_rate: { toNumber(): number } | null;
|
||||
}>;
|
||||
}) {
|
||||
@@ -312,8 +326,11 @@ export function invoiceTotalWithVat(inv: {
|
||||
inv.invoice_items,
|
||||
inv.apply_vat,
|
||||
(i) =>
|
||||
(Number(i.quantity?.toNumber()) || 0) *
|
||||
(Number(i.unit_price?.toNumber()) || 0),
|
||||
lineNet(
|
||||
Number(i.quantity?.toNumber()) || 0,
|
||||
Number(i.unit_price?.toNumber()) || 0,
|
||||
i.discount?.toNumber(),
|
||||
),
|
||||
// Preserve original `||` semantics: a line rate of 0/NaN falls through to
|
||||
// the invoice default, then 21.
|
||||
(i) =>
|
||||
@@ -401,7 +418,12 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
|
||||
const { vat } = computeMoney(
|
||||
inv.invoice_items,
|
||||
inv.apply_vat,
|
||||
(item) => (Number(item.quantity) || 0) * (Number(item.unit_price) || 0),
|
||||
(item) =>
|
||||
lineNet(
|
||||
Number(item.quantity) || 0,
|
||||
Number(item.unit_price) || 0,
|
||||
Number(item.discount),
|
||||
),
|
||||
(item) => Number(item.vat_rate) || Number(inv.vat_rate) || 21,
|
||||
false, // accumulate raw per-line VAT; round only the per-currency sum
|
||||
);
|
||||
@@ -551,6 +573,7 @@ export async function createInvoice(body: InvoiceInput) {
|
||||
quantity: item.quantity ?? 1,
|
||||
unit: item.unit ?? null,
|
||||
unit_price: item.unit_price ?? 0,
|
||||
discount: item.discount ?? 0,
|
||||
vat_rate: item.vat_rate ?? 21.0,
|
||||
position: item.position ?? i,
|
||||
})),
|
||||
@@ -688,6 +711,7 @@ export async function updateInvoice(id: number, body: InvoiceInput) {
|
||||
quantity: item.quantity ?? 1,
|
||||
unit: item.unit ?? null,
|
||||
unit_price: item.unit_price ?? 0,
|
||||
discount: item.discount ?? 0,
|
||||
vat_rate: item.vat_rate ?? 21.0,
|
||||
position: item.position ?? i,
|
||||
})),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import prisma from "../config/database";
|
||||
import { lineNet } from "../utils/money";
|
||||
import {
|
||||
generateOfferNumber,
|
||||
releaseOfferNumber,
|
||||
@@ -14,6 +15,8 @@ interface QuotationItemInput {
|
||||
quantity?: number;
|
||||
unit?: string;
|
||||
unit_price?: number;
|
||||
/** Per-item discount as a percentage (0–100). */
|
||||
discount?: number;
|
||||
is_included_in_total?: boolean;
|
||||
position?: number;
|
||||
}
|
||||
@@ -122,6 +125,7 @@ type EnrichableLineItem = {
|
||||
is_included_in_total?: unknown;
|
||||
quantity?: unknown;
|
||||
unit_price?: unknown;
|
||||
discount?: unknown;
|
||||
};
|
||||
type EnrichableQuotation = {
|
||||
quotation_items: EnrichableLineItem[];
|
||||
@@ -138,7 +142,12 @@ function enrichQuotation<T extends EnrichableQuotation>(q: T) {
|
||||
.filter((i) => i.is_included_in_total !== false)
|
||||
.reduce(
|
||||
(s: number, i) =>
|
||||
s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
|
||||
s +
|
||||
lineNet(
|
||||
Number(i.quantity) || 0,
|
||||
Number(i.unit_price) || 0,
|
||||
Number(i.discount),
|
||||
),
|
||||
0,
|
||||
);
|
||||
const { quotation_items, scope_sections, ...rest } = q;
|
||||
@@ -345,6 +354,7 @@ export async function createOffer(body: Record<string, unknown>) {
|
||||
quantity: item.quantity ?? 1,
|
||||
unit: item.unit ?? null,
|
||||
unit_price: item.unit_price ?? 0,
|
||||
discount: item.discount ?? 0,
|
||||
is_included_in_total: item.is_included_in_total !== false,
|
||||
position: item.position ?? i,
|
||||
})),
|
||||
@@ -480,6 +490,7 @@ export async function updateOffer(id: number, body: Record<string, unknown>) {
|
||||
quantity: item.quantity ?? 1,
|
||||
unit: item.unit ?? null,
|
||||
unit_price: item.unit_price ?? 0,
|
||||
discount: item.discount ?? 0,
|
||||
is_included_in_total: item.is_included_in_total !== false,
|
||||
position: item.position ?? i,
|
||||
})),
|
||||
@@ -602,6 +613,7 @@ export async function duplicateOffer(id: number) {
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
discount: item.discount,
|
||||
is_included_in_total: item.is_included_in_total,
|
||||
position: item.position,
|
||||
})),
|
||||
|
||||
17
src/utils/money.ts
Normal file
17
src/utils/money.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Net amount of a single document line after its per-item percentage discount
|
||||
* ("Sleva"). `net = qty × unit_price × (1 − discount/100)`.
|
||||
*
|
||||
* The discount is tolerant of null/undefined/NaN (a missing discount = none)
|
||||
* and clamped to 0–100 so a stray value can never make the net negative.
|
||||
* Single source of truth for offers + invoices (totals, VAT base, PDFs).
|
||||
*/
|
||||
export function lineNet(
|
||||
qty: number,
|
||||
unitPrice: number,
|
||||
discountPct: number | null | undefined,
|
||||
): number {
|
||||
const pct = Number(discountPct);
|
||||
const safePct = Number.isFinite(pct) ? Math.min(100, Math.max(0, pct)) : 0;
|
||||
return qty * unitPrice * (1 - safePct / 100);
|
||||
}
|
||||
@@ -1,10 +1,20 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
// Bake the package.json version into the bundle so the running SPA can compare
|
||||
// it to the server's X-App-Version header and detect a new deploy.
|
||||
const appVersion = (
|
||||
JSON.parse(readFileSync("./package.json", "utf8")) as { version: string }
|
||||
).version;
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
root: ".",
|
||||
publicDir: "public",
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(appVersion),
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user