Compare commits

..

13 Commits

Author SHA1 Message Date
BOHA
1c1b9dca17 chore(release): v2.4.3 - VAT removed from offers/orders/issued orders (non-tax documents)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:22:28 +02:00
BOHA
7b20c47937 feat(pdf): drop the prices-excl.-VAT notice from offer/order/PO PDFs
The 'Ceny jsou uvedeny bez DPH...' explanatory line is removed at user request from all three non-tax-document PDFs - the 'Celkem bez DPH' total label carries the information by itself. Dead vat_notice keys and .vat-note CSS removed; tests now pin the notice's ABSENCE (file renamed pdf-vat-note -> pdf-no-vat).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:19:08 +02:00
BOHA
7398cad466 feat(vat)!: remove VAT entirely from offers, orders and issued orders
Accounting rule: nabidky, objednavky (incl. confirmation PDF) and objednavky
vydane are NOT tax documents - VAT belongs only on invoices. Per user
decision this is a FULL removal including DB columns.

- migration: DROP orders.vat_rate/apply_vat, issued_orders.vat_rate/apply_vat,
  issued_order_items.vat_rate, quotations.vat_rate/apply_vat (applied to
  dev + test DBs)
- services: net-only totals everywhere (computeIssuedOrderTotals(items) ->
  {total}; enrichOrder/enrichQuotation net; per-currency list totals net)
- PDFs (offers, order confirmation, issued order): no VAT columns/summary,
  single 'Celkem bez DPH' / 'Total excl. VAT' total, note under totals:
  'Ceny jsou uvedeny bez DPH. DPH bude uctovano dle platnych predpisu.';
  duplicate Cena/Celkem column merged (desc width 56%); orders-pdf render
  extracted as exported renderOrderConfirmationHtml for testability
- frontend: Uplatnit DPH checkboxes, VAT selects and per-item VAT columns
  removed from OfferDetail/OrderDetail/IssuedOrderDetail/
  OrderConfirmationModal/ReceivedOrders manual-create; list footers read
  'Celkem bez DPH'; invoice-from-order prefill now takes the company default
  VAT (invoice decides its own VAT)
- tests: suites reworked to net math; new pdf-vat-note.test.ts pins the
  exact cs+en note text and VAT-free layout on all three PDFs

Invoices and received invoices keep their VAT handling unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:13:16 +02:00
BOHA
396a1d37ec chore(release): v2.4.2 - editable issued-order PDF heading (order_text)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:21:44 +02:00
BOHA
ca1f07671f feat(issued-orders): editable PDF heading 'Text objednavky (na PDF)' (order_text)
New nullable issued_orders.order_text column (migration applied to dev+test). The heading above the PDF items table is now editable per order, mirroring invoices' billing_text: empty falls back to the default, which is now 'Objednavame si u Vas:' per user wording. Field sits above Dodaci podminky in the detail form; persisted via create + update strFields (both schemas - the invoices Update-schema gap is not repeated). Tests: persistence round-trip incl. null-clears, PDF custom heading + default fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:16:40 +02:00
BOHA
638264fc7c chore(release): v2.4.1 - dashboard staleness fix, VAT-less order PDFs, release-process docs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:08:52 +02:00
BOHA
f68a4dafc4 feat(orders-pdf): without 'Uplatnit DPH' hide VAT columns, total reads 'Celkem bez DPH'
Applies to both the issued-order (PO) PDF and the order-confirmation PDF: when apply_vat is off the %DPH and DPH columns are dropped entirely (widths redistributed) instead of printing 0% / 0,00, the redundant Mezisoucet row is omitted, and the grand total is labeled 'Celkem bez DPH' / 'Total excl. VAT'. The per-line DPH cell already contained only the line VAT (never netto+VAT) - now pinned by a regression test (2x100 @ 21%: DPH cell 42,00, Celkem cell 242,00).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:04:07 +02:00
BOHA
700fb47bbc docs(release): prisma generate + prisma.config.ts are mandatory deploy steps
npm install skips client regeneration when deps are unchanged - a schema-changing release then serves a stale client (P2022 500s; bit v2.4.0). prisma.config.ts carries the Prisma 7 datasource and must ship in the tarball.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:52:02 +02:00
BOHA
c2746d78c9 fix(dashboard): stale punch button/KPIs after attendance changes on other pages
Verified flow: clock in on dashboard -> delete the record in /attendance/admin
-> navigate back: 'Zaznamenat odchod', the Pritomni KPI and the presence card
still showed the pre-delete state until F5. Root cause: every attendance
mutation outside the dashboard invalidated only ["attendance"], never
["dashboard"], so within the dashboard query's 60s staleTime the remount was
served from cache.

- useAttendanceAdmin create/bulk/edit/delete, /attendance punch +
  leave-request, AttendanceCreate, LeaveApproval approve/reject now also
  invalidate ["dashboard"] (CLAUDE.md rule: mutations invalidate every domain
  that embeds their data)
- systemic backstop: dashboardOptions uses refetchOnMount "always" - the
  dashboard aggregates attendance/offers/invoices/orders/projects/leave, and
  domain pages can't all be expected to invalidate it; returning to the
  dashboard must never show pre-mutation data

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:40:06 +02:00
BOHA
4e534471d9 chore(release): v2.4.0 - dashboard today-window + cross-login cache fix, issued orders -> suppliers (migration), @db.Date filter sweep
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:34:35 +02:00
BOHA
975a555af5 fix(dates): @db.Date filters built from local midnight queried the wrong day
Prisma truncates a JS Date used in a WHERE filter on a @db.Date column to
its UTC date part. Under TZ=Europe/Prague a local-midnight boundary
(new Date(y,m,d) = 22:00/23:00Z of the previous day) therefore filtered as
the PREVIOUS calendar date. Same class as the dashboard fix; full sweep of
every @db.Date filter in the codebase. New shared helper
utcMidnightOfLocalDay() in src/utils/date.ts.

Fixed (all previously off by one day):
- attendance.service: getStatus today+month windows; getWorkfund (last day
  of each month was double-counted across months); getPrintData (monthly
  print included prev month's last day); listAttendance (admin month view
  included prev month's last day AND dropped the selected month's last
  day); createAttendance duplicate/overlap validation (checked only the
  PREVIOUS day - same-day duplicates were never caught, neighbors falsely
  rejected)
- invoice-alerts: the 'splatnost za 3 dny' advance alert had NEVER fired
  (due==today+3 was outside the fetched window); window computation
  extracted as computeAlertWindow() + pure tests
- invoices.service: month list/totals filter dropped invoices issued on the
  month's last day; getInvoiceStats month/year bounds; markOverdueInvoices
  boundary; auto paid_date could store yesterday during 00:00-02:00
- received-invoices auto paid_date, issued-orders default order_date: same
  night-window write hazard, now via the helper
- attendance.schema: shift_date hardened to isoDateString (bare YYYY-MM-DD)

+9 regression tests (month boundaries, same-day duplicate, last-day-of-month
invoice in list+totals, alert window).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:29:28 +02:00
BOHA
6a22195c7d feat(issued-orders): counterparty is now a supplier (dodavatel), not a customer
Issued orders are purchase orders WE send - they must pick from suppliers
(sklad_suppliers), not from customers. Per user decision customer_id was
REPLACED (not kept alongside): migration drops issued_orders.customer_id and
adds supplier_id FK -> sklad_suppliers (existing rows lose their counterparty
- the feature is days old; re-point them in the UI).

- service: input/filters/search (suppliers.name + ico)/enrichment/detail all
  supplier-based; create validates the supplier inside the transaction and
  update before write -> Czech 400 'Dodavatel nenalezen' instead of P2003 500;
  detail returns a minimal supplier field set (no internal notes leak)
- routes: supplier_id on list + stats; new GET /issued-orders/suppliers
  lookup (orders.view/create/edit guard - orders users lack warehouse.manage
  which guards the warehouse suppliers CRUD), active suppliers only,
  name+id ordering
- PDF: Dodavatel block now renders the supplier (name, newline-split address,
  IC/DIC), layout and both language label sets unchanged
- frontend: new SupplierPicker kit component (CustomerPicker untouched),
  IssuedOrderDetail/IssuedOrders switched to supplier_id/supplier_name; the
  picker keeps a fallback option for orders whose supplier was later
  deactivated; WarehouseSuppliers CRUD now also invalidates issued-orders so
  the picker can't go stale
- tests: issued-orders suite switched to supplier fixtures + new coverage
  (lookup shape + 403, nonexistent supplier 400, PDF supplier block)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:29:06 +02:00
BOHA
74ce24e3fa fix(dashboard,auth): today-window on @db.Date + query-cache cleared across logins
Two dashboard bugs reported after clock-in:

1. 'Dochazka dnes' / 'Pritomni dnes' showed everyone absent even after
   refresh: attendance.shift_date is @db.Date and Prisma truncates filter
   Dates to their UTC date part, so the local-midnight boundaries
   (new Date(y,m,d) = 22:00/23:00Z of the previous day) queried
   [yesterday, today) and matched zero of today's punches. Boundaries are
   now UTC-midnight instants of the local (Prague) calendar day.
   Reproduced empirically against real rows; route-level regression test
   added (dashboard.test.ts).

2. After logout + login as a different user, cached dashboards/buttons from
   the previous user were served: query keys are user-agnostic and the
   React Query cache outlives the session. logout(), login() and the 2FA
   verify path now clear the whole cache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:28:45 +02:00
56 changed files with 1715 additions and 1137 deletions

View File

@@ -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`
3. Commit and tag (`git tag -a 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`):
- 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/`
- Extract tarball: `tar -xzf /tmp/app-ts-X.Y.Z.tar.gz`
- 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`
- Restart: `pm2 restart app-ts --update-env`

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "app-ts",
"version": "2.3.0",
"version": "2.4.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "app-ts",
"version": "2.3.0",
"version": "2.4.3",
"license": "ISC",
"dependencies": {
"@anthropic-ai/sdk": "^0.102.0",

View File

@@ -1,6 +1,6 @@
{
"name": "app-ts",
"version": "2.3.0",
"version": "2.4.3",
"description": "",
"main": "dist/server.js",
"scripts": {

View File

@@ -0,0 +1,16 @@
-- DropForeignKey
ALTER TABLE `issued_orders` DROP FOREIGN KEY `issued_orders_ibfk_1`;
-- DropIndex
DROP INDEX `issued_orders_customer_id` ON `issued_orders`;
-- AlterTable
ALTER TABLE `issued_orders` DROP COLUMN `customer_id`,
ADD COLUMN `supplier_id` INTEGER NULL;
-- CreateIndex
CREATE INDEX `issued_orders_supplier_id` ON `issued_orders`(`supplier_id`);
-- AddForeignKey
ALTER TABLE `issued_orders` ADD CONSTRAINT `issued_orders_supplier_fk` FOREIGN KEY (`supplier_id`) REFERENCES `sklad_suppliers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE `issued_orders` ADD COLUMN `order_text` VARCHAR(500) NULL;

View File

@@ -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`;

View File

@@ -193,7 +193,6 @@ model customers {
sync_version Int? @default(0)
invoices invoices[]
orders orders[]
issued_orders issued_orders[]
projects projects[]
quotations quotations[]
}
@@ -348,8 +347,6 @@ model orders {
status String? @default("prijata") @db.VarChar(30)
currency String? @default("CZK") @db.VarChar(10)
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)
scope_title String? @db.VarChar(500)
scope_description String? @db.Text
@@ -370,11 +367,9 @@ model orders {
model issued_orders {
id Int @id @default(autoincrement())
po_number String? @unique(map: "idx_issued_orders_number_unique") @db.VarChar(50)
customer_id Int?
supplier_id Int?
status issued_orders_status @default(draft)
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)
order_date DateTime? @db.Date
delivery_date DateTime? @db.Date
@@ -382,14 +377,15 @@ model issued_orders {
delivery_terms String? @db.VarChar(500)
payment_terms String? @db.VarChar(500)
issued_by String? @db.VarChar(255)
order_text String? @db.VarChar(500)
notes String? @db.Text
internal_notes String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
issued_order_items issued_order_items[]
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "issued_orders_ibfk_1")
suppliers sklad_suppliers? @relation(fields: [supplier_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "issued_orders_supplier_fk")
@@index([customer_id], map: "issued_orders_customer_id")
@@index([supplier_id], map: "issued_orders_supplier_id")
@@index([status, order_date], map: "idx_issued_orders_status_date")
}
@@ -401,7 +397,6 @@ model issued_order_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)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
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")
@@ -494,8 +489,6 @@ model quotations {
valid_until DateTime? @db.Date
currency String? @default("CZK") @db.VarChar(10)
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?
status String @default("active") @db.VarChar(20)
scope_title String? @db.VarChar(500)
@@ -796,7 +789,8 @@ model sklad_suppliers {
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
receipts sklad_receipts[]
receipts sklad_receipts[]
issued_orders issued_orders[]
@@map("sklad_suppliers")
}

View File

@@ -258,6 +258,106 @@ describe("GET /api/admin/attendance (complete month, no truncation)", () => {
});
});
describe("GET /api/admin/attendance (month window boundaries, @db.Date)", () => {
it("includes the month's own LAST day and excludes the neighbor month's first day", async () => {
// shift_date is @db.Date — Prisma compares the filter Dates by their UTC
// date part. The old LOCAL-midnight month bounds (22:00/23:00 UTC of the
// previous day) shifted the whole window a day back: the June list
// included May 31 and DROPPED June 30. Fixture rows sit exactly on the
// 2098-06 / 2098-07 boundary.
const juneLast = await prisma.attendance.create({
data: {
user_id: adminUserId,
shift_date: new Date(2098, 5, 30, 12, 0, 0), // 2098-06-30, local noon
leave_type: "work",
arrival_time: new Date(2098, 5, 30, 8, 0, 0),
departure_time: new Date(2098, 5, 30, 16, 0, 0),
notes: "attendance_wire_test june-last-day",
},
});
const julyFirst = await prisma.attendance.create({
data: {
user_id: adminUserId,
shift_date: new Date(2098, 6, 1, 12, 0, 0), // 2098-07-01, local noon
leave_type: "work",
arrival_time: new Date(2098, 6, 1, 8, 0, 0),
departure_time: new Date(2098, 6, 1, 16, 0, 0),
notes: "attendance_wire_test july-first-day",
},
});
const june = await app.inject({
method: "GET",
url: "/api/admin/attendance?year=2098&month=6&limit=100",
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(june.statusCode).toBe(200);
const juneIds = june.json().data.map((r: { id: number }) => r.id);
expect(juneIds).toContain(juneLast.id);
expect(juneIds).not.toContain(julyFirst.id);
const july = await app.inject({
method: "GET",
url: "/api/admin/attendance?year=2098&month=7&limit=100",
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(july.statusCode).toBe(200);
const julyIds = july.json().data.map((r: { id: number }) => r.id);
expect(julyIds).toContain(julyFirst.id);
expect(julyIds).not.toContain(juneLast.id);
});
});
describe("POST /api/admin/attendance (same-day duplicate window, @db.Date)", () => {
it("rejects a second leave record on the SAME day", async () => {
const first = await authPost("/api/admin/attendance", adminToken, {
user_id: adminUserId,
shift_date: "2098-08-10",
leave_type: "vacation",
leave_hours: 8,
notes: "attendance_wire_test dup-leave-first",
});
expect(first.statusCode).toBe(201);
// Old bug: the duplicate/overlap window was built from LOCAL midnights of
// the UTC-midnight shiftDate, so the validation queried ONLY the previous
// day — a same-day duplicate leave sailed through undetected.
const second = await authPost("/api/admin/attendance", adminToken, {
user_id: adminUserId,
shift_date: "2098-08-10",
leave_type: "sick",
leave_hours: 8,
notes: "attendance_wire_test dup-leave-second",
});
expect(second.statusCode).toBe(400);
expect(second.json().success).toBe(false);
});
it("does NOT falsely reject a leave on the day AFTER an existing record", async () => {
const first = await authPost("/api/admin/attendance", adminToken, {
user_id: adminUserId,
shift_date: "2098-08-10",
leave_type: "vacation",
leave_hours: 8,
notes: "attendance_wire_test neighbor-day-first",
});
expect(first.statusCode).toBe(201);
// Old bug (the other half): creating a record for the NEXT day queried
// the window [Aug 10, Aug 11) — i.e. yesterday's records — and bounced
// with a false "záznam o nepřítomnosti již existuje".
const nextDay = await authPost("/api/admin/attendance", adminToken, {
user_id: adminUserId,
shift_date: "2098-08-11",
leave_type: "vacation",
leave_hours: 8,
notes: "attendance_wire_test neighbor-day-next",
});
expect(nextDay.statusCode).toBe(201);
expect(nextDay.json().success).toBe(true);
});
});
describe("PUT /api/admin/attendance/:id (combined datetimes)", () => {
it("updates a record with combined datetimes (incl. overnight departure)", async () => {
const created = await prisma.attendance.create({

View File

@@ -0,0 +1,100 @@
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 dashboardRoutes from "../routes/admin/dashboard";
// ---------------------------------------------------------------------------
// Regression test for the "Docházka dnes" / "Přítomní dnes" dashboard window.
//
// attendance.shift_date is @db.Date and Prisma truncates a filter Date to its
// UTC date part. The dashboard used local-midnight boundaries
// (new Date(y, m, d) = 22:00/23:00 UTC of the PREVIOUS day under
// TZ=Europe/Prague), which truncated to [yesterday, today) — today's punches
// matched zero rows and everyone showed as absent. The boundaries must be
// UTC-midnight instants of the LOCAL calendar day.
//
// Unlike the other suites this one has to use the REAL current date (the
// route computes "today" internally), so fixtures are tracked by id and
// deleted in afterAll.
// ---------------------------------------------------------------------------
let app: Awaited<ReturnType<typeof buildApp>>;
let adminUserId: number;
let adminToken: string;
const createdIds: number[] = [];
async function buildApp() {
const a = Fastify({ logger: false });
await a.register(dashboardRoutes, { prefix: "/api/admin/dashboard" });
return a;
}
beforeAll(async () => {
app = await buildApp();
const admin = await prisma.users.findFirst({
where: { roles: { name: "admin" } },
include: { roles: true },
});
if (!admin) throw new Error("Test setup: admin user not found");
adminUserId = admin.id;
adminToken = jwt.sign(
{ sub: admin.id, username: admin.username, role: "admin" },
config.jwt.secret,
{ expiresIn: "15m" },
);
});
afterAll(async () => {
if (createdIds.length) {
await prisma.attendance.deleteMany({ where: { id: { in: createdIds } } });
}
if (app) await app.close();
});
describe("GET /api/admin/dashboard — today's attendance window", () => {
it("counts a punched-in user as present (local-noon @db.Date row)", async () => {
const now = new Date();
// The attendance regime stores shift_date at LOCAL NOON (so the UTC date
// part equals the Prague calendar date) — same as punchAction does.
const row = await prisma.attendance.create({
data: {
user_id: adminUserId,
shift_date: new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
12,
0,
0,
),
leave_type: "work",
arrival_time: now,
departure_time: null,
notes: "dashboard_window_test",
},
});
createdIds.push(row.id);
const res = await app.inject({
method: "GET",
url: "/api/admin/dashboard",
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
const attendance = body.data.attendance;
expect(attendance).toBeTruthy();
const me = attendance.users.find(
(u: { user_id: number }) => u.user_id === adminUserId,
);
expect(me).toBeTruthy();
expect(me.status).toBe("in");
expect(attendance.present_today).toBeGreaterThanOrEqual(1);
});
});

View File

@@ -51,6 +51,8 @@ describe("issued-order drafts — deferred numbering", () => {
it("a draft has no number and does NOT consume the sequence", async () => {
const before = (await previewIssuedOrderNumber()).number;
const draft = await createIssuedOrder({}); // defaults to draft
if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`);
issuedOrderIds.push(draft.id);
expect(draft.status).toBe("draft");
expect(draft.po_number).toBeNull();
@@ -62,6 +64,8 @@ describe("issued-order drafts — deferred numbering", () => {
it("finalizing a draft (draft -> sent) assigns the next sequence number", async () => {
const expected = (await previewIssuedOrderNumber()).number;
const draft = await createIssuedOrder({});
if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`);
issuedOrderIds.push(draft.id);
const res = await updateIssuedOrder(draft.id, { status: "sent" });
@@ -77,6 +81,8 @@ describe("issued-order drafts — deferred numbering", () => {
it("finalizing is idempotent — re-finalizing does not re-number", async () => {
const draft = await createIssuedOrder({});
if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`);
issuedOrderIds.push(draft.id);
await updateIssuedOrder(draft.id, { status: "sent" });
@@ -104,6 +110,8 @@ describe("issued-order drafts — deferred numbering", () => {
it("two drafts coexist with null numbers (no unique violation)", async () => {
const a = await createIssuedOrder({});
const b = await createIssuedOrder({});
if ("error" in a) throw new Error(`createIssuedOrder failed: ${a.error}`);
if ("error" in b) throw new Error(`createIssuedOrder failed: ${b.error}`);
issuedOrderIds.push(a.id, b.id);
expect(a.po_number).toBeNull();
expect(b.po_number).toBeNull();
@@ -112,6 +120,8 @@ describe("issued-order drafts — deferred numbering", () => {
it("deleting a draft releases nothing (no number was consumed)", async () => {
const before = (await previewIssuedOrderNumber()).number;
const draft = await createIssuedOrder({});
if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`);
await deleteIssuedOrder(draft.id);
const after = (await previewIssuedOrderNumber()).number;
expect(after).toBe(before);

View File

@@ -0,0 +1,50 @@
import { describe, it, expect } from "vitest";
import { computeAlertWindow } from "../services/invoice-alerts";
import { utcMidnightOfLocalDay } from "../utils/date";
// Pure tests — no DB rows are touched. due_date is @db.Date: Prisma truncates
// a Date used in a WHERE filter to its UTC date part, so the alert window
// boundaries must be UTC midnights of the LOCAL calendar day. The old code
// used local midnights (= 22:00/23:00 UTC of the PREVIOUS day), which shifted
// the [today, today+3] due_date window a day early — the "splatnost za 3 dny"
// advance alert (due == today+3) could then never fire. All assertions below
// are timezone-independent (inputs are built from local components).
describe("utcMidnightOfLocalDay", () => {
it("preserves the LOCAL calendar day shortly after local midnight (the night window)", () => {
// 00:30 local on 2098-07-01 — in Prague (UTC+2) this instant is
// 2098-06-30T22:30Z, i.e. its UTC date part is the PREVIOUS day, which is
// exactly what Prisma would truncate a bare Date to.
const justAfterMidnight = new Date(2098, 6, 1, 0, 30, 0);
expect(utcMidnightOfLocalDay(justAfterMidnight).getTime()).toBe(
Date.UTC(2098, 6, 1),
);
});
it("returns UTC midnight of the local day for a mid-day instant", () => {
const noon = new Date(2098, 0, 15, 12, 0, 0);
expect(utcMidnightOfLocalDay(noon).getTime()).toBe(Date.UTC(2098, 0, 15));
});
});
describe("computeAlertWindow", () => {
it("builds [today, today+3] as UTC midnights of the LOCAL day, with matching strings", () => {
// 00:30 local — the window where the old local-midnight computation
// produced yesterday's UTC date and the whole window slid a day early.
const now = new Date(2098, 6, 1, 0, 30, 0);
const w = computeAlertWindow(now);
expect(w.today.getTime()).toBe(Date.UTC(2098, 6, 1));
expect(w.in3days.getTime()).toBe(Date.UTC(2098, 6, 4));
expect(w.todayStr).toBe("2098-07-01");
expect(w.in3daysStr).toBe("2098-07-04");
});
it("rolls the +3-day bound over a month boundary", () => {
const now = new Date(2098, 0, 30, 8, 0, 0); // 2098-01-30
const w = computeAlertWindow(now);
expect(w.in3days.getTime()).toBe(Date.UTC(2098, 1, 2)); // 2098-02-02
expect(w.in3daysStr).toBe("2098-02-02");
});
});

View File

@@ -1,10 +1,12 @@
import { describe, it, expect, afterEach } from "vitest";
import { describe, it, expect, afterEach, beforeEach } from "vitest";
import { Prisma } from "@prisma/client";
import prisma from "../config/database";
import {
invoiceTotalWithVat,
createInvoice,
updateInvoice,
listInvoices,
getInvoiceListTotals,
} from "../services/invoices.service";
import { UpdateInvoiceSchema } from "../schemas/invoices.schema";
@@ -179,3 +181,83 @@ describe("updateInvoice — billing_text round-trips through UpdateInvoiceSchema
expect(row?.billing_text).toBeNull();
});
});
/* -------------------------------------------------------------------------- */
/* Month filter boundaries — issue_date is @db.Date */
/* -------------------------------------------------------------------------- */
// issue_date is @db.Date: Prisma compares a Date filter by its UTC date part.
// The old LOCAL-midnight month bounds (= 22:00/23:00 UTC of the previous day)
// shifted the window a day back — the list/totals included the previous
// month's last day and DROPPED invoices issued on the selected month's LAST
// day. buildInvoiceWhere is shared by listInvoices AND getInvoiceListTotals,
// so both are exercised. Fixtures use the test-only "TST" currency + far-future
// 2098 dates so totals can be asserted exactly without colliding with other
// suites' rows.
describe("listInvoices / getInvoiceListTotals — month filter (@db.Date boundaries)", () => {
const cleanup = async () => {
// invoice_items cascade on invoice delete.
await prisma.invoices.deleteMany({ where: { currency: "TST" } });
};
beforeEach(cleanup);
afterEach(cleanup);
const listParams = {
page: 1,
limit: 100,
skip: 0,
sort: "id",
order: "asc" as const,
search: "",
};
async function createBoundaryFixtures() {
// Drafts: no invoice number is consumed, but buildInvoiceWhere has no
// status filter so they appear in the list/totals like any invoice.
const lastDayJan = await createInvoice({
status: "draft",
issue_date: "2098-01-31",
currency: "TST",
vat_rate: 21,
items: [{ quantity: 1, unit_price: 100, vat_rate: 21 }], // total 121
});
const firstDayFeb = await createInvoice({
status: "draft",
issue_date: "2098-02-01",
currency: "TST",
vat_rate: 21,
items: [{ quantity: 1, unit_price: 200, vat_rate: 21 }], // total 242
});
return { lastDayJan, firstDayFeb };
}
it("a month's list contains its own LAST day and not the neighbor month's first day", async () => {
const { lastDayJan, firstDayFeb } = await createBoundaryFixtures();
const jan = await listInvoices({ ...listParams, month: 1, year: 2098 });
const janIds = jan.data.map((i) => i.id);
expect(janIds).toContain(lastDayJan.id);
expect(janIds).not.toContain(firstDayFeb.id);
const feb = await listInvoices({ ...listParams, month: 2, year: 2098 });
const febIds = feb.data.map((i) => i.id);
expect(febIds).toContain(firstDayFeb.id);
expect(febIds).not.toContain(lastDayJan.id);
});
it("per-currency totals include the month's last day exactly once", async () => {
await createBoundaryFixtures();
const jan = await getInvoiceListTotals({ month: 1, year: 2098 });
const janTst = jan.totals.find((t) => t.currency === "TST");
// 100 + 21 % VAT — proves the Jan 31 invoice is counted and the
// Feb 1 invoice (242) is NOT pulled into January.
expect(janTst?.amount).toBe(121);
const feb = await getInvoiceListTotals({ month: 2, year: 2098 });
const febTst = feb.totals.find((t) => t.currency === "TST");
expect(febTst?.amount).toBe(242);
});
});

View File

@@ -1,5 +1,8 @@
import { describe, it, expect, afterEach } from "vitest";
import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
import Fastify from "fastify";
import jwt from "jsonwebtoken";
import prisma from "../config/database";
import { config } from "../config/env";
import {
generateIssuedOrderNumber,
previewIssuedOrderNumber,
@@ -13,7 +16,9 @@ import {
listIssuedOrders,
updateIssuedOrder,
deleteIssuedOrder,
type IssuedOrderInput,
} from "../services/issued-orders.service";
import issuedOrdersRoutes from "../routes/admin/issued-orders";
import { renderIssuedOrderHtml } from "../routes/admin/issued-orders-pdf";
afterEach(async () => {
@@ -51,87 +56,90 @@ describe("issued-order numbering", () => {
});
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({
customer_id: "5",
vat_rate: "21",
supplier_id: "5",
items: [{ description: "X", quantity: "2", unit_price: "100" }],
});
expect(ok.success).toBe(true);
if (ok.success) expect(ok.data.customer_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);
});
});
const createdIds: number[] = [];
const createdCustomerIds: number[] = [];
const createdSupplierIds: number[] = [];
afterEach(async () => {
// Orders first — the supplier FK is onDelete Restrict.
for (const id of createdIds)
await prisma.issued_orders.deleteMany({ where: { id } });
for (const id of createdCustomerIds)
await prisma.customers.deleteMany({ where: { id } });
for (const id of createdSupplierIds)
await prisma.sklad_suppliers.deleteMany({ where: { id } });
createdIds.length = 0;
createdCustomerIds.length = 0;
createdSupplierIds.length = 0;
});
async function makeCustomer() {
const c = await prisma.customers.create({
data: { name: "Dodavatel s.r.o." },
async function makeSupplier(
data: Partial<{ name: string; ico: string; is_active: boolean }> = {},
) {
const s = await prisma.sklad_suppliers.create({
data: {
name: data.name ?? "io_test_Dodavatel s.r.o.",
ico: data.ico ?? null,
is_active: data.is_active ?? true,
},
});
createdCustomerIds.push(c.id);
return c;
createdSupplierIds.push(s.id);
return s;
}
describe("computeIssuedOrderTotals (NET + VAT-on-top)", () => {
it("adds VAT on top of net, rounded per line", () => {
const t = computeIssuedOrderTotals(
[
{ quantity: 2, unit_price: 100, vat_rate: 21 },
{ quantity: 1, unit_price: 50, vat_rate: 12 },
],
true,
21,
);
expect(t.subtotal).toBe(250);
expect(t.vat_amount).toBe(48);
expect(t.total).toBe(298);
/** createIssuedOrder + narrow the supplier-validation union + track cleanup. */
async function mkIssued(input: IssuedOrderInput = {}) {
const res = await createIssuedOrder(input);
if ("error" in res) throw new Error(`createIssuedOrder failed: ${res.error}`);
createdIds.push(res.id);
return res;
}
describe("computeIssuedOrderTotals (NET only — no VAT on issued orders)", () => {
it("sums qty × unit_price per line, rounded to 2dp", () => {
const t = computeIssuedOrderTotals([
{ quantity: 2, unit_price: 100 },
{ quantity: 1, unit_price: 50 },
]);
expect(t).toEqual({ total: 250 });
});
it("zeroes VAT when apply_vat is false but keeps the net subtotal", () => {
const t = computeIssuedOrderTotals(
[{ quantity: 3, unit_price: 100, vat_rate: 21 }],
false,
21,
);
expect(t).toEqual({ subtotal: 300, vat_amount: 0, total: 300 });
});
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);
it("treats null/garbage numerics as zero", () => {
const t = computeIssuedOrderTotals([
{ quantity: null, unit_price: 100 },
{ quantity: 3, unit_price: "abc" },
{ quantity: 2, unit_price: "10.555" },
]);
expect(t).toEqual({ total: 21.11 });
});
});
describe("createIssuedOrder", () => {
it("defaults to draft with NO PO number, stores items", async () => {
const c = await makeCustomer();
const order = await createIssuedOrder({
customer_id: c.id,
items: [
{ description: "Materiál", quantity: 2, unit_price: 100, vat_rate: 21 },
],
it("defaults to draft with NO PO number, stores supplier_id + items", async () => {
const s = await makeSupplier();
const order = await mkIssued({
supplier_id: s.id,
items: [{ description: "Materiál", quantity: 2, unit_price: 100 }],
});
createdIds.push(order.id);
// Deferred numbering: a draft carries no number.
expect(order.po_number).toBeNull();
expect(order.status).toBe("draft");
expect(order.supplier_id).toBe(s.id);
const items = await prisma.issued_order_items.findMany({
where: { issued_order_id: order.id },
});
@@ -141,17 +149,37 @@ describe("createIssuedOrder", () => {
it("numbers immediately when created already-finalized (status sent)", async () => {
const before = (await previewIssuedOrderNumber()).number;
const order = await createIssuedOrder({ status: "sent" });
createdIds.push(order.id);
const order = await mkIssued({ status: "sent" });
expect(order.po_number).toBe(before);
expect(order.status).toBe("sent");
});
it("rejects a nonexistent supplier_id instead of throwing P2003", async () => {
const res = await createIssuedOrder({ supplier_id: 99999999 });
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", () => {
it("allows draft -> sent and rejects draft -> completed", async () => {
const order = await createIssuedOrder({});
createdIds.push(order.id);
const order = await mkIssued({});
const ok = await updateIssuedOrder(order.id, { status: "sent" });
expect("error" in ok).toBe(false);
const bad = await updateIssuedOrder(order.id, { status: "completed" });
@@ -159,10 +187,9 @@ describe("updateIssuedOrder status transitions", () => {
});
it("locks items once confirmed", async () => {
const order = await createIssuedOrder({
const order = await mkIssued({
items: [{ description: "A", quantity: 1, unit_price: 10 }],
});
createdIds.push(order.id);
await updateIssuedOrder(order.id, { status: "sent" });
await updateIssuedOrder(order.id, { status: "confirmed" });
await updateIssuedOrder(order.id, {
@@ -174,22 +201,28 @@ describe("updateIssuedOrder status transitions", () => {
expect(items.length).toBe(1);
expect(items[0].description).toBe("A");
});
it("rejects a nonexistent supplier_id on update", async () => {
const order = await mkIssued({});
const res = await updateIssuedOrder(order.id, { supplier_id: 99999999 });
expect("error" in res && res.error).toBe("supplier_not_found");
});
});
describe("getIssuedOrder", () => {
it("returns customer_name and valid_transitions", async () => {
const c = await makeCustomer();
const order = await createIssuedOrder({ customer_id: c.id });
createdIds.push(order.id);
it("returns supplier, supplier_name and valid_transitions", async () => {
const s = await makeSupplier();
const order = await mkIssued({ supplier_id: s.id });
const detail = await getIssuedOrder(order.id);
expect(detail?.customer_name).toBe("Dodavatel s.r.o.");
expect(detail?.supplier_name).toBe("io_test_Dodavatel s.r.o.");
expect(detail?.supplier?.id).toBe(s.id);
expect(detail?.valid_transitions).toContain("sent");
});
});
describe("deleteIssuedOrder", () => {
it("deletes, cascades items, frees the latest number", async () => {
const order = await createIssuedOrder({
const order = await mkIssued({
items: [{ description: "X", quantity: 1, unit_price: 1 }],
});
// Finalize so the order has a real (consumed) number to free on delete.
@@ -215,8 +248,7 @@ describe("deleteIssuedOrder", () => {
// Allocate a real prior-year number (consumes that year's sequence: 0 -> 1)
// so the PO number matches the prior year's current highest.
const { number: poNumber } = await generateIssuedOrderNumber(priorYear);
const order = await createIssuedOrder({ po_number: poNumber });
createdIds.push(order.id);
const order = await mkIssued({ po_number: poNumber });
// Backdate creation into the prior year so delete derives that year.
await prisma.issued_orders.update({
where: { id: order.id },
@@ -234,9 +266,8 @@ describe("deleteIssuedOrder", () => {
describe("listIssuedOrders month filter", () => {
it("returns only orders whose order_date is in the given month", async () => {
const inMonth = await createIssuedOrder({ order_date: "2026-03-15" });
const outMonth = await createIssuedOrder({ order_date: "2026-04-15" });
createdIds.push(inMonth.id, outMonth.id);
const inMonth = await mkIssued({ order_date: "2026-03-15" });
const outMonth = await mkIssued({ order_date: "2026-04-15" });
const res = await listIssuedOrders({
page: 1,
limit: 50,
@@ -252,14 +283,148 @@ describe("listIssuedOrders month filter", () => {
});
});
/* -------------------------------------------------------------------------- */
/* Route-level: suppliers lookup endpoint + supplier validation */
/* -------------------------------------------------------------------------- */
let app: ReturnType<typeof Fastify> | null = null;
let adminToken = "";
let noPermToken = "";
let noPermUserId = 0;
let noPermRoleId = 0;
function generateToken(user: {
id: number;
username: string;
roleName: string | null;
}): string {
return jwt.sign(
{ sub: user.id, username: user.username, role: user.roleName },
config.jwt.secret,
{ expiresIn: "15m" },
);
}
beforeAll(async () => {
app = Fastify({ logger: false });
await app.register(issuedOrdersRoutes, {
prefix: "/api/admin/issued-orders",
});
const admin = await prisma.users.findFirst({
where: { roles: { name: "admin" } },
});
if (!admin) throw new Error("Test setup: admin user not found");
adminToken = generateToken({
id: admin.id,
username: admin.username,
roleName: "admin",
});
// A role with NO orders permissions, to prove the lookup endpoint is gated.
const noPermRole = await prisma.roles.create({
data: {
name: "io_test_no_orders",
display_name: "Test No Orders",
description: "Test role without orders permissions",
},
});
noPermRoleId = noPermRole.id;
const noPermUser = await prisma.users.create({
data: {
username: "io_test_noperm",
email: "io_test_noperm@test.local",
password_hash:
"$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO",
first_name: "No",
last_name: "Orders",
is_active: true,
role_id: noPermRole.id,
},
});
noPermUserId = noPermUser.id;
noPermToken = generateToken({
id: noPermUser.id,
username: noPermUser.username,
roleName: noPermRole.name,
});
});
afterAll(async () => {
if (noPermUserId)
await prisma.users.delete({ where: { id: noPermUserId } }).catch(() => {});
if (noPermRoleId)
await prisma.roles.delete({ where: { id: noPermRoleId } }).catch(() => {});
if (app) await app.close();
});
describe("GET /api/admin/issued-orders/suppliers", () => {
it("returns active suppliers only (with the picker fields)", async () => {
const active = await makeSupplier({
name: "io_test_Aktivní dodavatel",
ico: "12345678",
});
const inactive = await makeSupplier({
name: "io_test_Neaktivní dodavatel",
is_active: false,
});
const res = await app!.inject({
method: "GET",
url: "/api/admin/issued-orders/suppliers",
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
const ids = body.data.map((s: { id: number }) => s.id);
expect(ids).toContain(active.id);
expect(ids).not.toContain(inactive.id);
const row = body.data.find((s: { id: number }) => s.id === active.id);
expect(row.name).toBe("io_test_Aktivní dodavatel");
expect(row.ico).toBe("12345678");
// Lightweight lookup shape — all picker fields present.
expect(Object.keys(row).sort()).toEqual(
["address", "dic", "email", "ico", "id", "name", "phone"].sort(),
);
});
it("is denied (403) to a user with no orders permissions", async () => {
const res = await app!.inject({
method: "GET",
url: "/api/admin/issued-orders/suppliers",
headers: { Authorization: `Bearer ${noPermToken}` },
});
expect(res.statusCode).toBe(403);
expect(res.json().success).toBe(false);
});
});
describe("POST /api/admin/issued-orders supplier validation", () => {
it("returns 400 (not a P2003 500) for a nonexistent supplier_id", async () => {
const res = await app!.inject({
method: "POST",
url: "/api/admin/issued-orders",
headers: { Authorization: `Bearer ${adminToken}` },
payload: { supplier_id: 99999999 },
});
expect(res.statusCode).toBe(400);
const body = res.json();
expect(body.success).toBe(false);
expect(body.error).toBe("Dodavatel nenalezen");
});
});
/* -------------------------------------------------------------------------- */
/* PDF render */
/* -------------------------------------------------------------------------- */
describe("renderIssuedOrderHtml", () => {
const order = {
po_number: "26720001",
order_date: new Date("2026-06-09T12:00:00"),
delivery_date: null,
currency: "CZK",
apply_vat: true,
vat_rate: 21,
notes: "<p>Pozn</p><script>alert(1)</script>",
delivery_terms: null,
payment_terms: null,
@@ -272,17 +437,24 @@ describe("renderIssuedOrderHtml", () => {
quantity: 2,
unit: "ks",
unit_price: 100,
vat_rate: 21,
},
];
const issuer = { name: "Jan Novák" };
// sklad_suppliers shape: single Text address blob + ico/dic columns.
const supplier = {
name: "Dodavatel s.r.o.",
ico: "12345678",
dic: "CZ12345678",
address: "Průmyslová 5\n190 00 Praha",
};
it("renders the PO number, items, and both party names (PO direction)", () => {
const html = renderIssuedOrderHtml(
order,
items,
{ name: "Dodavatel s.r.o." },
supplier,
{ company_name: "Naše firma" },
"cs",
issuer,
@@ -291,20 +463,96 @@ describe("renderIssuedOrderHtml", () => {
expect(html).toContain("Materiál");
// Optional item sub-line.
expect(html).toContain("Detailní popis položky");
// PO direction: the customer record is the Dodavatel (supplier), our
// company is the Odběratel (buyer).
// PO direction: the sklad_suppliers record is the Dodavatel (supplier),
// our company is the Odběratel (buyer).
expect(html).toContain("Dodavatel s.r.o.");
expect(html).toContain("Naše firma");
expect(html).toContain("Dodavatel");
expect(html).toContain("Odběratel");
});
it("renders the supplier address (split on newlines) plus IČO and DIČ", () => {
const html = renderIssuedOrderHtml(
order,
items,
supplier,
null,
"cs",
issuer,
);
// The single Text address blob becomes one address line per newline.
expect(html).toContain('<div class="address-line">Průmyslová 5</div>');
expect(html).toContain('<div class="address-line">190 00 Praha</div>');
expect(html).toContain("IČ: 12345678");
expect(html).toContain("DIČ: CZ12345678");
});
it("renders a no-newline address as a single line and skips missing IČO/DIČ", () => {
const html = renderIssuedOrderHtml(
order,
items,
{ name: "Jednořádkový", address: "Ulice 1, 100 00 Praha" },
null,
"cs",
issuer,
);
expect(html).toContain(
'<div class="address-line">Ulice 1, 100 00 Praha</div>',
);
expect(html).not.toContain("IČ: ");
expect(html).not.toContain("DIČ: ");
});
it("strips script tags from notes", () => {
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
expect(html).not.toContain("<script>");
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", () => {
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
// Footer: Vystavil <name> from authData. No e-mail line.

View File

@@ -47,8 +47,6 @@ const baseOrder = {
status: "prijata" as const,
currency: "CZK",
language: "cs",
vat_rate: 21,
apply_vat: true,
exchange_rate: 1,
};
@@ -117,8 +115,6 @@ describe("createOrderFromQuotation (auto-project gets its OWN number)", () => {
status: "active",
currency: "CZK",
language: "cs",
vat_rate: 21,
apply_vat: true,
},
});
createdQuotationIds.push(quotation.id);

View File

@@ -54,18 +54,16 @@ function amountFor(
}
describe("getOfferTotals per-currency aggregation", () => {
it("sums offer TOTAL incl. VAT per currency over the full filtered set", async () => {
// Two CZK offers + one EUR. 21% VAT applied on the whole subtotal
// (enrichQuotation math).
// CZK #1: 2 x 1000 = 2000 net -> +21% = 2420
// CZK #2: 1 x 500 = 500 net -> +21% = 605 => CZK total 3025
// EUR : 3 x 100 = 300 net -> +21% = 363 => EUR total 363
it("sums offer NET total per currency over the full filtered set", async () => {
// Two CZK offers + one EUR. NET only (enrichQuotation math — offers are
// not tax documents, no VAT anywhere).
// CZK #1: 2 x 1000 = 2000
// CZK #2: 1 x 500 = 500 => CZK total 2500
// EUR : 3 x 100 = 300 => EUR total 300
const mk = async (currency: string, qty: number, price: number) => {
const res = await createOffer({
status: "draft", // draft so no offer number is consumed
currency,
vat_rate: 21,
apply_vat: true,
project_code: OFFER_MARKER,
items: [{ description: "X", quantity: qty, unit_price: price }],
});
@@ -79,8 +77,8 @@ describe("getOfferTotals per-currency aggregation", () => {
await mk("EUR", 3, 100);
const { totals } = await getOfferTotals({ search: OFFER_MARKER });
expect(amountFor(totals, "CZK")).toBe(3025);
expect(amountFor(totals, "EUR")).toBe(363);
expect(amountFor(totals, "CZK")).toBe(2500);
expect(amountFor(totals, "EUR")).toBe(300);
});
it("respects the where: a status filter narrows the result", async () => {
@@ -88,8 +86,6 @@ describe("getOfferTotals per-currency aggregation", () => {
const res = await createOffer({
status,
currency: "CZK",
vat_rate: 21,
apply_vat: true,
project_code: OFFER_MARKER,
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
});
@@ -103,23 +99,23 @@ describe("getOfferTotals per-currency aggregation", () => {
await mk("draft");
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 });
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({
search: OFFER_MARKER,
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({
search: OFFER_MARKER,
status: "invalidated",
});
expect(amountFor(onlyInvalid.totals, "CZK")).toBe(1210);
expect(amountFor(onlyInvalid.totals, "CZK")).toBe(1000);
});
});

View File

@@ -8,8 +8,9 @@ import {
// 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
// endpoints sum order TOTAL (incl. VAT) per currency across the WHOLE filtered
// set, and that the where (month/year + status) is respected.
// endpoints sum order NET total per currency across the WHOLE filtered set
// (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
// window and skew the deterministic sums (mirrors drafts-aggregation.test.ts).
@@ -46,19 +47,17 @@ function amountFor(
}
describe("getOrderTotals (received orders) per-currency aggregation", () => {
it("sums TOTAL incl. VAT per currency over the full filtered set", async () => {
// Two CZK orders + one EUR, all in the same far-future month. 21% VAT,
// applied on the whole subtotal (enrichOrder math).
// CZK #1: 2 x 1000 = 2000 net -> +21% = 2420
// CZK #2: 1 x 500 = 500 net -> +21% = 605 => CZK total 3025
// EUR : 3 x 100 = 300 net -> +21% = 363 => EUR total 363
it("sums NET total per currency over the full filtered set", async () => {
// Two CZK orders + one EUR, all in the same far-future month. NET only
// (enrichOrder math — orders carry no VAT).
// CZK #1: 2 x 1000 = 2000
// CZK #2: 1 x 500 = 500 => CZK total 2500
// EUR : 3 x 100 = 300 => EUR total 300
const mk = async (currency: string, qty: number, price: number) => {
const res = await createOrder({
status: "prijata",
currency,
language: "cs",
vat_rate: 21,
apply_vat: true,
create_project: false,
items: [{ description: "X", quantity: qty, unit_price: price }],
});
@@ -83,8 +82,8 @@ describe("getOrderTotals (received orders) per-currency aggregation", () => {
month: STATS_MONTH,
year: STATS_YEAR,
});
expect(amountFor(totals, "CZK")).toBe(3025);
expect(amountFor(totals, "EUR")).toBe(363);
expect(amountFor(totals, "CZK")).toBe(2500);
expect(amountFor(totals, "EUR")).toBe(300);
});
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,
currency: "CZK",
language: "cs",
vat_rate: 21,
apply_vat: true,
create_project: false,
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
});
@@ -122,49 +119,45 @@ describe("getOrderTotals (received orders) per-currency aggregation", () => {
await mk("stornovana", 0);
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({
month: STATS_MONTH,
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({
month: STATS_MONTH,
year: STATS_YEAR,
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({
month: STATS_MONTH + 1,
year: STATS_YEAR,
});
expect(amountFor(nextMonth.totals, "CZK")).toBe(1210);
expect(amountFor(nextMonth.totals, "CZK")).toBe(1000);
});
});
describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () => {
it("sums TOTAL incl. VAT per currency over the full filtered set", async () => {
// order_date is settable at create, so no post-create pin needed. VAT is
// applied per-line (computeIssuedOrderTotals) but with a single line per
// order here the result matches the on-the-whole subtotal math.
// CZK #1: 2 x 1000 @21% = 2420
// CZK #2: 1 x 500 @21% = 605 => CZK total 3025
// EUR : 3 x 100 @21% = 363 => EUR total 363
it("sums NET total per currency over the full filtered set", async () => {
// order_date is settable at create, so no post-create pin needed. NET only
// (computeIssuedOrderTotals — issued orders carry no VAT).
// CZK #1: 2 x 1000 = 2000
// CZK #2: 1 x 500 = 500 => CZK total 2500
// EUR : 3 x 100 = 300 => EUR total 300
const dateStr = `${STATS_YEAR}-0${STATS_MONTH}-15`;
const mk = async (currency: string, qty: number, price: number) => {
const o = await createIssuedOrder({
currency,
vat_rate: 21,
apply_vat: true,
order_date: dateStr,
items: [
{ description: "X", quantity: qty, unit_price: price, vat_rate: 21 },
],
items: [{ description: "X", quantity: qty, unit_price: price }],
});
if ("error" in o) throw new Error(`createIssuedOrder failed: ${o.error}`);
createdIssuedIds.push(o.id);
return o.id;
};
@@ -177,69 +170,63 @@ describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () =>
month: STATS_MONTH,
year: STATS_YEAR,
});
expect(amountFor(totals, "CZK")).toBe(3025);
expect(amountFor(totals, "EUR")).toBe(363);
expect(amountFor(totals, "CZK")).toBe(2500);
expect(amountFor(totals, "EUR")).toBe(300);
});
it("respects the where: a different month and a status filter change the result", async () => {
const inMonth = `${STATS_YEAR}-0${STATS_MONTH}-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({
currency: "CZK",
vat_rate: 21,
apply_vat: true,
order_date: inMonth,
items: [
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
],
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
});
if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`);
createdIssuedIds.push(draft.id);
const sent = await createIssuedOrder({
status: "sent",
currency: "CZK",
vat_rate: 21,
apply_vat: true,
order_date: inMonth,
items: [
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
],
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
});
if ("error" in sent)
throw new Error(`createIssuedOrder failed: ${sent.error}`);
createdIssuedIds.push(sent.id);
const next = await createIssuedOrder({
currency: "CZK",
vat_rate: 21,
apply_vat: true,
order_date: nextMonth,
items: [
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
],
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
});
if ("error" in next)
throw new Error(`createIssuedOrder failed: ${next.error}`);
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({
month: STATS_MONTH,
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({
month: STATS_MONTH,
year: STATS_YEAR,
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({
month: STATS_MONTH + 1,
year: STATS_YEAR,
});
expect(amountFor(nextStats.totals, "CZK")).toBe(1210);
expect(amountFor(nextStats.totals, "CZK")).toBe(1000);
});
});

View File

@@ -30,8 +30,6 @@ const baseOrder = {
status: "prijata" as const,
currency: "CZK",
language: "cs",
vat_rate: 21,
apply_vat: true,
exchange_rate: 1,
};

View 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ě");
});
});

View File

@@ -58,34 +58,6 @@ describe("Zod form coercion + NaN rejection", () => {
});
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", () => {
const result = CreateQuotationSchema.safeParse({
customer_id: 1,
@@ -300,8 +272,8 @@ describe("schema hardening — does not reject previously-valid input", () => {
expect(b.success).toBe(true);
if (b.success) expect(b.data.trip_date).toBe("2026-06-09");
// Genuinely malformed dates are still rejected.
expect(CreateTripSchema.safeParse({ ...base, trip_date: "09/06/2026" }).success).toBe(
false,
);
expect(
CreateTripSchema.safeParse({ ...base, trip_date: "09/06/2026" }).success,
).toBe(false);
});
});

View File

@@ -7,8 +7,8 @@ import { useTheme } from "@mui/material/styles";
import { Modal, Button, TextField, Field } from "../ui";
import { useAlert } from "../context/AlertContext";
// Editable line-item. quantity/unit_price/vat_rate are held as the raw typed
// string while editing (so a field can be cleared — empty renders fine in a
// Editable line-item. quantity/unit_price are held as the raw typed string
// while editing (so a field can be cleared — empty renders fine in a
// type="number" input) and are coerced to numbers in handleEditGenerate before
// being handed to onGenerate (the parent posts them verbatim).
interface ConfirmationItem {
@@ -17,7 +17,6 @@ interface ConfirmationItem {
unit: string;
unit_price: string | number;
is_included_in_total: boolean;
vat_rate: string | number;
}
// Numeric shape handed to the parent (the raw editing strings are coerced in
@@ -28,21 +27,14 @@ interface GeneratedItem {
unit: string;
unit_price: number;
is_included_in_total: boolean;
vat_rate: number;
}
interface OrderConfirmationModalProps {
isOpen: boolean;
onClose: () => void;
onGenerate: (
lang: string,
applyVat: boolean,
items?: GeneratedItem[],
) => Promise<void>;
onGenerate: (lang: string, items?: GeneratedItem[]) => Promise<void>;
initialItems: ConfirmationItem[];
orderNumber: string;
defaultVatRate: number;
applyVat: boolean;
}
export default function OrderConfirmationModal({
@@ -51,15 +43,12 @@ export default function OrderConfirmationModal({
onGenerate,
initialItems,
orderNumber,
defaultVatRate,
applyVat,
}: OrderConfirmationModalProps) {
const alert = useAlert();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const [step, setStep] = useState<"choose" | "edit">("choose");
const [lang, setLang] = useState<string>("cs");
const [applyVatState, setApplyVatState] = useState(applyVat);
const [items, setItems] = useState<ConfirmationItem[]>(initialItems);
const [loading, setLoading] = useState(false);
@@ -72,17 +61,16 @@ export default function OrderConfirmationModal({
if (!isOpen) return;
setStep("choose");
setLang("cs");
setApplyVatState(applyVat);
setItems(initialItems);
// initialItems/applyVat are captured at open time; intentionally not in the
// dep array so an unrelated parent re-render doesn't clobber the user's edits.
// initialItems are captured at open time; intentionally not in the dep
// array so an unrelated parent re-render doesn't clobber the user's edits.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
const handleUseExisting = async () => {
setLoading(true);
try {
await onGenerate(lang, applyVatState, undefined);
await onGenerate(lang, undefined);
// Only close on success — a generation error must keep the modal open.
onClose();
} catch (err) {
@@ -104,9 +92,8 @@ export default function OrderConfirmationModal({
unit: it.unit,
unit_price: Number(it.unit_price) || 0,
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.
onClose();
} catch (err) {
@@ -145,10 +132,9 @@ export default function OrderConfirmationModal({
unit: "ks",
unit_price: 0,
is_included_in_total: true,
vat_rate: defaultVatRate,
},
]);
}, [defaultVatRate]);
}, []);
return (
<Modal
@@ -186,27 +172,6 @@ export default function OrderConfirmationModal({
</Box>
</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í">
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Jak chcete připravit potvrzení objednávky?
@@ -291,7 +256,7 @@ export default function OrderConfirmationModal({
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
gap: 1,
}}
>
@@ -318,15 +283,6 @@ export default function OrderConfirmationModal({
}
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>
))}
@@ -356,7 +312,6 @@ export default function OrderConfirmationModal({
<th>Mn.</th>
<th>Jedn.</th>
<th>Cena</th>
<th>%DPH</th>
<th style={{ width: "40px" }} />
</tr>
</thead>
@@ -403,17 +358,6 @@ export default function OrderConfirmationModal({
slotProps={{ htmlInput: { step: "0.01" } }}
/>
</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>
<IconButton
size="small"

View File

@@ -9,6 +9,7 @@ import {
type ReactNode,
} from "react";
import { setSessionExpired, setTokenGetter, setRefreshFn } from "../utils/api";
import { queryClient } from "../lib/queryClient";
const API_BASE = "/api/admin";
@@ -232,6 +233,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
remember,
};
}
// Query keys are user-agnostic (["dashboard"], ["attendance"], …),
// so anything cached from a previously logged-in user would be
// served to this one — drop the whole cache on every login.
queryClient.clear();
setAccessTokenFn(data.data.access_token, data.data.expires_in);
setUser(mapUser(data.data.user));
cachedUserRef.current = mapUser(data.data.user);
@@ -288,6 +293,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
);
const data = await response.json();
if (data.success) {
// Same reason as in login(): the cache may hold the previous
// user's data under user-agnostic keys.
queryClient.clear();
setAccessTokenFn(data.data.access_token, data.data.expires_in);
setUser(mapUser(data.data.user));
cachedUserRef.current = mapUser(data.data.user);
@@ -327,6 +335,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
clearTimeout(refreshTimeoutRef.current);
refreshTimeoutRef.current = null;
}
// The React Query cache outlives the session — without this, the next
// user to log in on this browser sees the previous user's cached
// dashboards/lists until each query refetches.
queryClient.clear();
}
}, [getAccessTokenFn]);

View File

@@ -1048,6 +1048,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
if (result.success) {
setShowCreateModal(false);
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 new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
@@ -1120,6 +1124,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
if (result.success) {
setShowBulkModal(false);
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 new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
@@ -1255,6 +1263,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
if (result.success) {
setShowEditModal(false);
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 new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
@@ -1285,6 +1297,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
if (result.success) {
setDeleteConfirm({ show: false, record: null });
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);
alert.success(
result.message || result.data?.message || "Záznam smazán",

View File

@@ -6,6 +6,11 @@ export const dashboardOptions = () =>
queryKey: ["dashboard"],
queryFn: () => jsonQuery<Record<string, unknown>>("/api/admin/dashboard"),
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

View File

@@ -4,16 +4,26 @@ import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
export interface IssuedOrder {
id: number;
po_number: string | null;
customer_id: number | null;
customer_name: string | null;
supplier_id: number | null;
supplier_name: string | null;
status: string;
currency: string | null;
order_date: string | null;
subtotal: number;
vat_amount: number;
// NET total — issued orders carry no VAT (not tax documents).
total: number;
}
/** Active supplier row from the lightweight PO-picker lookup endpoint. */
export interface Supplier {
id: number;
name: string;
ico: string | null;
dic: string | null;
address: string | null;
email: string | null;
phone: string | null;
}
export interface IssuedOrderItem {
id?: number;
description: string | null;
@@ -21,26 +31,33 @@ export interface IssuedOrderItem {
quantity: number | string | null;
unit: string | null;
unit_price: number | string | null;
vat_rate: number | string | null;
position?: number;
}
export interface IssuedOrderDetail extends IssuedOrder {
apply_vat: boolean | null;
vat_rate: number | string | null;
exchange_rate: number | string | null;
delivery_date: string | null;
language: string | null;
delivery_terms: string | null;
payment_terms: string | null;
issued_by: string | null;
order_text: string | null;
notes: string | null;
internal_notes: string | null;
items: IssuedOrderItem[];
customer: Record<string, unknown> | null;
supplier: Record<string, unknown> | null;
valid_transitions: string[];
}
// Suppliers lookup for the PO supplier picker — hits the issued-orders-scoped
// endpoint (orders permissions), NOT the warehouse.manage-guarded CRUD list.
export const issuedOrderSuppliersOptions = () =>
queryOptions({
queryKey: ["issued-orders", "suppliers"],
queryFn: () => jsonQuery<Supplier[]>("/api/admin/issued-orders/suppliers"),
staleTime: 2 * 60_000,
});
export const issuedOrderListOptions = (filters: {
search?: string;
sort?: string;

View File

@@ -157,8 +157,6 @@ export interface OfferDetailData {
valid_until: string;
currency: string;
language: string;
vat_rate: number;
apply_vat: boolean;
items?: OfferItemData[];
sections?: OfferSectionData[];
status: string;

View File

@@ -43,8 +43,6 @@ export interface OrderData {
status: string;
notes: string;
attachment_name?: string;
apply_vat: number | boolean;
vat_rate: number;
language?: string;
items: OrderItem[];
sections: OrderSection[];

View File

@@ -274,7 +274,8 @@ export default function Attendance() {
>({
url: () => `${API_BASE}/attendance`,
method: () => "POST",
invalidate: ["attendance"],
// dashboard included: the punch-button state + presence cards live there.
invalidate: ["attendance", "dashboard"],
});
const notesMutation = useApiMutation<{ notes: string }, { message?: string }>(
@@ -300,7 +301,8 @@ export default function Attendance() {
>({
url: () => `${API_BASE}/leave-requests`,
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);

View File

@@ -74,7 +74,7 @@ export default function AttendanceCreate() {
const createMutation = useApiMutation<CreatePayload, { message?: string }>({
url: () => `${API_BASE}/attendance`,
method: () => "POST",
invalidate: ["attendance", "users"],
invalidate: ["attendance", "users", "dashboard"],
});
const [form, setForm] = useState<CreateForm>(() => {

View File

@@ -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) {
const order = orderDataQuery.data;
const vatRate = numberOr(
order.vat_rate,
companySettings?.default_vat_rate ?? 21,
);
const vatRate = numberOr(companySettings?.default_vat_rate, 21);
setForm((prev) => ({
...prev,
customer_id: order.customer_id as number,
@@ -803,7 +802,7 @@ export default function InvoiceDetail() {
(order.currency as string) ||
companySettings?.default_currency ||
"CZK",
apply_vat: Number(order.apply_vat) || 0,
apply_vat: 1,
vat_rate: vatRate,
}));
const orderItems = order.items as Record<string, unknown>[] | undefined;

View File

@@ -41,8 +41,11 @@ import Forbidden from "../components/Forbidden";
import RichEditor from "../components/RichEditor";
import apiFetch from "../utils/api";
import { jsonQuery } from "../lib/apiAdapter";
import { offerCustomersOptions, type Customer } from "../lib/queries/offers";
import { issuedOrderDetailOptions } from "../lib/queries/issued-orders";
import {
issuedOrderDetailOptions,
issuedOrderSuppliersOptions,
type Supplier,
} from "../lib/queries/issued-orders";
import { companySettingsOptions } from "../lib/queries/settings";
import { formatCurrency, numberOr, todayLocalStr } from "../utils/formatters";
import { normalizeDateStr } from "../utils/attendanceHelpers";
@@ -54,11 +57,10 @@ import {
DateField,
Field,
StatusChip,
CheckboxField,
ConfirmDialog,
LoadingState,
PageEnter,
CustomerPicker,
SupplierPicker,
headerActionsSx,
} from "../ui";
import {
@@ -82,11 +84,6 @@ const TRANSITION_LABELS: Record<string, string> = {
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 BackIcon = (
@@ -148,26 +145,23 @@ interface OrderItem {
item_description: string;
// 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
// only where used (live totals + save payload). vat_rate stays numeric: it
// is edited via a Select, never a free-text number input.
// only where used (live totals + save payload).
quantity: string | number;
unit: string;
unit_price: string | number;
vat_rate: number;
}
interface OrderForm {
customer_id: number | null;
customer_name: string;
supplier_id: number | null;
supplier_name: string;
currency: string;
apply_vat: boolean;
vat_rate: number;
order_date: string;
delivery_date: string;
language: string;
delivery_terms: string;
payment_terms: string;
issued_by: string;
order_text: string;
notes: string;
internal_notes: string;
status: string;
@@ -178,7 +172,6 @@ function SortableOrderRow({
item,
index,
currency,
apply_vat,
readOnly,
onUpdate,
onRemove,
@@ -187,7 +180,6 @@ function SortableOrderRow({
item: OrderItem;
index: number;
currency: string;
apply_vat: boolean;
readOnly: boolean;
onUpdate: (
index: number,
@@ -286,7 +278,7 @@ function SortableOrderRow({
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
gap: 1,
}}
>
@@ -313,21 +305,6 @@ function SortableOrderRow({
slotProps={{ htmlInput: { step: "any" } }}
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
sx={{
@@ -432,20 +409,6 @@ function SortableOrderRow({
sx={{ "& input": { textAlign: "right" } }}
/>
</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
align="right"
sx={{
@@ -492,7 +455,6 @@ export default function IssuedOrderDetail() {
quantity: 1,
unit: "ks",
unit_price: 0,
vat_rate: 21,
}),
[],
);
@@ -506,17 +468,16 @@ export default function IssuedOrderDetail() {
);
const [form, setForm] = useState<OrderForm>({
customer_id: null,
customer_name: "",
supplier_id: null,
supplier_name: "",
currency: "CZK",
apply_vat: true,
vat_rate: 21,
order_date: todayLocalStr(),
delivery_date: "",
language: "cs",
delivery_terms: "",
payment_terms: "",
issued_by: user?.fullName || "",
order_text: "",
notes: "",
internal_notes: "",
status: "draft",
@@ -529,7 +490,6 @@ export default function IssuedOrderDetail() {
quantity: 1,
unit: "ks",
unit_price: 0,
vat_rate: 21,
},
]);
const [errors, setErrors] = useState<Record<string, string>>({});
@@ -551,8 +511,34 @@ export default function IssuedOrderDetail() {
const [deleting, setDeleting] = useState(false);
// ─── Queries ───
const customersQuery = useQuery(offerCustomersOptions());
const customers = customersQuery.data ?? [];
const suppliersQuery = useQuery(issuedOrderSuppliersOptions());
// The lookup returns ACTIVE suppliers only, but a saved order may reference
// a since-deactivated one — without a fallback option the picker would
// resolve to nothing and render the counterparty blank. The fallback is
// built from the hydrated form state (supplier_name comes from the detail
// response), so the name stays visible and a re-save keeps the same id.
const suppliers = useMemo<Supplier[]>(() => {
const activeSuppliers = suppliersQuery.data ?? [];
if (
form.supplier_id != null &&
!activeSuppliers.some((s: Supplier) => s.id === form.supplier_id)
) {
return [
...activeSuppliers,
{
id: form.supplier_id,
name: form.supplier_name || `Dodavatel #${form.supplier_id}`,
ico: null,
dic: null,
address: null,
email: null,
phone: null,
},
];
}
return activeSuppliers;
}, [suppliersQuery.data, form.supplier_id, form.supplier_name]);
const companySettings = useQuery(companySettingsOptions()).data;
// Configurable currency list from company settings (falls back to the
@@ -576,22 +562,21 @@ export default function IssuedOrderDetail() {
// ─── Edit mode: hydrate form from detail (once) ───
useEffect(() => {
if (!isEdit || dataReady) return;
if (detailQuery.isLoading || customersQuery.isLoading) return;
if (detailQuery.isLoading || suppliersQuery.isLoading) return;
if (!detailQuery.data) return;
const d = detailQuery.data;
setForm({
customer_id: d.customer_id ?? null,
customer_name: d.customer_name ?? "",
supplier_id: d.supplier_id ?? null,
supplier_name: d.supplier_name ?? "",
currency: d.currency || "CZK",
apply_vat: d.apply_vat !== false,
vat_rate: numberOr(d.vat_rate, 21),
order_date: normalizeDateStr(d.order_date),
delivery_date: normalizeDateStr(d.delivery_date),
language: d.language || "cs",
delivery_terms: d.delivery_terms || "",
payment_terms: d.payment_terms || "",
issued_by: d.issued_by || "",
order_text: d.order_text || "",
notes: d.notes || "",
internal_notes: d.internal_notes || "",
status: d.status,
@@ -608,7 +593,6 @@ export default function IssuedOrderDetail() {
quantity: numberOr(it.quantity, 1),
unit: it.unit || "",
unit_price: Number(it.unit_price) || 0,
vat_rate: numberOr(it.vat_rate, numberOr(d.vat_rate, 21)),
}))
: [];
if (mapped.length > 0) setItems(mapped);
@@ -619,13 +603,13 @@ export default function IssuedOrderDetail() {
dataReady,
detailQuery.isLoading,
detailQuery.data,
customersQuery.isLoading,
suppliersQuery.isLoading,
]);
// ─── Create mode: set the previewed PO number + default issued_by ───
useEffect(() => {
if (isEdit || dataReady) return;
if (nextNumberQuery.isLoading || customersQuery.isLoading) return;
if (nextNumberQuery.isLoading || suppliersQuery.isLoading) return;
if (nextNumberQuery.data) setPoNumber(nextNumberQuery.data);
setDataReady(true);
}, [
@@ -633,7 +617,7 @@ export default function IssuedOrderDetail() {
dataReady,
nextNumberQuery.isLoading,
nextNumberQuery.data,
customersQuery.isLoading,
suppliersQuery.isLoading,
]);
// Keep the displayed PO number in sync once it becomes available — a draft
@@ -651,21 +635,14 @@ export default function IssuedOrderDetail() {
const editable = !isEdit || form.status === "draft" || form.status === "sent";
const canExport = hasPermission("orders.view");
// ─── Totals (live) ───
// ─── Totals (live, NET only — issued orders carry no VAT) ───
const totals = useMemo(() => {
let subtotal = 0;
const vatByRate: Record<number, number> = {};
let total = 0;
items.forEach((it) => {
const line = (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;
}
total += (Number(it.quantity) || 0) * (Number(it.unit_price) || 0);
});
const totalVat = Object.values(vatByRate).reduce((s, v) => s + v, 0);
return { subtotal, vatByRate, totalVat, total: subtotal + totalVat };
}, [items, form.apply_vat]);
return { total };
}, [items]);
// ─── Mutations ───
const saveMutation = useApiMutation<Record<string, unknown>, { id: number }>({
@@ -715,20 +692,20 @@ export default function IssuedOrderDetail() {
});
};
const selectCustomer = (id: number | null) => {
const c = id != null ? customers.find((x: Customer) => x.id === id) : null;
const selectSupplier = (id: number | null) => {
const s = id != null ? suppliers.find((x: Supplier) => x.id === id) : null;
setForm((prev) => ({
...prev,
customer_id: id,
customer_name: c?.name || "",
supplier_id: id,
supplier_name: s?.name || "",
}));
setErrors((prev) => ({ ...prev, customer_id: "" }));
setErrors((prev) => ({ ...prev, supplier_id: "" }));
};
// ─── Submit (create + edit) ───
const handleSubmit = async (targetStatus?: string) => {
const newErrors: Record<string, string> = {};
if (!form.customer_id) newErrors.customer_id = "Vyberte dodavatele";
if (!form.supplier_id) newErrors.supplier_id = "Vyberte dodavatele";
if (!form.order_date) newErrors.order_date = "Zadejte datum";
if (items.length === 0 || items.every((i) => !i.description.trim())) {
newErrors.items = "Přidejte alespoň jednu položku";
@@ -740,16 +717,15 @@ export default function IssuedOrderDetail() {
setSavingAction(targetStatus ?? "save");
try {
const payload: Record<string, unknown> = {
customer_id: form.customer_id,
supplier_id: form.supplier_id,
currency: form.currency,
vat_rate: form.vat_rate,
apply_vat: form.apply_vat,
order_date: form.order_date,
delivery_date: form.delivery_date || null,
language: form.language,
delivery_terms: form.delivery_terms,
payment_terms: form.payment_terms,
issued_by: form.issued_by,
order_text: form.order_text || null,
notes: form.notes,
internal_notes: form.internal_notes,
items: items
@@ -762,7 +738,6 @@ export default function IssuedOrderDetail() {
quantity: Number(it.quantity) || 0,
unit: it.unit,
unit_price: Number(it.unit_price) || 0,
vat_rate: it.vat_rate,
position: i,
})),
};
@@ -1108,13 +1083,13 @@ export default function IssuedOrderDetail() {
gap: 2,
}}
>
<Field label="Dodavatel" error={errors.customer_id} required>
<CustomerPicker
customers={customers}
value={form.customer_id}
onChange={selectCustomer}
<Field label="Dodavatel" error={errors.supplier_id} required>
<SupplierPicker
suppliers={suppliers}
value={form.supplier_id}
onChange={selectSupplier}
disabled={!editable}
error={errors.customer_id}
error={errors.supplier_id}
placeholder="Vyberte dodavatele…"
/>
</Field>
@@ -1142,7 +1117,7 @@ export default function IssuedOrderDetail() {
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr 1fr" },
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" },
gap: 2,
}}
>
@@ -1169,31 +1144,6 @@ export default function IssuedOrderDetail() {
]}
/>
</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>
<Field label="Vystavil">
@@ -1258,7 +1208,6 @@ export default function IssuedOrderDetail() {
item={item}
index={index}
currency={form.currency}
apply_vat={form.apply_vat}
readOnly={!editable}
onUpdate={updateItem}
onRemove={removeItem}
@@ -1291,11 +1240,6 @@ export default function IssuedOrderDetail() {
<TableCell sx={{ width: "8rem" }} align="center">
Jedn. cena
</TableCell>
{form.apply_vat ? (
<TableCell sx={{ width: "5rem" }} align="center">
DPH
</TableCell>
) : null}
<TableCell sx={{ width: "8rem" }} align="right">
Celkem
</TableCell>
@@ -1309,7 +1253,6 @@ export default function IssuedOrderDetail() {
item={item}
index={index}
currency={form.currency}
apply_vat={form.apply_vat}
readOnly={!editable}
onUpdate={updateItem}
onRemove={removeItem}
@@ -1323,7 +1266,7 @@ export default function IssuedOrderDetail() {
</SortableContext>
</DndContext>
{/* Totals */}
{/* Totals (NET only — issued orders are not tax documents) */}
<Box
sx={{
mt: 2,
@@ -1334,34 +1277,6 @@ export default function IssuedOrderDetail() {
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
sx={{
display: "flex",
@@ -1373,7 +1288,7 @@ export default function IssuedOrderDetail() {
}}
>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
Celkem:
Celkem bez DPH:
</Typography>
<Typography
variant="body2"
@@ -1390,6 +1305,16 @@ export default function IssuedOrderDetail() {
{/* Notes & terms */}
<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">
<TextField
value={form.delivery_terms}

View File

@@ -229,10 +229,10 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
),
},
{
key: "customer_name",
key: "supplier_name",
header: "Dodavatel",
width: "24%",
render: (o) => o.customer_name || "—",
render: (o) => o.supplier_name || "—",
},
{
key: "status",
@@ -392,7 +392,7 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
fontSize: "0.9rem",
}}
>
<span>Celkem:</span>
<span>Celkem bez DPH:</span>
<Box
component="span"
sx={{ fontWeight: 700, color: "text.primary" }}

View File

@@ -161,7 +161,7 @@ export default function LeaveApproval() {
>({
url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
method: () => "PUT",
invalidate: ["leave-requests", "leave", "attendance", "users"],
invalidate: ["leave-requests", "leave", "attendance", "users", "dashboard"],
onSuccess: () => {
setApproveModal({ open: false, request: null });
alert.success("Žádost byla schválena");
@@ -174,7 +174,7 @@ export default function LeaveApproval() {
>({
url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
method: () => "PUT",
invalidate: ["leave-requests", "leave", "attendance", "users"],
invalidate: ["leave-requests", "leave", "attendance", "users", "dashboard"],
onSuccess: () => {
setRejectModal({ open: false, request: null });
setRejectNote("");

View File

@@ -118,8 +118,6 @@ interface OfferForm {
valid_until: string;
currency: string;
language: string;
vat_rate: number;
apply_vat: boolean;
}
const emptyForm: OfferForm = {
@@ -131,8 +129,6 @@ const emptyForm: OfferForm = {
valid_until: "",
currency: "CZK",
language: "EN",
vat_rate: 21,
apply_vat: false,
};
const emptyScopeSection = (): ScopeSection => ({
@@ -585,10 +581,6 @@ export default function OfferDetail() {
prev.currency === "CZK"
? companySettings.default_currency || "CZK"
: prev.currency,
vat_rate:
prev.vat_rate === 21
? (companySettings.default_vat_rate ?? 21)
: prev.vat_rate,
}));
}
}, [companySettings, isEdit]);
@@ -639,8 +631,6 @@ export default function OfferDetail() {
valid_until: d.valid_until ? d.valid_until.substring(0, 10) : "",
currency: d.currency || companySettings?.default_currency || "CZK",
language: d.language || "EN",
vat_rate: d.vat_rate ?? companySettings?.default_vat_rate ?? 21,
apply_vat: !!d.apply_vat,
};
setForm(formData);
const mappedItems =
@@ -790,7 +780,8 @@ export default function OfferDetail() {
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) {
return (
sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
@@ -798,8 +789,6 @@ export default function OfferDetail() {
}
return sum;
}, 0);
const vatAmount = form.apply_vat ? subtotal * (form.vat_rate / 100) : 0;
const total = subtotal + vatAmount;
const handleSave = async (targetStatus?: string) => {
const newErrors: Record<string, string> = {};
@@ -1389,44 +1378,6 @@ export default function OfferDetail() {
/>
</Field>
</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>
{/* Items Section with drag-and-drop */}
@@ -1596,7 +1547,7 @@ export default function OfferDetail() {
</SortableContext>
</DndContext>
{/* Totals */}
{/* Totals (NET only — offers are not tax documents) */}
<Box
sx={{
mt: 2,
@@ -1607,30 +1558,6 @@ export default function OfferDetail() {
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
sx={{
display: "flex",
@@ -1642,7 +1569,7 @@ export default function OfferDetail() {
}}
>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
Celkem:
Celkem bez DPH:
</Typography>
<Typography
variant="body2"

View File

@@ -906,7 +906,7 @@ export default function Offers() {
fontSize: "0.9rem",
}}
>
<span>Celkem:</span>
<span>Celkem bez DPH:</span>
<Box
component="span"
sx={{ fontWeight: 700, color: "text.primary" }}

View File

@@ -144,9 +144,10 @@ export default function OrderDetail() {
return () => window.removeEventListener("beforeunload", handler);
}, [isDirty]);
// NET only — received orders are not tax documents (no VAT on them).
const totals = useMemo(() => {
if (!order?.items) return { subtotal: 0, vatAmount: 0, total: 0 };
const subtotal = order.items.reduce((sum, item) => {
if (!order?.items) return { total: 0 };
const total = order.items.reduce((sum, item) => {
if (Number(item.is_included_in_total)) {
return (
sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
@@ -154,10 +155,7 @@ export default function OrderDetail() {
}
return sum;
}, 0);
const vatAmount = Number(order.apply_vat)
? subtotal * ((Number(order.vat_rate) || 0) / 100)
: 0;
return { subtotal, vatAmount, total: subtotal + vatAmount };
return { total };
}, [order]);
const statusMutation = useApiMutation<{ status: string }, unknown>({
@@ -236,14 +234,12 @@ export default function OrderDetail() {
const handleGenerateConfirmation = async (
lang: string,
applyVat: boolean,
customItems?: Array<{
description: string;
quantity: number;
unit: string;
unit_price: number;
is_included_in_total: boolean;
vat_rate: number;
}>,
) => {
setConfirmationLoading(true);
@@ -253,7 +249,7 @@ export default function OrderDetail() {
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ lang, applyVat, items: customItems }),
body: JSON.stringify({ lang, items: customItems }),
},
);
if (!response.ok) {
@@ -625,7 +621,7 @@ export default function OrderDetail() {
empty={<EmptyState title="Žádné položky." />}
/>
{/* Totals */}
{/* Totals (NET only — orders are not tax documents) */}
<Box
sx={{
mt: 2,
@@ -636,30 +632,6 @@ export default function OrderDetail() {
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
sx={{
display: "flex",
@@ -671,7 +643,7 @@ export default function OrderDetail() {
}}
>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
Celkem k úhradě:
Celkem bez DPH:
</Typography>
<Typography
variant="body2"
@@ -829,11 +801,8 @@ export default function OrderDetail() {
unit: it.unit || "",
unit_price: Number(it.unit_price) || 0,
is_included_in_total: Number(it.is_included_in_total) !== 0,
vat_rate: Number(order.vat_rate) || 21,
}))}
orderNumber={order.order_number}
defaultVatRate={Number(order.vat_rate) || 21}
applyVat={!!order.apply_vat}
/>
)}
</PageEnter>

View File

@@ -184,8 +184,6 @@ export default function OrdersReceived({
customer_id: "",
customer_order_number: "",
currency: "CZK",
vat_rate: "21",
apply_vat: true,
scope_title: "",
scope_description: "",
notes: "",
@@ -219,8 +217,6 @@ export default function OrdersReceived({
customer_id: "",
customer_order_number: "",
currency: "CZK",
vat_rate: "21",
apply_vat: true,
scope_title: "",
scope_description: "",
notes: "",
@@ -255,8 +251,6 @@ export default function OrdersReceived({
fd.append("customer_id", createForm.customer_id);
fd.append("customer_order_number", createForm.customer_order_number);
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_description", createForm.scope_description);
fd.append("notes", createForm.notes);
@@ -274,8 +268,6 @@ export default function OrdersReceived({
: null,
customer_order_number: createForm.customer_order_number,
currency: createForm.currency,
vat_rate: createForm.vat_rate,
apply_vat: createForm.apply_vat,
scope_title: createForm.scope_title,
scope_description: createForm.scope_description,
notes: createForm.notes,
@@ -571,7 +563,7 @@ export default function OrdersReceived({
fontSize: "0.9rem",
}}
>
<span>Celkem:</span>
<span>Celkem bez DPH:</span>
<Box
component="span"
sx={{ fontWeight: 700, color: "text.primary" }}
@@ -669,19 +661,6 @@ export default function OrdersReceived({
/>
</Field>
</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 sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
@@ -757,12 +736,6 @@ export default function OrdersReceived({
/>
</Field>
<CheckboxField
label="Účtovat DPH"
checked={createForm.apply_vat}
onChange={(v) => setCreateForm({ ...createForm, apply_vat: v })}
/>
<CheckboxField
label="Vytvořit propojený projekt"
checked={createForm.create_project}

View File

@@ -137,7 +137,9 @@ export default function WarehouseSuppliers() {
url: () =>
editingSupplier ? `${API_BASE}/${editingSupplier.id}` : API_BASE,
method: () => (editingSupplier ? "PUT" : "POST"),
invalidate: ["warehouse"],
// issued-orders included: the PO form's supplier picker caches under
// ["issued-orders","suppliers"] and must see supplier CRUD immediately.
invalidate: ["warehouse", "issued-orders"],
onSuccess: (data) => {
setShowModal(false);
alert.success(data?.message || "Dodavatel byl uložen");
@@ -147,7 +149,9 @@ export default function WarehouseSuppliers() {
const deleteMutation = useApiMutation<number, { message?: string }>({
url: (id) => `${API_BASE}/${id}`,
method: () => "DELETE",
invalidate: ["warehouse"],
// issued-orders included: the PO form's supplier picker caches under
// ["issued-orders","suppliers"] and must see supplier CRUD immediately.
invalidate: ["warehouse", "issued-orders"],
onSuccess: (data) => {
setDeactivateConfirm({ show: false, supplier: null });
alert.success(data?.message || "Dodavatel byl smazán");
@@ -163,7 +167,9 @@ export default function WarehouseSuppliers() {
>({
url: ({ id }) => `${API_BASE}/${id}`,
method: () => "PUT",
invalidate: ["warehouse"],
// issued-orders included: the PO form's supplier picker caches under
// ["issued-orders","suppliers"] and must see supplier CRUD immediately.
invalidate: ["warehouse", "issued-orders"],
});
if (!hasPermission("warehouse.manage")) return <Forbidden />;

View File

@@ -0,0 +1,88 @@
import Autocomplete, { createFilterOptions } from "@mui/material/Autocomplete";
import MuiTextField from "@mui/material/TextField";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import type { Supplier } from "../lib/queries/issued-orders";
interface SupplierPickerProps {
suppliers: Supplier[];
/** Selected supplier id (FK), or null when none chosen. */
value: number | null;
onChange: (id: number | null) => void;
disabled?: boolean;
/**
* When set, draws the input in its error state (red border). The error TEXT
* is owned by the surrounding <Field> wrapper, so we deliberately do NOT
* render helperText here to avoid duplicating the message.
*/
error?: string;
placeholder?: string;
/** Optional id forwarded by <Field> for label association. */
id?: string;
}
// Search matches the supplier name AND the IČO, so typing either finds the
// record. Match on a stringified "name + ico" so MUI's default
// case-insensitive substring filtering covers both.
const filterSuppliers = createFilterOptions<Supplier>({
stringify: (s) => `${s.name} ${s.ico ?? ""}`,
});
/**
* Searchable supplier picker for issued orders (purchase orders) — a
* controlled MUI Autocomplete over the sklad_suppliers lookup list, bound to
* the supplier id. Modeled on CustomerPicker (offers/invoices keep that one);
* renders bare (no label/error text); wrap it in the page's
* <Field label error> for the label + error line.
*/
export default function SupplierPicker({
suppliers,
value,
onChange,
disabled,
error,
placeholder,
id,
}: SupplierPickerProps) {
const selected = suppliers.find((s) => s.id === value) ?? null;
return (
<Autocomplete<Supplier>
options={suppliers}
value={selected}
onChange={(_, opt) => onChange(opt ? opt.id : null)}
getOptionLabel={(s) => s?.name ?? ""}
isOptionEqualToValue={(o, v) => o.id === v.id}
filterOptions={filterSuppliers}
disabled={disabled}
size="small"
fullWidth
autoHighlight
renderOption={(props, s) => {
const { key, ...rest } = props as typeof props & { key: string };
return (
<Box component="li" key={key} {...rest}>
<Box>
{s.name}
{s.ico && (
<Typography
variant="caption"
sx={{ display: "block", color: "text.secondary" }}
>
: {s.ico}
</Typography>
)}
</Box>
</Box>
);
}}
renderInput={(params) => (
<MuiTextField
{...params}
id={id}
placeholder={placeholder ?? "Vyberte dodavatele…"}
error={!!error}
/>
)}
/>
);
}

View File

@@ -10,6 +10,7 @@ export { Field, SwitchField } from "./Field";
export { default as Select } from "./Select";
export type { SelectOption } from "./Select";
export { default as CustomerPicker } from "./CustomerPicker";
export { default as SupplierPicker } from "./SupplierPicker";
export { default as StatusChip } from "./StatusChip";
export { CheckboxField } from "./Checkbox";
export { default as Alert } from "./Alert";

View File

@@ -11,15 +11,16 @@ export default async function dashboardRoutes(
): Promise<void> {
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
const now = new Date();
// shift_date is @db.Date: Prisma truncates a filter Date to its UTC date
// part, so these boundaries MUST be UTC-midnight instants of the LOCAL
// (Prague) calendar day. A local-midnight Date (new Date(y, m, d)) is
// 22:00/23:00 UTC of the PREVIOUS day and silently shifts the window to
// [yesterday, today) — the "Docházka dnes" card then matches zero rows.
const todayStart = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()),
);
const todayEnd = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate() + 1,
Date.UTC(now.getFullYear(), now.getMonth(), now.getDate() + 1),
);
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1);

View File

@@ -156,6 +156,31 @@ function buildAddressLines(
return { name, lines };
}
/**
* Address block for the sklad_suppliers counterparty. Unlike customers (which
* have structured street/city/postal columns), suppliers.address is a single
* Text blob — split it on newlines into one rendered line each (a blob without
* newlines renders as one line). IČO/DIČ come from the supplier's ico/dic
* columns, prefixed with the same translated labels the customer block used.
*/
function buildSupplierLines(
supplier: Record<string, unknown> | null,
tObj: Record<string, string>,
): AddressResult {
if (!supplier) return { name: "", lines: [] };
const name = String(supplier.name || "");
const lines: string[] = [];
if (supplier.address) {
for (const part of String(supplier.address).split(/\r?\n/)) {
const line = part.trim();
if (line) lines.push(line);
}
}
if (supplier.ico) lines.push(`${tObj.ico}${supplier.ico}`);
if (supplier.dic) lines.push(`${tObj.dic}${supplier.dic}`);
return { name, lines };
}
/* ── Translations ────────────────────────────────────────────────── */
type Lang = "cs" | "en";
@@ -165,23 +190,18 @@ const translations: Record<Lang, Record<string, string>> = {
title: "OBJEDNÁVKA",
heading: "OBJEDNÁVKA č.",
// PO direction: WE (company) are the buyer (Odběratel), the
// selected customer record is the supplier (Dodavatel).
// selected sklad_suppliers record is the supplier (Dodavatel).
supplier: "Dodavatel",
buyer: "Odběratel",
issue_date: "Datum vystavení:",
delivery_date: "Požadované dodání:",
billing: "Objednáváme u Vás:",
billing: "Objednáváme si u Vás:",
col_no: "Č.",
col_desc: "Popis",
col_qty: "Množství",
col_unit_price: "Jedn. cena",
col_price: "Cena",
col_vat_pct: "%DPH",
col_vat: "DPH",
col_total: "Celkem",
subtotal: "Mezisoučet:",
vat_label: "DPH",
total: "Celkem",
total_no_vat: "Celkem bez DPH",
amounts_in: "Částky jsou uvedeny v",
notes: "Poznámky",
delivery_terms: "Dodací podmínky:",
@@ -202,13 +222,8 @@ const translations: Record<Lang, Record<string, string>> = {
col_desc: "Description",
col_qty: "Quantity",
col_unit_price: "Unit price",
col_price: "Price",
col_vat_pct: "VAT%",
col_vat: "VAT",
col_total: "Total",
subtotal: "Subtotal:",
vat_label: "VAT",
total: "Total",
total_no_vat: "Total excl. VAT",
amounts_in: "Amounts are in",
notes: "Notes",
delivery_terms: "Delivery terms:",
@@ -224,12 +239,12 @@ interface IssuedOrderPdfData {
order_date: Date | null;
delivery_date: Date | null;
currency: string | null;
apply_vat: boolean | null;
vat_rate: unknown;
notes: string | null;
delivery_terms: string | null;
payment_terms: string | null;
issued_by: string | null;
// Editable heading above the items table; null → t.billing default.
order_text?: string | null;
}
interface IssuedOrderPdfItem {
@@ -238,21 +253,18 @@ interface IssuedOrderPdfItem {
quantity: unknown;
unit: string | null;
unit_price: unknown;
vat_rate: unknown;
}
export function renderIssuedOrderHtml(
order: IssuedOrderPdfData,
items: IssuedOrderPdfItem[],
customer: Record<string, unknown> | null,
supplier: Record<string, unknown> | null,
settings: Record<string, unknown> | null,
lang: Lang,
issuer: { name: string },
): string {
const t = translations[lang];
const applyVat = order.apply_vat !== false;
const currency = order.currency || "CZK";
const docRate = order.vat_rate != null ? Number(order.vat_rate) : 21;
const poNumber = escapeHtml(order.po_number || "");
// Logo embedding (same logic as the confirmation template).
@@ -268,42 +280,26 @@ export function renderIssuedOrderHtml(
}
// PO direction: our company (settings) = Odběratel (buyer);
// the customer record = Dodavatel (supplier).
// the sklad_suppliers record = Dodavatel (supplier).
const buyer = buildAddressLines(settings, true, t); // company → Odběratel
const supplier = buildAddressLines(customer, false, t); // customer → Dodavatel
const supplierAddr = buildSupplierLines(supplier, t); // supplier → Dodavatel
const buyerLinesHtml = buyer.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join("");
const supplierLinesHtml = supplier.lines
const supplierLinesHtml = supplierAddr.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join("");
// Items — NET + per-line VAT-on-top, rounded per line (same math the
// service computeIssuedOrderTotals uses; mirrors the confirmation loop).
let subtotal = 0;
let totalVat = 0;
const vatSummary: Record<string, { base: number; vat: number }> = {};
// Items — NET only. A purchase order is not a tax document: no VAT columns,
// no VAT math (same as the service computeIssuedOrderTotals).
let total = 0;
const itemsHtml = items
.map((it, i) => {
const qty = Number(it.quantity) || 0;
const unitPrice = Number(it.unit_price) || 0;
const rate =
it.vat_rate != null && it.vat_rate !== ""
? 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 lineTotal = qty * unitPrice;
total += lineTotal;
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
const descHtml = `${escapeHtml(it.description)}${
@@ -316,30 +312,12 @@ export function renderIssuedOrderHtml(
<td class="desc">${descHtml}</td>
<td class="center">${formatNum(qty, qtyDecimals)}${it.unit ? ` / ${escapeHtml(it.unit)}` : ""}</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>
</tr>`;
})
.join("");
subtotal = Math.round(subtotal * 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>`;
}
}
}
total = Math.round(total * 100) / 100;
const notesRaw = order.notes ?? "";
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
@@ -450,7 +428,7 @@ export function renderIssuedOrderHtml(
vertical-align: top;
width: 50%;
}
.header-grid td.addr-customer {
.header-grid td.addr-supplier {
background: #f5f5f5;
}
.header-grid td.details-bank {
@@ -715,7 +693,7 @@ ${indentCSS}
<div class="invoice-title">${escapeHtml(t.heading)} ${poNumber}</div>
</div>
<!-- Odberatel (nase firma) / Dodavatel (zakaznik) + Detaily -->
<!-- Odberatel (nase firma) / Dodavatel (sklad_suppliers) + Detaily -->
<table class="header-grid" cellspacing="0">
<tr>
<td>
@@ -723,9 +701,9 @@ ${indentCSS}
<div class="address-name">${escapeHtml(buyer.name)}</div>
${buyerLinesHtml}
</td>
<td class="addr-customer">
<td class="addr-supplier">
<div class="address-label">${escapeHtml(t.supplier)}</div>
<div class="address-name">${escapeHtml(supplier.name)}</div>
<div class="address-name">${escapeHtml(supplierAddr.name)}</div>
${supplierLinesHtml}
</td>
</tr>
@@ -739,18 +717,15 @@ ${indentCSS}
</table>
<!-- Polozky -->
<div class="billing-label">${escapeHtml(t.billing)}</div>
<div class="billing-label">${escapeHtml(order.order_text || t.billing)}</div>
<table class="items">
<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:56%">${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>
<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>
<th class="right" style="width:16%">${escapeHtml(t.col_total)}</th>
<th class="right" style="width:21%">${escapeHtml(t.col_total)}</th>
</tr>
</thead>
<tbody>
@@ -758,18 +733,12 @@ ${indentCSS}
</tbody>
</table>
<!-- Soucty -->
<!-- Soucty (jen souhrnny radek bez DPH - mezisoucet by ho jen opakoval) -->
<div class="totals-wrapper">
<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">
<span class="label">${escapeHtml(t.total)}</span>
<span class="value">${formatNum(totalToPay)} ${escapeHtml(currency)}</span>
<span class="label">${escapeHtml(t.total_no_vat)}</span>
<span class="value">${formatNum(total)} ${escapeHtml(currency)}</span>
</div>
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
</div>
@@ -818,9 +787,9 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
where: { issued_order_id: id },
orderBy: { position: "asc" },
});
const customer = order.customer_id
? ((await prisma.customers.findUnique({
where: { id: order.customer_id },
const supplier = order.supplier_id
? ((await prisma.sklad_suppliers.findUnique({
where: { id: order.supplier_id },
})) as Record<string, unknown> | null)
: null;
const settings = (await prisma.company_settings.findFirst()) as Record<
@@ -838,7 +807,7 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
const html = renderIssuedOrderHtml(
order,
items,
customer,
supplier,
settings,
lang,
issuer,

View File

@@ -1,5 +1,6 @@
import { FastifyInstance } from "fastify";
import { requirePermission } from "../../middleware/auth";
import prisma from "../../config/database";
import { requirePermission, requireAnyPermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, error, parseId, paginated } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
@@ -34,7 +35,7 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
order,
search,
status: query.status ? String(query.status) : undefined,
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
supplier_id: query.supplier_id ? Number(query.supplier_id) : undefined,
month: query.month ? Number(query.month) : undefined,
year: query.year ? Number(query.year) : undefined,
});
@@ -66,7 +67,7 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
const result = await getIssuedOrderTotals({
search: query.search ? String(query.search) : undefined,
status: query.status ? String(query.status) : undefined,
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
supplier_id: query.supplier_id ? Number(query.supplier_id) : undefined,
month: query.month ? Number(query.month) : undefined,
year: query.year ? Number(query.year) : undefined,
});
@@ -74,6 +75,40 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
},
);
// GET /api/admin/issued-orders/suppliers — lightweight supplier lookup for
// the PO supplier picker. Guarded by the ORDERS permissions (any of
// view/create/edit), NOT warehouse.manage: the warehouse suppliers CRUD is
// gated by warehouse.manage, which orders users typically lack — without
// this endpoint they couldn't populate the picker. Registered BEFORE "/:id"
// so the literal "suppliers" path isn't captured as an order id.
fastify.get(
"/suppliers",
{
preHandler: requireAnyPermission(
"orders.view",
"orders.create",
"orders.edit",
),
},
async (_request, reply) => {
const suppliers = await prisma.sklad_suppliers.findMany({
where: { is_active: true },
select: {
id: true,
name: true,
ico: true,
dic: true,
address: true,
email: true,
phone: true,
},
// id tiebreak so same-name suppliers sort deterministically.
orderBy: [{ name: "asc" }, { id: "asc" }],
});
return success(reply, suppliers);
},
);
// GET /api/admin/issued-orders/:id
fastify.get<{ Params: { id: string } }>(
"/:id",
@@ -95,6 +130,11 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
const parsed = parseBody(CreateIssuedOrderSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const order = await createIssuedOrder(parsed.data);
if ("error" in order) {
if (order.error === "supplier_not_found")
return error(reply, "Dodavatel nenalezen", 400);
return error(reply, "Neznámá chyba", 500);
}
await logAudit({
request,
authData: request.authData,
@@ -125,6 +165,8 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
if ("error" in result) {
if (result.error === "not_found")
return error(reply, "Objednávka nenalezena", 404);
if (result.error === "supplier_not_found")
return error(reply, "Dodavatel nenalezen", 400);
if (result.error === "invalid_transition")
return error(
reply,

View File

@@ -203,9 +203,7 @@ const TRANSLATIONS: Record<string, Record<string, string>> = {
unit_price: { EN: "Unit Price", CZ: "Jedn. cena" },
included: { EN: "Included", CZ: "Zahrnuto" },
total: { EN: "Total", CZ: "Celkem" },
subtotal: { EN: "Subtotal", CZ: "Mezisou\u010Det" },
vat: { EN: "VAT", CZ: "DPH" },
total_to_pay: { EN: "Total to pay", CZ: "Celkem k \u00FAhrad\u011B" },
total_no_vat: { EN: "Total excl. VAT", CZ: "Celkem bez DPH" },
ico: { EN: "ID", CZ: "I\u010CO" },
dic: { EN: "VAT ID", CZ: "DI\u010C" },
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" />`;
}
// Offers are NOT tax documents — the total is NET only (no VAT math).
const items = quotation.quotation_items;
let subtotal = 0;
let total = 0;
for (const item of items) {
if (item.is_included_in_total !== false) {
subtotal +=
total +=
(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;
for (const s of quotation.scope_sections) {
if ((s.content || "").trim() || (s.title || "").trim()) {
@@ -318,22 +313,10 @@ export default async function offersPdfRoutes(
</tr>`;
});
let totalsHtml = "";
if (applyVat) {
totalsHtml += `<div class="detail-rows">
<div class="row">
<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>
// No Mezisoučet/VAT rows — they would only duplicate the net total.
const totalsHtml = `<div class="grand">
<span class="label">${escapeHtml(t("total_no_vat"))}</span>
<span class="value">${formatCurrency(total, currency)}</span>
</div>`;
const quotationNumber = escapeHtml(quotation.quotation_number);

View File

@@ -20,16 +20,14 @@ const OrderPdfItemSchema = z.object({
unit: z.string().max(255),
unit_price: z.number().min(0).finite(),
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()`.
// `items` is strictly validated; `lang`/`applyVat` are typed explicitly so the
// handler no longer reads them as untyped passthrough keys.
// `items` is strictly validated; `lang` is typed explicitly so the handler no
// longer reads it as an untyped passthrough key.
const OrderPdfBodySchema = z.looseObject({
items: z.array(OrderPdfItemSchema).optional(),
lang: z.string().max(10).optional(),
applyVat: z.boolean().optional(),
});
/* ── Helpers ─────────────────────────────────────────────────────── */
@@ -193,13 +191,8 @@ const translations: Record<string, Record<string, string>> = {
col_desc: "Popis",
col_qty: "Množství",
col_unit_price: "Jedn. cena",
col_price: "Cena",
col_vat_pct: "%DPH",
col_vat: "DPH",
col_total: "Celkem",
subtotal: "Mezisoučet:",
vat_label: "DPH",
total: "Celkem",
total_no_vat: "Celkem bez DPH",
amounts_in: "Částky jsou uvedeny v",
notes: "Poznámky",
issued_by: "Vystavil:",
@@ -221,13 +214,8 @@ const translations: Record<string, Record<string, string>> = {
col_desc: "Description",
col_qty: "Quantity",
col_unit_price: "Unit price",
col_price: "Price",
col_vat_pct: "VAT%",
col_vat: "VAT",
col_total: "Total",
subtotal: "Subtotal:",
vat_label: "VAT",
total: "Total",
total_no_vat: "Total excl. VAT",
amounts_in: "Amounts are in",
notes: "Notes",
issued_by: "Issued by:",
@@ -238,207 +226,119 @@ const translations: Record<string, Record<string, string>> = {
},
};
/* ── Route ───────────────────────────────────────────────────────── */
/* ── Template ────────────────────────────────────────────────────── */
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;
export interface OrderConfirmationPdfItem {
description: string;
quantity: number;
unit: string;
unit_price: number;
is_included_in_total: boolean;
}
try {
const lang = body.lang === "en" ? "en" : "cs";
const t = translations[lang];
interface OrderConfirmationPdfData {
order_number: string | null;
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 },
// 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" } },
},
});
/**
* Order-confirmation HTML. The confirmation is NOT a tax document — prices
* are NET only: no VAT columns or VAT summary, the grand total is labeled
* "Celkem bez DPH" and the totals box carries the fixed prices-excl.-VAT
* notice. Exported for tests.
*/
export function renderOrderConfirmationHtml(
order: OrderConfirmationPdfData,
items: OrderConfirmationPdfItem[],
settings: Record<string, unknown> | null,
lang: "cs" | "en",
userName: string,
): string {
const t = translations[lang];
if (!order) {
return reply
.status(404)
.type("text/html")
.send("<html><body><h1>Objednávka nenalezena</h1></body></html>");
}
let logoImg = "";
if (settings?.logo_data) {
const buf = Buffer.from(settings.logo_data as Buffer);
let mime = "image/png";
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<
string,
unknown
> | null;
const currency = order.currency || "CZK";
let logoImg = "";
if (settings?.logo_data) {
const buf = Buffer.from(settings.logo_data as Buffer);
let mime = "image/png";
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" />`;
}
// NET-only total over the included lines — no VAT math anywhere.
let total = 0;
for (const item of items) {
if (item.is_included_in_total) total += item.quantity * item.unit_price;
}
total = Math.round(total * 100) / 100;
const currency = order.currency || "CZK";
const applyVat =
body.applyVat !== undefined ? !!body.applyVat : !!order.apply_vat;
const orderVatRate = Number(order.vat_rate) || 21;
const supp = buildAddressLines(settings, true, t);
const cust = buildAddressLines(
(order.customers as Record<string, unknown>) || null,
false,
t,
);
// 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;
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("");
let items: Array<{
description: string;
quantity: number;
unit: string;
unit_price: number;
is_included_in_total: boolean;
vat_rate: number;
}> = [];
const orderNumber = escapeHtml(order.order_number || "");
const poNumber = escapeHtml(order.customer_order_number || "");
const orderDateStr = formatDate(order.created_at);
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,
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>
const itemsHtml = items
.map((item, i) => {
const lineTotal = item.quantity * item.unit_price;
const qtyDecimals = Math.floor(item.quantity) === item.quantity ? 0 : 2;
return `<tr>
<td class="row-num">${i + 1}</td>
<td class="desc">${escapeHtml(item.description)}</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(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>
</tr>`;
})
.join("");
})
.join("");
const paymentMethod =
String((order as Record<string, unknown>).payment_method || "") ||
(lang === "cs" ? "převodem" : "Bank transfer");
const paymentMethod =
String(order.payment_method || "") ||
(lang === "cs" ? "převodem" : "Bank transfer");
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 notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
const notesHtml = notesStripped
? `
const notesRaw = order.notes ?? "";
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
const notesHtml = notesStripped
? `
<div class="invoice-notes">
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div>
<div class="invoice-notes-content">${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}</div>
</div>
`
: "";
: "";
// Quill indent CSS
let indentCSS = "";
for (let n = 1; n <= 9; n++) {
const pad = n * 3;
const liPad = n * 3 + 1.5;
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
}
// Quill indent CSS
let indentCSS = "";
for (let n = 1; n <= 9; n++) {
const pad = n * 3;
const liPad = n * 3 + 1.5;
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
}
const html = `<!DOCTYPE html>
return `<!DOCTYPE html>
<html lang="${escapeHtml(lang)}">
<head>
<meta charset="utf-8" />
@@ -686,47 +586,6 @@ export default async function ordersPdfRoutes(
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 */
.footer-row {
display: flex;
@@ -848,13 +707,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:56%">${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>
<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>
<th class="right" style="width:16%">${escapeHtml(t.col_total)}</th>
<th class="right" style="width:21%">${escapeHtml(t.col_total)}</th>
</tr>
</thead>
<tbody>
@@ -862,18 +718,12 @@ ${indentCSS}
</tbody>
</table>
<!-- Soucty -->
<!-- Soucty (jen souhrnny radek bez DPH - mezisoucet by ho jen opakoval) -->
<div class="totals-wrapper">
<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">
<span class="label">${escapeHtml(t.total)}</span>
<span class="value">${formatNum(totalToPay)} ${escapeHtml(currency)}</span>
<span class="label">${escapeHtml(t.total_no_vat)}</span>
<span class="value">${formatNum(total)} ${escapeHtml(currency)}</span>
</div>
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
</div>
@@ -900,9 +750,96 @@ ${indentCSS}
</body>
</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 filename = `Potvrzeni-${orderNumber || String(id)}.pdf`;
const filename = `Potvrzeni-${order.order_number || String(id)}.pdf`;
return reply
.type("application/pdf")

View File

@@ -15,6 +15,7 @@ import {
} from "../../schemas/received-invoices.schema";
import { nasInvoicesManager } from "../../services/nas-financials-manager";
import { toCzk } from "../../services/exchange-rates";
import { utcMidnightOfLocalDay } from "../../utils/date";
import path from "path";
const VALID_STATUSES = ["unpaid", "paid"] as const;
@@ -561,14 +562,17 @@ export default async function receivedInvoicesRoutes(
// `amount` is the GROSS total (VAT included); VAT is the portion within it.
const computedVat = vatFromGross(finalAmount, finalVatRate);
// Auto-set paid_date when status transitions to paid (matching PHP)
// Auto-set paid_date when status transitions to paid (matching PHP).
// paid_date is @db.Date (truncated to the UTC date part) —
// utcMidnightOfLocalDay keeps the LOCAL calendar day even during the
// 00:0002:00 Prague window (a bare new Date() stored yesterday there).
const newStatus =
body.status !== undefined
? String(body.status)
: String(existing.status);
const paidDate =
newStatus === "paid" && String(existing.status) !== "paid"
? new Date()
? utcMidnightOfLocalDay()
: body.paid_date !== undefined
? body.paid_date
? new Date(String(body.paid_date))

View File

@@ -1,6 +1,7 @@
import { z } from "zod";
import {
intIdFromForm,
isoDateString,
nullableDateTimeString,
nullableIntIdFromForm,
numberFromForm,
@@ -73,7 +74,10 @@ export const AttendancePunchSchema = z.object({
// NOT bare HH:MM times.
export const CreateAttendanceSchema = z.object({
user_id: intIdFromForm.optional(),
shift_date: z.string(),
// Must be a bare "YYYY-MM-DD" (parses to UTC midnight) — the service's
// duplicate-validation window and the stored @db.Date both depend on it.
// isoDateString leniently strips a trailing time component.
shift_date: isoDateString,
arrival_time: nullableDateTimeString.optional(),
arrival_lat: numberFromForm.nullish(),
arrival_lng: numberFromForm.nullish(),

View File

@@ -1,10 +1,8 @@
import { z } from "zod";
import {
numberInRange,
nonNegativeNumberFromForm,
positiveNumberFromForm,
nullableIntIdFromForm,
booleanFromForm,
isoDateString,
} from "./common";
@@ -14,7 +12,6 @@ export const IssuedOrderItemSchema = z.object({
quantity: positiveNumberFromForm.optional(),
unit: z.string().max(20).nullish(),
unit_price: nonNegativeNumberFromForm.optional(),
vat_rate: numberInRange(0, 100).optional(),
position: z.number().int().nonnegative().optional(),
});
@@ -28,11 +25,9 @@ const ISSUED_ORDER_STATUSES = [
export const CreateIssuedOrderSchema = z.object({
po_number: z.string().max(50).nullish(),
customer_id: nullableIntIdFromForm.nullish(),
supplier_id: nullableIntIdFromForm.nullish(),
status: z.enum(ISSUED_ORDER_STATUSES).optional(),
currency: z.string().max(10).optional(),
vat_rate: numberInRange(0, 100).optional(),
apply_vat: booleanFromForm.optional(),
exchange_rate: nonNegativeNumberFromForm.optional(),
order_date: isoDateString.nullish(),
delivery_date: isoDateString.nullish(),
@@ -40,6 +35,9 @@ export const CreateIssuedOrderSchema = z.object({
delivery_terms: z.string().max(500).nullish(),
payment_terms: z.string().max(500).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(),
internal_notes: z.string().nullish(),
items: z.array(IssuedOrderItemSchema).optional(),

View File

@@ -1,7 +1,6 @@
import { z } from "zod";
import {
numberFromForm,
numberInRange,
nonNegativeNumberFromForm,
positiveNumberFromForm,
nullableIntIdFromForm,
@@ -33,8 +32,6 @@ export const CreateQuotationSchema = z.object({
valid_until: z.string().max(255).nullish(),
currency: z.string().max(20).optional().default("CZK"),
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
.enum(["draft", "active", "ordered", "invalidated"])
.optional()
@@ -52,8 +49,6 @@ export const UpdateQuotationSchema = z.object({
valid_until: z.union([z.string().max(255), z.null()]).optional(),
currency: 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(),
scope_title: z.string().max(255).nullish(),
scope_description: z.string().max(8000).nullish(),

View File

@@ -1,7 +1,6 @@
import { z } from "zod";
import {
numberFromForm,
numberInRange,
nonNegativeNumberFromForm,
positiveNumberFromForm,
intIdFromForm,
@@ -42,8 +41,6 @@ export const CreateOrderSchema = z.object({
.default("prijata"),
currency: z.string().max(20).optional().default("CZK"),
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),
scope_title: z.string().max(255).nullish(),
scope_description: z.string().max(8000).nullish(),
@@ -62,8 +59,6 @@ export const UpdateOrderSchema = z.object({
scope_description: z.string().max(8000).nullish(),
notes: z.string().max(8000).nullish(),
customer_id: nullableIntIdFromForm.optional(),
vat_rate: numberInRange(0, 100).optional(),
apply_vat: booleanFromForm.optional(),
items: z.array(OrderItemSchema).optional(),
sections: z.array(OrderSectionSchema).optional(),
});

View File

@@ -189,12 +189,16 @@ export async function getStatus(userId: number) {
const y = now.getFullYear(),
m = now.getMonth(),
d = now.getDate();
const todayStart = new Date(y, m, d, 0, 0, 0);
const todayEnd = new Date(y, m, d, 23, 59, 59);
// shift_date is @db.Date: Prisma compares it by its UTC date part, so the
// range boundaries must be UTC midnights of the LOCAL calendar day. A local
// midnight (= 22:00/23:00 UTC of the previous day) shifts the whole window
// one day back. Half-open [gte, lt) ranges.
const todayStart = new Date(Date.UTC(y, m, d));
const todayEnd = new Date(Date.UTC(y, m, d + 1));
// Monthly fund range (used by query #4)
const monthStart = new Date(y, m, 1);
const monthEnd = new Date(y, m + 1, 0, 23, 59, 59);
const monthStart = new Date(Date.UTC(y, m, 1));
const monthEnd = new Date(Date.UTC(y, m + 1, 1));
// Queries 1-4 are independent of one another → run them in parallel.
const [ongoingShift, todayShiftsRaw, balance, monthRecords] =
@@ -212,7 +216,7 @@ export async function getStatus(userId: number) {
prisma.attendance.findMany({
where: {
user_id: userId,
shift_date: { gte: todayStart, lte: todayEnd },
shift_date: { gte: todayStart, lt: todayEnd },
departure_time: { not: null },
},
include: {
@@ -228,7 +232,7 @@ export async function getStatus(userId: number) {
prisma.attendance.findMany({
where: {
user_id: userId,
shift_date: { gte: monthStart, lte: monthEnd },
shift_date: { gte: monthStart, lt: monthEnd },
},
}),
]);
@@ -585,10 +589,13 @@ export async function getWorkfund(year: number) {
: bizDays;
const fund = bizDays * 8;
const fundToDate = bizDaysToDate * 8;
const monthStart = new Date(year, m, 1);
const monthEnd = new Date(year, m + 1, 0, 23, 59, 59);
// shift_date is @db.Date (compared by UTC date part) → UTC-midnight
// month boundaries, half-open range. Local midnights would double-count
// each previous month's last day.
const monthStart = new Date(Date.UTC(year, m, 1));
const monthEnd = new Date(Date.UTC(year, m + 1, 1));
const monthRecords = await prisma.attendance.findMany({
where: { shift_date: { gte: monthStart, lte: monthEnd } },
where: { shift_date: { gte: monthStart, lt: monthEnd } },
select: {
user_id: true,
shift_date: true,
@@ -846,13 +853,16 @@ export async function getPrintData(
const yr = Number(yearStr);
const mo = Number(monthNumStr);
const monthStart = new Date(yr, mo - 1, 1);
const monthEnd = new Date(yr, mo, 0, 23, 59, 59);
// shift_date is @db.Date (compared by UTC date part) → UTC-midnight month
// boundaries, half-open range (local midnights pulled in the previous
// month's last day).
const monthStart = new Date(Date.UTC(yr, mo - 1, 1));
const monthEnd = new Date(Date.UTC(yr, mo, 1));
const users = await getAttendanceUsers();
const where: Record<string, unknown> = {
shift_date: { gte: monthStart, lte: monthEnd },
shift_date: { gte: monthStart, lt: monthEnd },
};
if (filterUserId) where.user_id = filterUserId;
@@ -1043,9 +1053,12 @@ export async function listAttendance(params: ListAttendanceParams) {
where.user_id = params.userId;
}
if (params.month && params.year) {
// shift_date is @db.Date: Prisma compares by UTC date part, so the month
// boundaries must be UTC midnights. Local midnights shifted the window a
// day back (included prev-month's last day, dropped this month's last day).
where.shift_date = {
gte: new Date(params.year, params.month - 1, 1),
lt: new Date(params.year, params.month, 1),
gte: new Date(Date.UTC(params.year, params.month - 1, 1)),
lt: new Date(Date.UTC(params.year, params.month, 1)),
};
}
@@ -1527,15 +1540,25 @@ export async function createAttendance(
) {
const userId = data.user_id ?? authUserId;
const shiftDate = new Date(data.shift_date);
// shift_date arrives as "YYYY-MM-DD" → parsed as UTC midnight, and the
// @db.Date column is compared by UTC date part. The duplicate/overlap
// window must therefore be the UTC day [shiftDate, shiftDate+1) — building
// it from LOCAL getters made both bounds 22:00/23:00 UTC of the previous
// day, so the validation queried ONLY yesterday's records (same-day
// overlaps undetected, false duplicates when yesterday had records).
const startOfDay = new Date(
shiftDate.getFullYear(),
shiftDate.getMonth(),
shiftDate.getDate(),
Date.UTC(
shiftDate.getUTCFullYear(),
shiftDate.getUTCMonth(),
shiftDate.getUTCDate(),
),
);
const endOfDay = new Date(
shiftDate.getFullYear(),
shiftDate.getMonth(),
shiftDate.getDate() + 1,
Date.UTC(
shiftDate.getUTCFullYear(),
shiftDate.getUTCMonth(),
shiftDate.getUTCDate() + 1,
),
);
// Multiple work shifts per day are allowed — only block when the new

View File

@@ -1,7 +1,11 @@
import prisma from "../config/database";
import { config } from "../config/env";
import { sendMail } from "./mailer";
import { localDateCzStr, localDateStr } from "../utils/date";
import {
localDateCzStr,
localDateStr,
utcMidnightOfLocalDay,
} from "../utils/date";
import { getSystemSettings } from "./system-settings";
interface AlertInvoice {
@@ -33,17 +37,35 @@ function formatAmount(n: number | { toNumber?: () => number }): string {
});
}
/**
* Alert window [today, today+3] for the due-date queries + the local date
* strings the classifier matches against.
*
* due_date is @db.Date: Prisma truncates the filter Dates to their UTC date
* part, so the window boundaries must be UTC midnights of the LOCAL calendar
* day. A local midnight (22:00/23:00 UTC of the previous day) shifted the
* whole window a day early — the "splatnost za 3 dny" advance alert
* (due == today+3) could then never fire.
*
* Exported (with an injectable `now`) so the window math is unit-testable.
*/
export function computeAlertWindow(now: Date = new Date()) {
const today = utcMidnightOfLocalDay(now);
const todayStr = localDateStr(now);
const in3days = new Date(today);
in3days.setUTCDate(in3days.getUTCDate() + 3);
const in3daysStr = localDateStr(
new Date(now.getFullYear(), now.getMonth(), now.getDate() + 3),
);
return { today, todayStr, in3days, in3daysStr };
}
export async function checkInvoiceAlerts(): Promise<void> {
const settings = await getSystemSettings();
const alertEmail = settings.invoice_alert_email || config.email.invoiceAlert;
if (!alertEmail) return;
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayStr = localDateStr(today);
const in3days = new Date(today);
in3days.setDate(in3days.getDate() + 3);
const in3daysStr = localDateStr(in3days);
const { today, todayStr, in3days, in3daysStr } = computeAlertWindow();
// Classify a due date into an alert type/label, or null if it doesn't match.
const classify = (

View File

@@ -1,4 +1,5 @@
import prisma from "../config/database";
import { utcMidnightOfLocalDay } from "../utils/date";
import { toCzk } from "./exchange-rates";
import {
generateInvoiceNumber,
@@ -139,8 +140,12 @@ function buildInvoiceWhere(
if (status) where.status = status;
if (customer_id) where.customer_id = customer_id;
if (month && year) {
const from = new Date(year, month - 1, 1);
const to = new Date(year, month, 1);
// issue_date is @db.Date: Prisma compares by UTC date part, so the month
// boundaries must be UTC midnights. Local midnights shifted the window a
// day back — the filter included the previous month's last day and
// DROPPED invoices issued on the selected month's last day.
const from = new Date(Date.UTC(year, month - 1, 1));
const to = new Date(Date.UTC(year, month, 1));
where.issue_date = { gte: from, lt: to };
}
if (search) {
@@ -180,13 +185,18 @@ function computeInvoiceTotals(
export async function markOverdueInvoices() {
try {
// due_date is @db.Date (UTC midnight of the calendar day). Compare
// against UTC midnight of the LOCAL today — a bare new Date() has
// yesterday's UTC date during the 00:0002:00 Prague window, so the
// overdue flip lagged a day there. Due TODAY = not yet overdue.
const today = utcMidnightOfLocalDay();
await prisma.invoices.updateMany({
where: { status: "issued", due_date: { lt: new Date() } },
where: { status: "issued", due_date: { lt: today } },
data: { status: "overdue" },
});
// Reverse: if due_date was changed to future, set back to issued
// Reverse: if due_date was changed to today/future, set back to issued
await prisma.invoices.updateMany({
where: { status: "overdue", due_date: { gte: new Date() } },
where: { status: "overdue", due_date: { gte: today } },
data: { status: "issued" },
});
} catch (err) {
@@ -312,10 +322,13 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
const year = queryYear || now.getFullYear();
const month = queryMonth || now.getMonth() + 1;
const monthStart = new Date(year, month - 1, 1);
const monthEnd = new Date(year, month, 0, 23, 59, 59);
const startOfYear = new Date(year, 0, 1);
const endOfYear = new Date(year, 11, 31, 23, 59, 59);
// issue_date is @db.Date (compared by UTC date part) → UTC-midnight period
// boundaries, half-open [gte, lt) ranges. Local-midnight bounds included
// the previous period's boundary day in the stats.
const monthStart = new Date(Date.UTC(year, month - 1, 1));
const nextMonthStart = new Date(Date.UTC(year, month, 1));
const startOfYear = new Date(Date.UTC(year, 0, 1));
const startOfNextYear = new Date(Date.UTC(year + 1, 0, 1));
const [monthInvoices, awaitingInvoices, overdueInvoices] = await Promise.all([
prisma.invoices.findMany({
@@ -323,21 +336,21 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
// Drafts are not real financial documents — exclude them so their
// VAT/amounts never inflate the monthly stats (vat_month etc.).
status: { not: "draft" },
issue_date: { gte: monthStart, lte: monthEnd },
issue_date: { gte: monthStart, lt: nextMonthStart },
},
include: { invoice_items: true },
}),
prisma.invoices.findMany({
where: {
status: "issued",
issue_date: { gte: startOfYear, lte: endOfYear },
issue_date: { gte: startOfYear, lt: startOfNextYear },
},
include: { invoice_items: true },
}),
prisma.invoices.findMany({
where: {
status: "overdue",
issue_date: { gte: startOfYear, lte: endOfYear },
issue_date: { gte: startOfYear, lt: startOfNextYear },
},
include: { invoice_items: true },
}),
@@ -601,9 +614,11 @@ export async function updateInvoice(id: number, body: InvoiceInput) {
// Status change
if (body.status !== undefined) {
data.status = String(body.status);
// Auto-set paid_date when transitioning to paid
// Auto-set paid_date when transitioning to paid. paid_date is @db.Date
// (truncated to the UTC date part) — utcMidnightOfLocalDay keeps the
// LOCAL calendar day even during the 00:0002:00 Prague window.
if (String(body.status) === "paid" && !existing.paid_date) {
data.paid_date = new Date();
data.paid_date = utcMidnightOfLocalDay();
}
}

View File

@@ -1,5 +1,6 @@
import type { $Enums } from "@prisma/client";
import prisma from "../config/database";
import { utcMidnightOfLocalDay } from "../utils/date";
import {
generateIssuedOrderNumber,
previewIssuedOrderNumber,
@@ -13,17 +14,14 @@ export interface IssuedOrderItemInput {
quantity?: number | string | null;
unit?: string | null;
unit_price?: number | string | null;
vat_rate?: number | string | null;
position?: number | null;
}
export interface IssuedOrderInput {
po_number?: string | number | null;
customer_id?: number | string | null;
supplier_id?: number | string | null;
status?: string;
currency?: string;
vat_rate?: number | string | null;
apply_vat?: boolean | number | string;
exchange_rate?: number | string | null;
order_date?: string | null;
delivery_date?: string | null;
@@ -31,6 +29,7 @@ export interface IssuedOrderInput {
delivery_terms?: string | null;
payment_terms?: string | null;
issued_by?: string | null;
order_text?: string | null;
notes?: string | null;
internal_notes?: string | null;
items?: IssuedOrderItemInput[];
@@ -40,7 +39,7 @@ export interface IssuedOrderInput {
interface IssuedOrderFilterParams {
search?: string;
status?: string;
customer_id?: number;
supplier_id?: number;
month?: number;
year?: number;
}
@@ -66,20 +65,23 @@ export interface CurrencyAmount {
function buildIssuedOrderWhere(
params: IssuedOrderFilterParams,
): Record<string, unknown> {
const { search, status, customer_id, month, year } = params;
const { search, status, supplier_id, month, year } = params;
const where: Record<string, unknown> = {};
if (status) where.status = status;
if (customer_id) where.customer_id = customer_id;
if (supplier_id) where.supplier_id = supplier_id;
if (search) {
where.OR = [
{ po_number: { contains: search } },
{ customers: { name: { contains: search } } },
{ customers: { company_id: { contains: search } } },
{ suppliers: { name: { contains: search } } },
{ suppliers: { ico: { contains: search } } },
];
}
if (month && year) {
const from = new Date(year, month - 1, 1);
const to = new Date(year, month, 1);
// order_date is @db.Date: Prisma compares by UTC date part, so the month
// boundaries must be UTC midnights. Local midnights shifted the window a
// day back (included prev-month's last day, dropped this month's last day).
const from = new Date(Date.UTC(year, month - 1, 1));
const to = new Date(Date.UTC(year, month, 1));
where.order_date = { gte: from, lt: to };
}
return where;
@@ -101,32 +103,18 @@ const ALLOWED_SORT_FIELDS = [
"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(
items: Array<{ quantity: unknown; unit_price: unknown; vat_rate: unknown }>,
applyVat: boolean | null,
defaultVatRate: unknown,
items: Array<{ quantity: unknown; unit_price: unknown }>,
) {
let subtotal = 0;
let vat = 0;
let total = 0;
for (const it of items) {
const base = (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;
}
total += (Number(it.quantity) || 0) * (Number(it.unit_price) || 0);
}
return {
subtotal: Math.round(subtotal * 100) / 100,
vat_amount: Math.round(vat * 100) / 100,
total: Math.round((subtotal + vat) * 100) / 100,
};
return { total: Math.round(total * 100) / 100 };
}
export async function listIssuedOrders(params: ListIssuedOrdersParams) {
@@ -150,7 +138,7 @@ export async function listIssuedOrders(params: ListIssuedOrdersParams) {
take: limit,
orderBy,
include: {
customers: { select: { id: true, name: true } },
suppliers: { select: { id: true, name: true } },
issued_order_items: true,
},
}),
@@ -158,16 +146,12 @@ export async function listIssuedOrders(params: ListIssuedOrdersParams) {
]);
const enriched = rows.map((o) => {
const totals = computeIssuedOrderTotals(
o.issued_order_items,
o.apply_vat,
o.vat_rate,
);
const totals = computeIssuedOrderTotals(o.issued_order_items);
const { issued_order_items, ...rest } = o;
return {
...rest,
items: issued_order_items,
customer_name: o.customers?.name || null,
supplier_name: o.suppliers?.name || null,
...totals,
};
});
@@ -176,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,
* and `computeIssuedOrderTotals` so per-order math matches the list/detail.
* Currency defaults to "CZK" when the column is null. Returns one entry per
@@ -194,11 +178,7 @@ export async function getIssuedOrderTotals(
const byCurrency: Record<string, number> = {};
for (const o of rows) {
const { total } = computeIssuedOrderTotals(
o.issued_order_items,
o.apply_vat,
o.vat_rate,
);
const { total } = computeIssuedOrderTotals(o.issued_order_items);
const cur = o.currency || "CZK";
byCurrency[cur] = (byCurrency[cur] || 0) + (Number(total) || 0);
}
@@ -217,7 +197,19 @@ export async function getIssuedOrder(id: number) {
const order = await prisma.issued_orders.findUnique({
where: { id },
include: {
customers: true,
// Same minimal field set as the picker lookup endpoint — the full row
// would expose internal notes/contact to any orders.view holder.
suppliers: {
select: {
id: true,
name: true,
ico: true,
dic: true,
address: true,
email: true,
phone: true,
},
},
issued_order_items: { orderBy: { position: "asc" } },
},
});
@@ -226,8 +218,8 @@ export async function getIssuedOrder(id: number) {
return {
...rest,
items: issued_order_items,
customer: order.customers,
customer_name: order.customers?.name || null,
supplier: order.suppliers,
supplier_name: order.suppliers?.name || null,
valid_transitions: VALID_TRANSITIONS[order.status as string] || [],
};
}
@@ -236,6 +228,17 @@ export async function createIssuedOrder(body: IssuedOrderInput) {
return prisma.$transaction(async (tx) => {
const status = body.status ? String(body.status) : "draft";
// Validate the referenced supplier exists BEFORE the insert — a dangling
// FK would otherwise surface as a P2003 500 instead of a clean 400.
const supplierId = body.supplier_id ? Number(body.supplier_id) : null;
if (supplierId) {
const supplier = await tx.sklad_suppliers.findUnique({
where: { id: supplierId },
select: { id: true },
});
if (!supplier) return { error: "supplier_not_found" as const };
}
// Deferred numbering: a draft carries NO po_number (the column is
// nullable-unique so many drafts coexist). The official number is consumed
// only when the draft is finalized (assignIssuedOrderNumber on draft->sent).
@@ -255,16 +258,17 @@ export async function createIssuedOrder(body: IssuedOrderInput) {
const order = await tx.issued_orders.create({
data: {
po_number: poNumber,
customer_id: body.customer_id ? Number(body.customer_id) : null,
supplier_id: supplierId,
status: status as $Enums.issued_orders_status,
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:
body.exchange_rate != null ? Number(body.exchange_rate) : 1.0,
// order_date is @db.Date (truncated to the UTC date part) — the
// "today" default must be utcMidnightOfLocalDay, or it stores
// yesterday during the 00:0002:00 Prague window.
order_date: body.order_date
? new Date(String(body.order_date))
: new Date(),
: utcMidnightOfLocalDay(),
delivery_date: body.delivery_date
? new Date(String(body.delivery_date))
: null,
@@ -274,6 +278,7 @@ export async function createIssuedOrder(body: IssuedOrderInput) {
: null,
payment_terms: body.payment_terms ? String(body.payment_terms) : 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,
internal_notes: body.internal_notes
? String(body.internal_notes)
@@ -290,7 +295,6 @@ export async function createIssuedOrder(body: IssuedOrderInput) {
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
vat_rate: item.vat_rate ?? 21.0,
position: item.position ?? i,
})),
});
@@ -324,18 +328,23 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
"delivery_terms",
"payment_terms",
"issued_by",
"order_text",
];
for (const f of strFields) {
if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
}
if (body.customer_id !== undefined)
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 (body.supplier_id !== undefined) {
const supplierId = body.supplier_id ? Number(body.supplier_id) : null;
if (supplierId) {
// Same dangling-FK guard as create: 400, not a P2003 500.
const supplier = await prisma.sklad_suppliers.findUnique({
where: { id: supplierId },
select: { id: true },
});
if (!supplier) return { error: "supplier_not_found" as const };
}
data.supplier_id = supplierId;
}
if (body.exchange_rate !== undefined)
data.exchange_rate =
body.exchange_rate != null ? Number(body.exchange_rate) : null;
@@ -388,7 +397,6 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
vat_rate: item.vat_rate ?? 21.0,
position: item.position ?? i,
})),
});

View File

@@ -77,28 +77,22 @@ function buildOfferWhere(params: OfferFilterParams): Record<string, unknown> {
return where;
}
// Offers are NOT tax documents — totals are NET only (no VAT anywhere).
function enrichQuotation(q: any) {
const subtotal = q.quotation_items
const total = q.quotation_items
.filter((i: any) => i.is_included_in_total !== false)
.reduce(
(s: number, i: any) =>
s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 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;
return {
...rest,
items: quotation_items,
sections: scope_sections,
customer_name: q.customers?.name || null,
subtotal: Math.round(subtotal * 100) / 100,
vat_amount: Math.round(vatAmount * 100) / 100,
total: Math.round((subtotal + vatAmount) * 100) / 100,
total: Math.round(total * 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
* `enrichQuotation` so the per-offer math matches the list/detail exactly.
* 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,
currency: body.currency ? String(body.currency) : "CZK",
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,
scope_title: body.scope_title ? String(body.scope_title) : null,
scope_description: body.scope_description
@@ -351,13 +343,6 @@ export async function updateOffer(id: number, body: Record<string, any>) {
: undefined,
currency: body.currency !== undefined ? String(body.currency) : 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,
project_code:
body.project_code !== undefined
@@ -479,8 +464,6 @@ export async function duplicateOffer(id: number) {
valid_until: null,
currency: original.currency,
language: original.language,
vat_rate: original.vat_rate,
apply_vat: original.apply_vat,
status: "active",
scope_title: original.scope_title,
scope_description: original.scope_description,

View File

@@ -72,23 +72,20 @@ async function syncProjectStatus(
});
}
// ⚠ Also called by getOrderTotals with a MINIMAL select (currency, apply_vat,
// vat_rate, item quantity/unit_price/is_included_in_total). If you read a NEW
// order/item field here, add it to that select — a field missing from the
// select is silently 0 in the per-currency totals.
// ⚠ Also called by getOrderTotals with a MINIMAL select (currency, item
// quantity/unit_price/is_included_in_total). If you read a NEW order/item
// field here, add it to that select — a field missing from the select is
// silently 0 in the per-currency totals.
//
// Orders are NOT tax documents — totals are NET only (no VAT anywhere).
function enrichOrder(o: any) {
const subtotal = o.order_items
const total = o.order_items
.filter((i: any) => i.is_included_in_total !== false)
.reduce(
(s: number, i: any) =>
s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 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 invoice = o.invoices?.[0] || null;
return {
@@ -100,9 +97,7 @@ function enrichOrder(o: any) {
project_code: o.quotations?.project_code || null,
invoice_id: invoice?.id || null,
invoice_number: invoice?.invoice_number || null,
subtotal: Math.round(subtotal * 100) / 100,
vat_amount: Math.round(vatAmount * 100) / 100,
total: Math.round((subtotal + vatAmount) * 100) / 100,
total: Math.round(total * 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
* `enrichOrder` so the per-order math matches the list/detail exactly. Returns
* one entry per currency, rounded to 2dp, zero/empty totals dropped.
@@ -202,15 +197,13 @@ export async function getOrderTotals(
): Promise<{ totals: CurrencyAmount[] }> {
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,
// sections HTML or relations here would multiply the query size for nothing.
const orders = await prisma.orders.findMany({
where,
select: {
currency: true,
apply_vat: true,
vat_rate: true,
order_items: {
select: {
quantity: true,
@@ -335,8 +328,6 @@ export async function createOrderFromQuotation(
status: "prijata",
currency: quotation.currency || "CZK",
language: quotation.language || "cs",
vat_rate: quotation.vat_rate ?? 21.0,
apply_vat: quotation.apply_vat ?? true,
scope_title: quotation.scope_title,
scope_description: quotation.scope_description,
attachment_data: attachmentBuffer
@@ -428,8 +419,6 @@ interface CreateOrderData {
status: string;
currency: string;
language: string;
vat_rate: number;
apply_vat?: boolean;
exchange_rate?: number;
scope_title?: string | null;
scope_description?: string | null;
@@ -469,8 +458,6 @@ export async function createOrder(
status: body.status,
currency: body.currency,
language: body.language,
vat_rate: body.vat_rate,
apply_vat: body.apply_vat !== false,
exchange_rate: body.exchange_rate,
scope_title: body.scope_title ?? null,
scope_description: body.scope_description ?? null,
@@ -629,10 +616,6 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
}
if (body.customer_id !== undefined)
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 (currentStatus !== "prijata" && currentStatus !== "v_realizaci") {

View File

@@ -8,6 +8,22 @@
* of JSON serialization (e.g., building lookup keys, shift_date strings).
*/
/**
* UTC midnight of the LOCAL calendar day of `d` (default: now).
*
* Prisma truncates a JS Date used against a `@db.Date` column (filter or
* write) to its **UTC** date part. With TZ=Europe/Prague, a local-midnight
* Date — `new Date(y, m, d)` — is 22:00/23:00 UTC of the PREVIOUS day, so it
* filters/stores as the previous calendar date; and a bare `new Date()`
* written to a `@db.Date` column stores yesterday during the 00:0002:00
* Prague window. Always build `@db.Date` values and range boundaries from
* `Date.UTC(...)` of the LOCAL calendar components — this helper does exactly
* that for "today" (or any reference Date).
*/
export function utcMidnightOfLocalDay(d: Date = new Date()): Date {
return new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
}
/** YYYY-MM-DD in local time */
export function localDateStr(d: Date): string {
const y = d.getFullYear();