Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
719d1f2659 | ||
|
|
3787cc4dd0 | ||
|
|
1ee59b54bc |
@@ -0,0 +1,98 @@
|
|||||||
|
# Per-item Sleva (% discount) — Offers + Invoices
|
||||||
|
|
||||||
|
**Date:** 2026-06-13
|
||||||
|
**Status:** Approved → implementing
|
||||||
|
**Scope:** Add a per-line-item percentage discount ("Sleva") to **offers**
|
||||||
|
(nabídky) and **issued invoices** (Faktury), on both the detail-page item
|
||||||
|
editor and the PDF. Received invoices, issued orders and orders are NOT
|
||||||
|
touched.
|
||||||
|
|
||||||
|
## Decisions (from brainstorming)
|
||||||
|
|
||||||
|
- **Type:** percentage per item (0–100), not an absolute amount.
|
||||||
|
- **Totals/VAT:** the discount reduces the line **net**; on invoices VAT is
|
||||||
|
computed on the **discounted** net. `net = qty × unit_price × (1 − discount/100)`.
|
||||||
|
- **PDF column is conditional:** render the Sleva column only when at least one
|
||||||
|
(included) item has `discount > 0`; otherwise the table is identical to today.
|
||||||
|
- **Detail-page column is always visible** (it's the input).
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
Add to `quotation_items` and `invoice_items`:
|
||||||
|
|
||||||
|
```
|
||||||
|
discount Decimal? @default(0.00) @db.Decimal(5, 2) // percent, 0–100
|
||||||
|
```
|
||||||
|
|
||||||
|
Nullable with default `0`, matching the existing money columns. "No sleva" =
|
||||||
|
`0` or null. One tracked migration; apply to dev `app` and `app_test`, then
|
||||||
|
`prisma generate`.
|
||||||
|
|
||||||
|
## Line math (single rule)
|
||||||
|
|
||||||
|
`lineNet = qty × unit_price × (1 − clamp(discount,0,100)/100)`
|
||||||
|
|
||||||
|
Add a shared backend helper so every site is identical:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/utils/money.ts (new)
|
||||||
|
export const lineNet = (qty: number, unitPrice: number, discountPct: number) =>
|
||||||
|
qty * unitPrice * (1 - (discountPct || 0) / 100);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
- **Schemas (Zod):** add `discount` to the offer-item and invoice-item schemas
|
||||||
|
via `numberInRange(0, 100)` (shared coercer in `common.ts`), optional, default
|
||||||
|
`0`.
|
||||||
|
- **offers.service** (`enrichQuotation`): line total uses `lineNet`; document
|
||||||
|
net ("Celkem bez DPH") sums discounted included lines. Persist `discount` on
|
||||||
|
item create/update.
|
||||||
|
- **invoices.service**: every `computeMoney` `getBase` callback
|
||||||
|
(`computeInvoiceTotals`, monthly-stats `invoiceMonthlyAmount`, per-rate
|
||||||
|
`vatMap`) returns `lineNet(...)` so subtotal **and** VAT use the discounted
|
||||||
|
net. Persist `discount` on item create/update.
|
||||||
|
- **DocumentItemsEditor's `documentItemsTotal`** (frontend) mirrors the same
|
||||||
|
discounted net.
|
||||||
|
|
||||||
|
## Frontend (Section 1 — detail-page editor)
|
||||||
|
|
||||||
|
- **Offers** → shared `src/admin/components/document/DocumentItemsEditor.tsx`:
|
||||||
|
`DocumentItem` gains `discount`; add a **"Sleva %"** numeric column (0–100);
|
||||||
|
per-line total and the footer net total apply it. Mobile card layout gets the
|
||||||
|
field. `emptyDocumentItem` defaults `discount: 0`. Hydration + save payloads
|
||||||
|
carry `discount`.
|
||||||
|
- **Invoices** → `src/admin/pages/InvoiceDetail.tsx` own editor
|
||||||
|
(`SortableInvoiceRow` + create/edit rows): `InvoiceItem` gains `discount`;
|
||||||
|
same column; net/VAT/gross totals apply it. Mobile layout too.
|
||||||
|
|
||||||
|
## PDF (Section 2 — conditional column)
|
||||||
|
|
||||||
|
`src/routes/admin/offers-pdf.ts` and `src/routes/admin/invoices-pdf.ts`:
|
||||||
|
|
||||||
|
- `const hasDiscount = items.some(i => includedInTotal(i) && Number(i.discount) > 0)`.
|
||||||
|
- When `hasDiscount`: add a column — header **"Sleva"** (cs) / **"Discount"**
|
||||||
|
(en, from the document's `language`), cell renders `"<n> %"` (escaped); adjust
|
||||||
|
colspans of any full-width rows accordingly.
|
||||||
|
- Line net / document total / VAT always use the discounted net regardless of
|
||||||
|
`hasDiscount`.
|
||||||
|
- `escapeHtml` every interpolation (string-built PDF templates).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `offer-invoice-totals` / invoice totals: net, VAT and gross with discounts
|
||||||
|
(incl. a 100% line = 0, and VAT-on-discounted-net).
|
||||||
|
- Schema: `discount` rejects <0 and >100, coerces form strings, defaults 0.
|
||||||
|
- PDF: column present when an item has a discount; absent when none; values
|
||||||
|
escaped.
|
||||||
|
|
||||||
|
## Non-goals (YAGNI)
|
||||||
|
|
||||||
|
- No separate "total discount" summary line, no document-level discount.
|
||||||
|
- No changes to received invoices, issued orders, or orders.
|
||||||
|
|
||||||
|
## Migration etiquette
|
||||||
|
|
||||||
|
DB change → user stops the dev server first; create the migration via the
|
||||||
|
non-interactive `prisma migrate diff` recipe, `migrate deploy` + `generate` on
|
||||||
|
dev and `app_test`, commit schema + migration folder together.
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.34",
|
"version": "2.4.36",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.34",
|
"version": "2.4.36",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.102.0",
|
"@anthropic-ai/sdk": "^0.102.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.34",
|
"version": "2.4.36",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `invoice_items` ADD COLUMN `discount` DECIMAL(5, 2) NULL DEFAULT 0.00;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `quotation_items` ADD COLUMN `discount` DECIMAL(5, 2) NULL DEFAULT 0.00;
|
||||||
|
|
||||||
@@ -208,6 +208,7 @@ model invoice_items {
|
|||||||
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
|
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
|
||||||
unit String? @db.VarChar(20)
|
unit String? @db.VarChar(20)
|
||||||
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
|
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
|
||||||
|
discount Decimal? @default(0.00) @db.Decimal(5, 2)
|
||||||
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
||||||
position Int? @default(0)
|
position Int? @default(0)
|
||||||
invoices invoices @relation(fields: [invoice_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "invoice_items_ibfk_1")
|
invoices invoices @relation(fields: [invoice_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "invoice_items_ibfk_1")
|
||||||
@@ -505,6 +506,7 @@ model quotation_items {
|
|||||||
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
|
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
|
||||||
unit String? @db.VarChar(20)
|
unit String? @db.VarChar(20)
|
||||||
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
|
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
|
||||||
|
discount Decimal? @default(0.00) @db.Decimal(5, 2)
|
||||||
is_included_in_total Boolean? @default(true)
|
is_included_in_total Boolean? @default(true)
|
||||||
modified_at DateTime? @db.DateTime(0)
|
modified_at DateTime? @db.DateTime(0)
|
||||||
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "quotation_items_ibfk_1")
|
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "quotation_items_ibfk_1")
|
||||||
|
|||||||
374
src/__tests__/__snapshots__/attendance-print-html.test.ts.snap
Normal file
374
src/__tests__/__snapshots__/attendance-print-html.test.ts.snap
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`buildUserSectionHtml > produces a stable per-user section (full output pinned) 1`] = `
|
||||||
|
"<div class="user-section">
|
||||||
|
<div class="user-header">
|
||||||
|
<h3>Jan Novák</h3>
|
||||||
|
<span class="total">Odpracováno: 8:00 h</span>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead><tr>
|
||||||
|
<th style="width:55px">Datum</th>
|
||||||
|
<th style="width:55px">Den</th>
|
||||||
|
<th style="width:60px">Typ</th>
|
||||||
|
<th class="text-center" style="width:55px">Příchod</th>
|
||||||
|
<th class="text-center" style="width:80px">Pauza</th>
|
||||||
|
<th class="text-center" style="width:55px">Odchod</th>
|
||||||
|
<th class="text-center" style="width:85px">Hodiny</th>
|
||||||
|
<th>Projekty</th>
|
||||||
|
<th>Poznámka</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody><tr class="weekend">
|
||||||
|
<td>1. 3. 2098</td>
|
||||||
|
<td>Sobota</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr><tr class="weekend">
|
||||||
|
<td>2. 3. 2098</td>
|
||||||
|
<td>Neděle</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr><tr class="">
|
||||||
|
<td>3. 3. 2098</td>
|
||||||
|
<td>Pondělí</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr><tr class="">
|
||||||
|
<td>4. 3. 2098</td>
|
||||||
|
<td>Úterý</td>
|
||||||
|
<td>Práce</td>
|
||||||
|
<td class="text-center">08:00</td>
|
||||||
|
<td class="text-center">12:00 - 12:30</td>
|
||||||
|
<td class="text-center">16:30</td>
|
||||||
|
<td class="text-center">8:00 (8,00)</td>
|
||||||
|
<td style="font-size:8px"><div>Alpha (8:00h)</div></td>
|
||||||
|
<td>Poznámka <b></td>
|
||||||
|
</tr><tr class="vacation">
|
||||||
|
<td>5. 3. 2098</td>
|
||||||
|
<td>Středa</td>
|
||||||
|
<td>Dovolená</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">8,00</td>
|
||||||
|
<td style="font-size:8px">—</td>
|
||||||
|
<td></td>
|
||||||
|
</tr><tr class="">
|
||||||
|
<td>6. 3. 2098</td>
|
||||||
|
<td>Čtvrtek</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr><tr class="">
|
||||||
|
<td>7. 3. 2098</td>
|
||||||
|
<td>Pátek</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr><tr class="weekend">
|
||||||
|
<td>8. 3. 2098</td>
|
||||||
|
<td>Sobota</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr><tr class="weekend">
|
||||||
|
<td>9. 3. 2098</td>
|
||||||
|
<td>Neděle</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr><tr class="">
|
||||||
|
<td>10. 3. 2098</td>
|
||||||
|
<td>Pondělí</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr><tr class="">
|
||||||
|
<td>11. 3. 2098</td>
|
||||||
|
<td>Úterý</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr><tr class="">
|
||||||
|
<td>12. 3. 2098</td>
|
||||||
|
<td>Středa</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr><tr class="">
|
||||||
|
<td>13. 3. 2098</td>
|
||||||
|
<td>Čtvrtek</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr><tr class="">
|
||||||
|
<td>14. 3. 2098</td>
|
||||||
|
<td>Pátek</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr><tr class="weekend">
|
||||||
|
<td>15. 3. 2098</td>
|
||||||
|
<td>Sobota</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr><tr class="weekend">
|
||||||
|
<td>16. 3. 2098</td>
|
||||||
|
<td>Neděle</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr><tr class="">
|
||||||
|
<td>17. 3. 2098</td>
|
||||||
|
<td>Pondělí</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr><tr class="">
|
||||||
|
<td>18. 3. 2098</td>
|
||||||
|
<td>Úterý</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr><tr class="">
|
||||||
|
<td>19. 3. 2098</td>
|
||||||
|
<td>Středa</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr><tr class="">
|
||||||
|
<td>20. 3. 2098</td>
|
||||||
|
<td>Čtvrtek</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr><tr class="">
|
||||||
|
<td>21. 3. 2098</td>
|
||||||
|
<td>Pátek</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr><tr class="weekend">
|
||||||
|
<td>22. 3. 2098</td>
|
||||||
|
<td>Sobota</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr><tr class="weekend">
|
||||||
|
<td>23. 3. 2098</td>
|
||||||
|
<td>Neděle</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr><tr class="">
|
||||||
|
<td>24. 3. 2098</td>
|
||||||
|
<td>Pondělí</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr><tr class="">
|
||||||
|
<td>25. 3. 2098</td>
|
||||||
|
<td>Úterý</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr><tr class="">
|
||||||
|
<td>26. 3. 2098</td>
|
||||||
|
<td>Středa</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr><tr class="">
|
||||||
|
<td>27. 3. 2098</td>
|
||||||
|
<td>Čtvrtek</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr><tr class="">
|
||||||
|
<td>28. 3. 2098</td>
|
||||||
|
<td>Pátek</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr><tr class="weekend">
|
||||||
|
<td>29. 3. 2098</td>
|
||||||
|
<td>Sobota</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr><tr class="weekend">
|
||||||
|
<td>30. 3. 2098</td>
|
||||||
|
<td>Neděle</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr><tr class="">
|
||||||
|
<td>31. 3. 2098</td>
|
||||||
|
<td>Pondělí</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="user-summary">
|
||||||
|
<div class="summary-row">
|
||||||
|
<span class="summary-label">Odpracováno:</span>
|
||||||
|
<span class="summary-value">8,00h</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row">
|
||||||
|
<span class="summary-label">Odpracováno včetně svátků a přesčasů:</span>
|
||||||
|
<span class="summary-value">16,00h</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row">
|
||||||
|
<span class="summary-label">Dovolená:</span>
|
||||||
|
<span class="summary-value">8,00h</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row">
|
||||||
|
<span class="summary-label">Přesčas:</span>
|
||||||
|
<span class="summary-value">8,00h</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row">
|
||||||
|
<span class="summary-label">Svátek:</span>
|
||||||
|
<span class="summary-value">0,00h</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row">
|
||||||
|
<span class="summary-label">So/Ne:</span>
|
||||||
|
<span class="summary-value">0,00h</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row">
|
||||||
|
<span class="summary-label">Práce v noci:</span>
|
||||||
|
<span class="summary-value">0,00h</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row summary-fund">
|
||||||
|
<span class="summary-label">Fond měsíce:</span>
|
||||||
|
<span class="summary-value">168,00h (21 dnů)</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend">
|
||||||
|
<span class="legend-item"><span class="legend-swatch weekend"></span>Víkend (So/Ne)</span>
|
||||||
|
<span class="legend-item"><span class="legend-swatch holiday"></span>Svátek</span>
|
||||||
|
<span class="legend-item"><span class="legend-swatch weekend-holiday"></span>Víkend + svátek</span>
|
||||||
|
<span class="legend-item"><span class="legend-swatch vacation"></span>Dovolená</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>"
|
||||||
|
`;
|
||||||
155
src/__tests__/attendance-print-html.test.ts
Normal file
155
src/__tests__/attendance-print-html.test.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
buildProjectLogsHtml,
|
||||||
|
buildUserSectionHtml,
|
||||||
|
buildPrintHtml,
|
||||||
|
} from "../admin/hooks/useAttendanceAdmin";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Characterization test for the attendance print-HTML builders.
|
||||||
|
*
|
||||||
|
* These builders had `any`-typed params; this pins their CURRENT output so the
|
||||||
|
* follow-up retyping (precise AttendanceRecord/UserTotal/PrintData types) is
|
||||||
|
* provably behavior-preserving. The only runtime touch in that retype is
|
||||||
|
* `parseInt(log.hours)` → `parseInt(String(log.hours))` to satisfy the typed
|
||||||
|
* `string | number | null` shape — the cases below exercise both string and
|
||||||
|
* numeric hours/minutes precisely so that equivalence is locked in.
|
||||||
|
*
|
||||||
|
* Fixtures are cast through `Parameters<typeof fn>` so the test compiles both
|
||||||
|
* before and after the param types tighten.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type RecordArg = Parameters<typeof buildProjectLogsHtml>[0];
|
||||||
|
type UserDataArg = Parameters<typeof buildUserSectionHtml>[1];
|
||||||
|
type PrintDataArg = Parameters<typeof buildUserSectionHtml>[2];
|
||||||
|
const asRecord = (o: unknown) => o as unknown as RecordArg;
|
||||||
|
const asUserData = (o: unknown) => o as unknown as UserDataArg;
|
||||||
|
const asPrintData = (o: unknown) => o as unknown as PrintDataArg;
|
||||||
|
|
||||||
|
describe("buildProjectLogsHtml", () => {
|
||||||
|
it("formats hours/minutes given as strings", () => {
|
||||||
|
expect(
|
||||||
|
buildProjectLogsHtml(
|
||||||
|
asRecord({
|
||||||
|
project_logs: [
|
||||||
|
{ project_id: 1, project_name: "Alpha", hours: "2", minutes: "30" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe("<div>Alpha (2:30h)</div>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats hours/minutes given as numbers (parseInt-of-number path)", () => {
|
||||||
|
expect(
|
||||||
|
buildProjectLogsHtml(
|
||||||
|
asRecord({
|
||||||
|
project_logs: [
|
||||||
|
{ project_id: 2, project_name: "Beta", hours: 1, minutes: 5 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe("<div>Beta (1:05h)</div>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives duration from started_at/ended_at when hours are absent", () => {
|
||||||
|
expect(
|
||||||
|
buildProjectLogsHtml(
|
||||||
|
asRecord({
|
||||||
|
project_logs: [
|
||||||
|
{
|
||||||
|
project_id: 3,
|
||||||
|
project_name: "Gamma",
|
||||||
|
started_at: "2098-01-05T08:00:00",
|
||||||
|
ended_at: "2098-01-05T10:30:00",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe("<div>Gamma (2:30h)</div>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to '#id' and 0:00 when name and times are missing", () => {
|
||||||
|
expect(
|
||||||
|
buildProjectLogsHtml(asRecord({ project_logs: [{ project_id: 7 }] })),
|
||||||
|
).toBe("<div>#7 (0:00h)</div>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the bare project name when there are no logs", () => {
|
||||||
|
expect(
|
||||||
|
buildProjectLogsHtml(
|
||||||
|
asRecord({ project_name: "Solo & <Co>", project_logs: [] }),
|
||||||
|
),
|
||||||
|
).toBe("Solo & <Co>");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deterministic fixture: a fixed far-future month, one work day (with a numeric
|
||||||
|
// project log so the snapshot also guards the parseInt path in context) and one
|
||||||
|
// vacation day.
|
||||||
|
const workRecord = {
|
||||||
|
id: 1,
|
||||||
|
user_id: 5,
|
||||||
|
shift_date: "2098-03-04",
|
||||||
|
leave_type: "work",
|
||||||
|
arrival_time: "2098-03-04T08:00:00",
|
||||||
|
departure_time: "2098-03-04T16:30:00",
|
||||||
|
break_start: "2098-03-04T12:00:00",
|
||||||
|
break_end: "2098-03-04T12:30:00",
|
||||||
|
notes: "Poznámka <b>",
|
||||||
|
project_logs: [
|
||||||
|
{ project_id: 1, project_name: "Alpha", hours: 8, minutes: 0 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const leaveRecord = {
|
||||||
|
id: 2,
|
||||||
|
user_id: 5,
|
||||||
|
shift_date: "2098-03-05",
|
||||||
|
leave_type: "vacation",
|
||||||
|
leave_hours: 8,
|
||||||
|
};
|
||||||
|
const userData = {
|
||||||
|
name: "Jan Novák",
|
||||||
|
minutes: 480,
|
||||||
|
records: [workRecord, leaveRecord],
|
||||||
|
};
|
||||||
|
const printData = {
|
||||||
|
month: "2098-03",
|
||||||
|
month_name: "Březen 2098",
|
||||||
|
selected_user_name: null,
|
||||||
|
user_totals: { "5": userData },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("buildUserSectionHtml", () => {
|
||||||
|
it("produces a stable per-user section (full output pinned)", () => {
|
||||||
|
const html = buildUserSectionHtml(
|
||||||
|
"5",
|
||||||
|
asUserData(userData),
|
||||||
|
asPrintData(printData),
|
||||||
|
);
|
||||||
|
expect(html).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildPrintHtml", () => {
|
||||||
|
// NB: the document footer embeds `new Date()`, so assert stable fragments
|
||||||
|
// rather than snapshotting the whole (non-deterministic) output.
|
||||||
|
it("wraps the user sections in a stable document shell", () => {
|
||||||
|
const section = buildUserSectionHtml(
|
||||||
|
"5",
|
||||||
|
asUserData(userData),
|
||||||
|
asPrintData(printData),
|
||||||
|
);
|
||||||
|
const html = buildPrintHtml(
|
||||||
|
asPrintData(printData),
|
||||||
|
section,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"Firma s.r.o.",
|
||||||
|
);
|
||||||
|
expect(html).toContain("<title>Docházka - Březen 2098</title>");
|
||||||
|
expect(html).toContain("EVIDENCE DOCHÁZKY");
|
||||||
|
expect(html).toContain('<div class="period">Březen 2098</div>');
|
||||||
|
expect(html).toContain("Firma s.r.o.");
|
||||||
|
expect(html).toContain(section);
|
||||||
|
});
|
||||||
|
});
|
||||||
130
src/__tests__/item-discount-pdf.test.ts
Normal file
130
src/__tests__/item-discount-pdf.test.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { config } from "../config/env";
|
||||||
|
import offersPdfRoutes from "../routes/admin/offers-pdf";
|
||||||
|
import invoicesPdfRoutes from "../routes/admin/invoices-pdf";
|
||||||
|
import { createInvoice } from "../services/invoices.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The per-item Sleva column is CONDITIONAL on the PDF: render it only when at
|
||||||
|
* least one item carries a discount > 0; otherwise the table looks exactly as
|
||||||
|
* before. The discounted net always flows into the line/grand totals.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let app: ReturnType<typeof Fastify> | null = null;
|
||||||
|
let adminToken = "";
|
||||||
|
const quotationIds: number[] = [];
|
||||||
|
const invoiceIds: number[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
await app.register(offersPdfRoutes, { prefix: "/api/admin/offers-pdf" });
|
||||||
|
await app.register(invoicesPdfRoutes, { prefix: "/api/admin/invoices-pdf" });
|
||||||
|
const admin = await prisma.users.findFirst({
|
||||||
|
where: { roles: { name: "admin" } },
|
||||||
|
});
|
||||||
|
if (!admin) throw new Error("Test setup: admin user not found");
|
||||||
|
adminToken = jwt.sign(
|
||||||
|
{ sub: admin.id, username: admin.username, role: "admin" },
|
||||||
|
config.jwt.secret,
|
||||||
|
{ expiresIn: "15m" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const id of quotationIds)
|
||||||
|
await prisma.quotations.deleteMany({ where: { id } });
|
||||||
|
for (const id of invoiceIds)
|
||||||
|
await prisma.invoices.deleteMany({ where: { id } });
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const offerHtml = async (discount: number): Promise<string> => {
|
||||||
|
const q = await prisma.quotations.create({
|
||||||
|
data: {
|
||||||
|
quotation_number: `Q-SLEVA-${discount}-${Date.now()}`,
|
||||||
|
status: "active",
|
||||||
|
currency: "CZK",
|
||||||
|
language: "cs",
|
||||||
|
quotation_items: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
description: "Položka",
|
||||||
|
quantity: 2,
|
||||||
|
unit: "ks",
|
||||||
|
unit_price: 100,
|
||||||
|
discount,
|
||||||
|
is_included_in_total: true,
|
||||||
|
position: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
quotationIds.push(q.id);
|
||||||
|
const res = await app!.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/admin/offers-pdf/${q.id}`,
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
return res.body;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("offer PDF Sleva column", () => {
|
||||||
|
it("renders a Sleva column + discounted total when an item has a discount", async () => {
|
||||||
|
const html = await offerHtml(10);
|
||||||
|
expect(html).toContain("Sleva");
|
||||||
|
// 2 × 100 with 10% off => 180,00 (no thousands separator at this size).
|
||||||
|
expect(html).toContain("180,00");
|
||||||
|
expect(html).not.toContain("200,00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the Sleva column when no item has a discount", async () => {
|
||||||
|
const html = await offerHtml(0);
|
||||||
|
expect(html).not.toContain("Sleva");
|
||||||
|
expect(html).toContain("200,00");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const invoiceHtml = async (discount: number): Promise<string> => {
|
||||||
|
const inv = await createInvoice({
|
||||||
|
status: "draft",
|
||||||
|
currency: "CZK",
|
||||||
|
apply_vat: true,
|
||||||
|
vat_rate: 21,
|
||||||
|
language: "cs",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
description: "Položka",
|
||||||
|
quantity: 2,
|
||||||
|
unit_price: 1000,
|
||||||
|
discount,
|
||||||
|
vat_rate: 21,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
invoiceIds.push(inv.id);
|
||||||
|
const res = await app!.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/admin/invoices-pdf/${inv.id}`,
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
return res.body;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("invoice PDF Sleva column", () => {
|
||||||
|
it("renders a Sleva column when an item has a discount", async () => {
|
||||||
|
const html = await invoiceHtml(10);
|
||||||
|
expect(html).toContain("Sleva");
|
||||||
|
expect(html).toContain("10");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the Sleva column when no item has a discount", async () => {
|
||||||
|
const html = await invoiceHtml(0);
|
||||||
|
expect(html).not.toContain("Sleva");
|
||||||
|
});
|
||||||
|
});
|
||||||
55
src/__tests__/item-discount-schema.test.ts
Normal file
55
src/__tests__/item-discount-schema.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { CreateQuotationSchema } from "../schemas/offers.schema";
|
||||||
|
import { CreateInvoiceSchema } from "../schemas/invoices.schema";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The per-item Sleva is a percentage: the offer- and invoice-item schemas must
|
||||||
|
* accept 0–100, default to 0 when absent, and reject out-of-range values.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("offer item discount schema", () => {
|
||||||
|
it("accepts a 0–100 discount", () => {
|
||||||
|
const r = CreateQuotationSchema.safeParse({
|
||||||
|
items: [{ description: "X", quantity: 1, unit_price: 100, discount: 15 }],
|
||||||
|
});
|
||||||
|
expect(r.success).toBe(true);
|
||||||
|
expect(r.success && r.data.items?.[0].discount).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults discount to 0 when absent", () => {
|
||||||
|
const r = CreateQuotationSchema.safeParse({
|
||||||
|
items: [{ description: "X", quantity: 1, unit_price: 100 }],
|
||||||
|
});
|
||||||
|
expect(r.success && r.data.items?.[0].discount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a discount above 100", () => {
|
||||||
|
const r = CreateQuotationSchema.safeParse({
|
||||||
|
items: [
|
||||||
|
{ description: "X", quantity: 1, unit_price: 100, discount: 150 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(r.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("invoice item discount schema", () => {
|
||||||
|
it("accepts a 0–100 discount and defaults to 0", () => {
|
||||||
|
const ok = CreateInvoiceSchema.safeParse({
|
||||||
|
items: [{ description: "X", quantity: 1, unit_price: 100, discount: 10 }],
|
||||||
|
});
|
||||||
|
expect(ok.success && ok.data.items?.[0].discount).toBe(10);
|
||||||
|
|
||||||
|
const def = CreateInvoiceSchema.safeParse({
|
||||||
|
items: [{ description: "X", quantity: 1, unit_price: 100 }],
|
||||||
|
});
|
||||||
|
expect(def.success && def.data.items?.[0].discount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a negative discount", () => {
|
||||||
|
const r = CreateInvoiceSchema.safeParse({
|
||||||
|
items: [{ description: "X", quantity: 1, unit_price: 100, discount: -5 }],
|
||||||
|
});
|
||||||
|
expect(r.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
94
src/__tests__/item-discount-totals.test.ts
Normal file
94
src/__tests__/item-discount-totals.test.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { createOffer, getOfferTotals } from "../services/offers.service";
|
||||||
|
import {
|
||||||
|
createInvoice,
|
||||||
|
getInvoiceListTotals,
|
||||||
|
} from "../services/invoices.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-item Sleva (% discount) must reduce the line NET — and on invoices the
|
||||||
|
* VAT is computed on the discounted net. Hits the real `app_test` DB through the
|
||||||
|
* service layer (suite convention), mirroring offer-invoice-totals.test.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MARKER = "SLEVA-TOTALS-2096";
|
||||||
|
const YEAR = 2096;
|
||||||
|
const MONTH = 8; // invoice issue_date window [2096-08-01, 2096-09-01)
|
||||||
|
const dateStr = `${YEAR}-0${MONTH}-15`;
|
||||||
|
|
||||||
|
const offerIds: number[] = [];
|
||||||
|
const invoiceIds: number[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const id of offerIds)
|
||||||
|
await prisma.quotations.deleteMany({ where: { id } });
|
||||||
|
for (const id of invoiceIds)
|
||||||
|
await prisma.invoices.deleteMany({ where: { id } });
|
||||||
|
offerIds.length = 0;
|
||||||
|
invoiceIds.length = 0;
|
||||||
|
await prisma.number_sequences.deleteMany({
|
||||||
|
where: { type: { in: ["offer", "invoice"] } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const amountFor = (
|
||||||
|
totals: { currency: string; amount: number }[],
|
||||||
|
currency: string,
|
||||||
|
): number | undefined => totals.find((t) => t.currency === currency)?.amount;
|
||||||
|
|
||||||
|
describe("offer per-item discount", () => {
|
||||||
|
it("reduces the offer NET total by the per-item percentage", async () => {
|
||||||
|
// 2 × 1000 with 10% off => 1800 net.
|
||||||
|
const res = await createOffer({
|
||||||
|
status: "draft",
|
||||||
|
currency: "CZK",
|
||||||
|
project_code: MARKER,
|
||||||
|
items: [
|
||||||
|
{ description: "X", quantity: 2, unit_price: 1000, discount: 10 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if ("error" in res) throw new Error(JSON.stringify(res));
|
||||||
|
offerIds.push(res.id);
|
||||||
|
|
||||||
|
const { totals } = await getOfferTotals({ search: MARKER });
|
||||||
|
expect(amountFor(totals, "CZK")).toBe(1800);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("invoice per-item discount", () => {
|
||||||
|
it("computes VAT on the discounted net (gross = discounted net + VAT)", async () => {
|
||||||
|
// 2 × 1000 with 10% off => net 1800, VAT@21% = 378, gross 2178.
|
||||||
|
const inv = await createInvoice({
|
||||||
|
status: "draft",
|
||||||
|
currency: "CZK",
|
||||||
|
vat_rate: 21,
|
||||||
|
apply_vat: true,
|
||||||
|
issue_date: dateStr,
|
||||||
|
items: [
|
||||||
|
{ description: "X", quantity: 2, unit_price: 1000, discount: 10 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
invoiceIds.push(inv.id);
|
||||||
|
|
||||||
|
const { totals } = await getInvoiceListTotals({ month: MONTH, year: YEAR });
|
||||||
|
expect(amountFor(totals, "CZK")).toBe(2178);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a 100% discount line as zero", async () => {
|
||||||
|
const inv = await createInvoice({
|
||||||
|
status: "draft",
|
||||||
|
currency: "CZK",
|
||||||
|
vat_rate: 21,
|
||||||
|
apply_vat: true,
|
||||||
|
issue_date: dateStr,
|
||||||
|
items: [
|
||||||
|
{ description: "Free", quantity: 5, unit_price: 200, discount: 100 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
invoiceIds.push(inv.id);
|
||||||
|
|
||||||
|
const { totals } = await getInvoiceListTotals({ month: MONTH, year: YEAR });
|
||||||
|
expect(amountFor(totals, "CZK")).toBeUndefined(); // zero totals are dropped
|
||||||
|
});
|
||||||
|
});
|
||||||
117
src/__tests__/modal-duplicate-close.test.ts
Normal file
117
src/__tests__/modal-duplicate-close.test.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { readdirSync, readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guards against the "two Zavřít buttons" bug class.
|
||||||
|
*
|
||||||
|
* The shared <Modal> (src/admin/ui/Modal.tsx) ALWAYS renders a primary submit
|
||||||
|
* button (text = `submitText`) and — unless `hideCancel` is set — a secondary
|
||||||
|
* cancel button (text = `cancelText`, default "Zrušit"). A close-only modal
|
||||||
|
* therefore sets `submitText="Zavřít"`; if it ALSO leaves the cancel button on,
|
||||||
|
* the user sees two buttons that both just close the dialog — and when
|
||||||
|
* `cancelText` is "Zavřít" too, two literally-identical "Zavřít" buttons
|
||||||
|
* (the production bug found on the "paid received invoice" detail modal).
|
||||||
|
*
|
||||||
|
* Rule enforced: any <Modal> whose `submitText` is a "Zavřít…" label (i.e. its
|
||||||
|
* only action is to close) MUST pass `hideCancel`. Close-only modals get a
|
||||||
|
* single button. See PlanCellModal / PlanCategoriesModal for the correct shape.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ADMIN_DIR = join(__dirname, "..", "admin");
|
||||||
|
|
||||||
|
function tsxFiles(dir: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) out.push(...tsxFiles(full));
|
||||||
|
else if (entry.name.endsWith(".tsx")) out.push(full);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract every `<Modal …>` opening-tag substring from a source string.
|
||||||
|
* Brace- and string-aware so arrow functions (`=>`) and conditional props
|
||||||
|
* (`{cond ? "a" : "b"}`) inside the tag don't terminate it early.
|
||||||
|
*/
|
||||||
|
function modalOpeningTags(src: string): string[] {
|
||||||
|
const tags: string[] = [];
|
||||||
|
let i = 0;
|
||||||
|
while ((i = src.indexOf("<Modal", i)) !== -1) {
|
||||||
|
const after = src[i + 6];
|
||||||
|
// Must be the <Modal> component, not <ModalSomething>.
|
||||||
|
if (after && !/[\s>/]/.test(after)) {
|
||||||
|
i += 6;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let depth = 0;
|
||||||
|
let quote: string | null = null;
|
||||||
|
let j = i + 6;
|
||||||
|
for (; j < src.length; j++) {
|
||||||
|
const c = src[j];
|
||||||
|
if (quote) {
|
||||||
|
if (c === quote && src[j - 1] !== "\\") quote = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === '"' || c === "'" || c === "`") quote = c;
|
||||||
|
else if (c === "{") depth++;
|
||||||
|
else if (c === "}") depth--;
|
||||||
|
else if (c === ">" && depth === 0) break;
|
||||||
|
}
|
||||||
|
tags.push(src.slice(i, j + 1));
|
||||||
|
i = j + 1;
|
||||||
|
}
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Value of a JSX prop: `name="..."`, `name={...}` (balanced), or boolean. */
|
||||||
|
function propValue(tag: string, name: string): string | null {
|
||||||
|
const re = new RegExp(`\\b${name}\\b`);
|
||||||
|
const m = re.exec(tag);
|
||||||
|
if (!m) return null;
|
||||||
|
let k = m.index + m[0].length;
|
||||||
|
while (k < tag.length && /\s/.test(tag[k])) k++;
|
||||||
|
if (tag[k] !== "=") return ""; // boolean prop (e.g. `hideCancel`)
|
||||||
|
k++;
|
||||||
|
while (k < tag.length && /\s/.test(tag[k])) k++;
|
||||||
|
if (tag[k] === '"' || tag[k] === "'") {
|
||||||
|
const q = tag[k];
|
||||||
|
const end = tag.indexOf(q, k + 1);
|
||||||
|
return tag.slice(k + 1, end);
|
||||||
|
}
|
||||||
|
if (tag[k] === "{") {
|
||||||
|
let depth = 0;
|
||||||
|
for (let p = k; p < tag.length; p++) {
|
||||||
|
if (tag[p] === "{") depth++;
|
||||||
|
else if (tag[p] === "}" && --depth === 0) return tag.slice(k, p + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Modal close-only buttons", () => {
|
||||||
|
const offenders: string[] = [];
|
||||||
|
|
||||||
|
for (const file of tsxFiles(ADMIN_DIR)) {
|
||||||
|
const src = readFileSync(file, "utf8");
|
||||||
|
for (const tag of modalOpeningTags(src)) {
|
||||||
|
const submit = propValue(tag, "submitText");
|
||||||
|
const isCloseOnly = submit != null && submit.includes("Zavřít");
|
||||||
|
const hasHideCancel = propValue(tag, "hideCancel") != null;
|
||||||
|
if (isCloseOnly && !hasHideCancel) {
|
||||||
|
const title = propValue(tag, "title") ?? "(no title)";
|
||||||
|
offenders.push(`${file.replace(ADMIN_DIR, "admin")} — ${title}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it("close-only modals (submitText='Zavřít') set hideCancel — no duplicate close button", () => {
|
||||||
|
expect(
|
||||||
|
offenders,
|
||||||
|
`These <Modal>s render a close-only 'Zavřít' submit alongside a redundant ` +
|
||||||
|
`cancel button (two buttons that both just close). Add hideCancel:\n` +
|
||||||
|
offenders.map((o) => ` • ${o}`).join("\n"),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
22
src/__tests__/money.test.ts
Normal file
22
src/__tests__/money.test.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { lineNet } from "../utils/money";
|
||||||
|
|
||||||
|
describe("lineNet (per-item discounted net)", () => {
|
||||||
|
it("returns qty × unit_price when there is no discount", () => {
|
||||||
|
expect(lineNet(2, 1000, 0)).toBe(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies a percentage discount to the line net", () => {
|
||||||
|
expect(lineNet(2, 1000, 10)).toBe(1800);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a 100% discount as zero", () => {
|
||||||
|
expect(lineNet(3, 500, 100)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats null/undefined/NaN discount as no discount", () => {
|
||||||
|
expect(lineNet(2, 1000, null)).toBe(2000);
|
||||||
|
expect(lineNet(2, 1000, undefined)).toBe(2000);
|
||||||
|
expect(lineNet(2, 1000, NaN)).toBe(2000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -488,6 +488,7 @@ function DayInRangeModal(
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSubmit={onClose}
|
onSubmit={onClose}
|
||||||
submitText="Zavřít — ponechat rozsah beze změny"
|
submitText="Zavřít — ponechat rozsah beze změny"
|
||||||
|
hideCancel
|
||||||
maxWidth="md"
|
maxWidth="md"
|
||||||
>
|
>
|
||||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||||
|
|||||||
@@ -491,9 +491,11 @@ export default function PlanGrid({
|
|||||||
}, [projects]);
|
}, [projects]);
|
||||||
// Memoize the day list so it isn't rebuilt on every render (only when the
|
// Memoize the day list so it isn't rebuilt on every render (only when the
|
||||||
// visible range changes). Stable identity also keeps the row map cheap.
|
// visible range changes). Stable identity also keeps the row map cheap.
|
||||||
|
const dateFrom = data?.date_from;
|
||||||
|
const dateTo = data?.date_to;
|
||||||
const days = useMemo(
|
const days = useMemo(
|
||||||
() => (data ? eachDay(data.date_from, data.date_to) : []),
|
() => (dateFrom && dateTo ? eachDay(dateFrom, dateTo) : []),
|
||||||
[data?.date_from, data?.date_to],
|
[dateFrom, dateTo],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!data) return <LoadingState />;
|
if (!data) return <LoadingState />;
|
||||||
|
|||||||
@@ -19,46 +19,22 @@ import {
|
|||||||
} from "../utils/attendanceHelpers";
|
} from "../utils/attendanceHelpers";
|
||||||
|
|
||||||
// ---------- Shared types ----------
|
// ---------- Shared types ----------
|
||||||
|
// Definitions live in the dependency-free ./shiftFormTypes module; re-exported
|
||||||
export interface ShiftFormData {
|
// here so existing importers of ShiftFormModal keep working.
|
||||||
user_id: string;
|
export type {
|
||||||
shift_date: string;
|
ShiftFormData,
|
||||||
leave_type: string;
|
ProjectLog,
|
||||||
leave_hours: number;
|
Project,
|
||||||
arrival_date: string;
|
User,
|
||||||
arrival_time: string;
|
EditingRecord,
|
||||||
break_start_date: string;
|
} from "./shiftFormTypes";
|
||||||
break_start_time: string;
|
import type {
|
||||||
break_end_date: string;
|
ShiftFormData,
|
||||||
break_end_time: string;
|
ProjectLog,
|
||||||
departure_date: string;
|
Project,
|
||||||
departure_time: string;
|
User,
|
||||||
notes: string;
|
EditingRecord,
|
||||||
}
|
} from "./shiftFormTypes";
|
||||||
|
|
||||||
export interface ProjectLog {
|
|
||||||
_key?: string;
|
|
||||||
id?: number;
|
|
||||||
project_id: string | number;
|
|
||||||
hours: string | number;
|
|
||||||
minutes: string | number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Project {
|
|
||||||
id: number | string;
|
|
||||||
project_number: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface User {
|
|
||||||
id: number | string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EditingRecord {
|
|
||||||
user_name: string;
|
|
||||||
shift_date: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Sub-component props ----------
|
// ---------- Sub-component props ----------
|
||||||
|
|
||||||
|
|||||||
@@ -49,10 +49,31 @@ export interface DocumentItem {
|
|||||||
quantity: string | number;
|
quantity: string | number;
|
||||||
unit: string;
|
unit: string;
|
||||||
unit_price: string | number;
|
unit_price: string | number;
|
||||||
|
/** Per-item discount as a percentage (0–100). Offers only; 0 elsewhere. */
|
||||||
|
discount: string | number;
|
||||||
/** Offers only — undefined (issued orders) counts as included. */
|
/** Offers only — undefined (issued orders) counts as included. */
|
||||||
is_included_in_total?: boolean;
|
is_included_in_total?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discount input styling: centered, and with the native number spinners hidden
|
||||||
|
* so a 3-digit value ("100") isn't clipped under the up/down arrows.
|
||||||
|
*/
|
||||||
|
const discountInputSx = {
|
||||||
|
"& input": { textAlign: "center" },
|
||||||
|
"& input[type=number]": { MozAppearance: "textfield" as const },
|
||||||
|
"& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button": {
|
||||||
|
WebkitAppearance: "none",
|
||||||
|
margin: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** NET of one line after its per-item percentage discount. */
|
||||||
|
const discountedLineNet = (item: DocumentItem): number =>
|
||||||
|
(Number(item.quantity) || 0) *
|
||||||
|
(Number(item.unit_price) || 0) *
|
||||||
|
(1 - (Number(item.discount) || 0) / 100);
|
||||||
|
|
||||||
let documentItemKeyCounter = 0;
|
let documentItemKeyCounter = 0;
|
||||||
|
|
||||||
/** Unique client-side key for a (new or hydrated) document item row. */
|
/** Unique client-side key for a (new or hydrated) document item row. */
|
||||||
@@ -66,6 +87,7 @@ export const emptyDocumentItem = (): DocumentItem => ({
|
|||||||
quantity: 1,
|
quantity: 1,
|
||||||
unit: "ks",
|
unit: "ks",
|
||||||
unit_price: 0,
|
unit_price: 0,
|
||||||
|
discount: 0,
|
||||||
is_included_in_total: true,
|
is_included_in_total: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,9 +95,7 @@ export const emptyDocumentItem = (): DocumentItem => ({
|
|||||||
export const documentItemsTotal = (items: DocumentItem[]): number =>
|
export const documentItemsTotal = (items: DocumentItem[]): number =>
|
||||||
items.reduce(
|
items.reduce(
|
||||||
(sum, it) =>
|
(sum, it) =>
|
||||||
it.is_included_in_total === false
|
it.is_included_in_total === false ? sum : sum + discountedLineNet(it),
|
||||||
? sum
|
|
||||||
: sum + (Number(it.quantity) || 0) * (Number(it.unit_price) || 0),
|
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -111,6 +131,7 @@ function SortableItemRow({
|
|||||||
readOnly,
|
readOnly,
|
||||||
canDelete,
|
canDelete,
|
||||||
showIncludedInTotal,
|
showIncludedInTotal,
|
||||||
|
showDiscount,
|
||||||
itemDescriptionMaxLength,
|
itemDescriptionMaxLength,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onRemove,
|
onRemove,
|
||||||
@@ -121,6 +142,7 @@ function SortableItemRow({
|
|||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
showIncludedInTotal: boolean;
|
showIncludedInTotal: boolean;
|
||||||
|
showDiscount: boolean;
|
||||||
itemDescriptionMaxLength: number;
|
itemDescriptionMaxLength: number;
|
||||||
onUpdate: (field: keyof DocumentItem, value: string | boolean) => void;
|
onUpdate: (field: keyof DocumentItem, value: string | boolean) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
@@ -143,8 +165,7 @@ function SortableItemRow({
|
|||||||
position: "relative" as const,
|
position: "relative" as const,
|
||||||
zIndex: isDragging ? 10 : undefined,
|
zIndex: isDragging ? 10 : undefined,
|
||||||
};
|
};
|
||||||
const lineTotal =
|
const lineTotal = discountedLineNet(item);
|
||||||
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
@@ -253,6 +274,17 @@ function SortableItemRow({
|
|||||||
InputProps={{ readOnly }}
|
InputProps={{ readOnly }}
|
||||||
sx={{ "& input": { textAlign: "right" } }}
|
sx={{ "& input": { textAlign: "right" } }}
|
||||||
/>
|
/>
|
||||||
|
{showDiscount && (
|
||||||
|
<TextField
|
||||||
|
label="Sleva %"
|
||||||
|
type="number"
|
||||||
|
value={item.discount ?? ""}
|
||||||
|
onChange={(e) => onUpdate("discount", e.target.value)}
|
||||||
|
slotProps={{ htmlInput: { min: "0", max: "100", step: "any" } }}
|
||||||
|
InputProps={{ readOnly }}
|
||||||
|
sx={discountInputSx}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -396,6 +428,18 @@ function SortableItemRow({
|
|||||||
sx={{ "& input": { textAlign: "right" } }}
|
sx={{ "& input": { textAlign: "right" } }}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
{showDiscount && (
|
||||||
|
<TableCell>
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
value={item.discount ?? ""}
|
||||||
|
onChange={(e) => onUpdate("discount", e.target.value)}
|
||||||
|
slotProps={{ htmlInput: { min: "0", max: "100", step: "any" } }}
|
||||||
|
InputProps={{ readOnly }}
|
||||||
|
sx={discountInputSx}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
{showIncludedInTotal && (
|
{showIncludedInTotal && (
|
||||||
<TableCell align="center">
|
<TableCell align="center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -442,6 +486,8 @@ interface DocumentItemsEditorProps {
|
|||||||
error?: string;
|
error?: string;
|
||||||
/** Render the offers-only "V ceně" (is_included_in_total) column. */
|
/** Render the offers-only "V ceně" (is_included_in_total) column. */
|
||||||
showIncludedInTotal?: boolean;
|
showIncludedInTotal?: boolean;
|
||||||
|
/** Render the per-item "Sleva %" discount column (offers only). */
|
||||||
|
showDiscount?: boolean;
|
||||||
/** Schema cap for the detailed description (offers 8000, issued orders 5000). */
|
/** Schema cap for the detailed description (offers 8000, issued orders 5000). */
|
||||||
itemDescriptionMaxLength?: number;
|
itemDescriptionMaxLength?: number;
|
||||||
/** Optional page-specific control (e.g. item-template Select) next to the add button. */
|
/** Optional page-specific control (e.g. item-template Select) next to the add button. */
|
||||||
@@ -460,6 +506,7 @@ export default function DocumentItemsEditor({
|
|||||||
readOnly,
|
readOnly,
|
||||||
error,
|
error,
|
||||||
showIncludedInTotal = false,
|
showIncludedInTotal = false,
|
||||||
|
showDiscount = false,
|
||||||
itemDescriptionMaxLength = 5000,
|
itemDescriptionMaxLength = 5000,
|
||||||
templatesSlot,
|
templatesSlot,
|
||||||
}: DocumentItemsEditorProps) {
|
}: DocumentItemsEditorProps) {
|
||||||
@@ -563,6 +610,7 @@ export default function DocumentItemsEditor({
|
|||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
canDelete={items.length > 1}
|
canDelete={items.length > 1}
|
||||||
showIncludedInTotal={showIncludedInTotal}
|
showIncludedInTotal={showIncludedInTotal}
|
||||||
|
showDiscount={showDiscount}
|
||||||
itemDescriptionMaxLength={itemDescriptionMaxLength}
|
itemDescriptionMaxLength={itemDescriptionMaxLength}
|
||||||
onUpdate={(field, value) => updateItem(index, field, value)}
|
onUpdate={(field, value) => updateItem(index, field, value)}
|
||||||
onRemove={() => removeItem(index)}
|
onRemove={() => removeItem(index)}
|
||||||
@@ -594,6 +642,11 @@ export default function DocumentItemsEditor({
|
|||||||
<TableCell sx={{ width: "8rem" }} align="center">
|
<TableCell sx={{ width: "8rem" }} align="center">
|
||||||
Jedn. cena
|
Jedn. cena
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
{showDiscount && (
|
||||||
|
<TableCell sx={{ width: "7rem" }} align="center">
|
||||||
|
Sleva %
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
{showIncludedInTotal && (
|
{showIncludedInTotal && (
|
||||||
<TableCell sx={{ width: "4rem" }} align="center">
|
<TableCell sx={{ width: "4rem" }} align="center">
|
||||||
V ceně
|
V ceně
|
||||||
@@ -615,6 +668,7 @@ export default function DocumentItemsEditor({
|
|||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
canDelete={items.length > 1}
|
canDelete={items.length > 1}
|
||||||
showIncludedInTotal={showIncludedInTotal}
|
showIncludedInTotal={showIncludedInTotal}
|
||||||
|
showDiscount={showDiscount}
|
||||||
itemDescriptionMaxLength={itemDescriptionMaxLength}
|
itemDescriptionMaxLength={itemDescriptionMaxLength}
|
||||||
onUpdate={(field, value) =>
|
onUpdate={(field, value) =>
|
||||||
updateItem(index, field, value)
|
updateItem(index, field, value)
|
||||||
|
|||||||
44
src/admin/components/shiftFormTypes.ts
Normal file
44
src/admin/components/shiftFormTypes.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// Shared attendance shift-form types, kept in a dependency-free module so they
|
||||||
|
// can be imported without pulling in the ShiftFormModal component graph (which
|
||||||
|
// reaches MUI/import.meta files that don't compile under the CommonJS test
|
||||||
|
// project). ShiftFormModal re-exports these for backward compatibility.
|
||||||
|
|
||||||
|
export interface ShiftFormData {
|
||||||
|
user_id: string;
|
||||||
|
shift_date: string;
|
||||||
|
leave_type: string;
|
||||||
|
leave_hours: number;
|
||||||
|
arrival_date: string;
|
||||||
|
arrival_time: string;
|
||||||
|
break_start_date: string;
|
||||||
|
break_start_time: string;
|
||||||
|
break_end_date: string;
|
||||||
|
break_end_time: string;
|
||||||
|
departure_date: string;
|
||||||
|
departure_time: string;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectLog {
|
||||||
|
_key?: string;
|
||||||
|
id?: number;
|
||||||
|
project_id: string | number;
|
||||||
|
hours: string | number;
|
||||||
|
minutes: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
id: number | string;
|
||||||
|
project_number: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number | string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditingRecord {
|
||||||
|
user_name: string;
|
||||||
|
shift_date: string;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import { Select } from "../../ui";
|
import { Select } from "../../ui";
|
||||||
@@ -26,7 +26,7 @@ export default function ReservationPicker({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const reservations = result?.data ?? [];
|
const reservations = useMemo(() => result?.data ?? [], [result?.data]);
|
||||||
|
|
||||||
// When item/project change, the loaded reservation list changes. If the
|
// When item/project change, the loaded reservation list changes. If the
|
||||||
// currently-selected reservation is no longer available, clear it and notify
|
// currently-selected reservation is no longer available, clear it and notify
|
||||||
|
|||||||
@@ -43,9 +43,10 @@ export function AlertProvider({ children }: { children: ReactNode }) {
|
|||||||
const timeoutsRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
|
const timeoutsRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const timeouts = timeoutsRef.current;
|
||||||
return () => {
|
return () => {
|
||||||
timeoutsRef.current.forEach(clearTimeout);
|
timeouts.forEach(clearTimeout);
|
||||||
timeoutsRef.current.clear();
|
timeouts.clear();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// silentRefresh and setAccessTokenFn reference each other; listing
|
||||||
|
// silentRefresh here would recreate both callbacks on every render. The
|
||||||
|
// scheduled silentRefresh is safe to call as a stable closure because all
|
||||||
|
// of its state lives in refs.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import type {
|
|||||||
ProjectLog,
|
ProjectLog,
|
||||||
Project,
|
Project,
|
||||||
User,
|
User,
|
||||||
} from "../components/ShiftFormModal";
|
} from "../components/shiftFormTypes";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -344,14 +344,25 @@ function escapeHtml(str: string): string {
|
|||||||
.replace(/'/g, "'");
|
.replace(/'/g, "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildProjectLogsHtml(record: Record<string, any>): string {
|
/** Shape of the print endpoint payload consumed by the HTML builders. */
|
||||||
|
interface PrintData {
|
||||||
|
month: string;
|
||||||
|
month_name: string;
|
||||||
|
selected_user_name?: string | null;
|
||||||
|
user_totals: Record<string, UserTotal>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildProjectLogsHtml(record: AttendanceRecord): string {
|
||||||
if (record.project_logs && record.project_logs.length > 0) {
|
if (record.project_logs && record.project_logs.length > 0) {
|
||||||
return record.project_logs
|
return record.project_logs
|
||||||
.map((log: any) => {
|
.map((log) => {
|
||||||
let h: number, m: number;
|
let h: number, m: number;
|
||||||
if (log.hours !== null && log.hours !== undefined) {
|
if (log.hours !== null && log.hours !== undefined) {
|
||||||
h = parseInt(log.hours) || 0;
|
// hours/minutes arrive as string or number — String() before parseInt
|
||||||
m = parseInt(log.minutes) || 0;
|
// (parseInt already coerces to string internally, so this is a no-op
|
||||||
|
// at runtime, it just satisfies parseInt's string parameter type).
|
||||||
|
h = parseInt(String(log.hours)) || 0;
|
||||||
|
m = parseInt(String(log.minutes)) || 0;
|
||||||
} else if (log.started_at && log.ended_at) {
|
} else if (log.started_at && log.ended_at) {
|
||||||
const mins = Math.max(
|
const mins = Math.max(
|
||||||
0,
|
0,
|
||||||
@@ -374,13 +385,13 @@ function buildProjectLogsHtml(record: Record<string, any>): string {
|
|||||||
return escapeHtml(record.project_name || "—");
|
return escapeHtml(record.project_name || "—");
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUserSectionHtml(
|
export function buildUserSectionHtml(
|
||||||
userId: string,
|
userId: string,
|
||||||
userData: Record<string, any>,
|
userData: UserTotal,
|
||||||
printData: Record<string, any>,
|
printData: PrintData,
|
||||||
): string {
|
): string {
|
||||||
// Build a date-keyed lookup of the user's records.
|
// Build a date-keyed lookup of the user's records.
|
||||||
const recordsByDate = new Map<string, Record<string, any>>();
|
const recordsByDate = new Map<string, AttendanceRecord>();
|
||||||
for (const r of userData.records || []) {
|
for (const r of userData.records || []) {
|
||||||
recordsByDate.set(normalizeDateStr(r.shift_date), r);
|
recordsByDate.set(normalizeDateStr(r.shift_date), r);
|
||||||
}
|
}
|
||||||
@@ -391,7 +402,7 @@ function buildUserSectionHtml(
|
|||||||
const mo = parseInt(monthStr, 10);
|
const mo = parseInt(monthStr, 10);
|
||||||
const daysInMonth = getDaysInMonth(yr, mo);
|
const daysInMonth = getDaysInMonth(yr, mo);
|
||||||
|
|
||||||
const userRecords = (userData.records || []) as Record<string, any>[];
|
const userRecords = userData.records ?? [];
|
||||||
|
|
||||||
const rows: string[] = [];
|
const rows: string[] = [];
|
||||||
for (let d = 1; d <= daysInMonth; d++) {
|
for (let d = 1; d <= daysInMonth; d++) {
|
||||||
@@ -588,8 +599,8 @@ function buildUserSectionHtml(
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPrintHtml(
|
export function buildPrintHtml(
|
||||||
pData: Record<string, any>,
|
pData: PrintData,
|
||||||
userSections: string,
|
userSections: string,
|
||||||
emptyMsg: string,
|
emptyMsg: string,
|
||||||
filterNote: string,
|
filterNote: string,
|
||||||
@@ -1330,7 +1341,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
|||||||
const pData = result.data;
|
const pData = result.data;
|
||||||
const userSections = Object.entries(pData.user_totals)
|
const userSections = Object.entries(pData.user_totals)
|
||||||
.map(([uid, uData]) =>
|
.map(([uid, uData]) =>
|
||||||
buildUserSectionHtml(uid, uData as Record<string, any>, pData),
|
buildUserSectionHtml(uid, uData as UserTotal, pData),
|
||||||
)
|
)
|
||||||
.join("");
|
.join("");
|
||||||
const emptyMsg =
|
const emptyMsg =
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
// Snapshot the currently-cached grid for a given key. Returns
|
// Snapshot the currently-cached grid for a given key. Returns
|
||||||
// `undefined` if there's nothing cached (we don't patch empty data).
|
// `undefined` if there's nothing cached (we don't patch empty data).
|
||||||
function snapshotGrid(key: readonly unknown[]): GridData | undefined {
|
function snapshotGrid(key: readonly unknown[]): GridData | undefined {
|
||||||
return qc.getQueryData<GridData>(key as any);
|
return qc.getQueryData<GridData>(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply a per-date cell patch to the cached grid at `key`. Returns the
|
// Apply a per-date cell patch to the cached grid at `key`. Returns the
|
||||||
@@ -312,7 +312,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
// `ctx` may be `undefined` (e.g. if onMutate itself threw) — guarded below.
|
// `ctx` may be `undefined` (e.g. if onMutate itself threw) — guarded below.
|
||||||
function rollbackCells(ctx: CellRollback | undefined) {
|
function rollbackCells(ctx: CellRollback | undefined) {
|
||||||
if (!ctx || !ctx.rolled) return;
|
if (!ctx || !ctx.rolled) return;
|
||||||
const prev = qc.getQueryData<GridData>(ctx.key as any);
|
const prev = qc.getQueryData<GridData>(ctx.key);
|
||||||
if (!prev) return;
|
if (!prev) return;
|
||||||
const userPrev = prev.cells[ctx.userId] ?? {};
|
const userPrev = prev.cells[ctx.userId] ?? {};
|
||||||
const userNext = { ...userPrev };
|
const userNext = { ...userPrev };
|
||||||
@@ -379,7 +379,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
// Find the user that owns this entry in the current grid by
|
// Find the user that owns this entry in the current grid by
|
||||||
// looking for any cell with entryId === id (we already know
|
// looking for any cell with entryId === id (we already know
|
||||||
// the id from vars; it doesn't change across the update).
|
// the id from vars; it doesn't change across the update).
|
||||||
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
const grid = qc.getQueryData<GridData>(currentGridKey);
|
||||||
let ownerUserId: number | null = null;
|
let ownerUserId: number | null = null;
|
||||||
if (grid) {
|
if (grid) {
|
||||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||||
@@ -430,7 +430,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
// For delete we need to know the entry's user_id and full range.
|
// For delete we need to know the entry's user_id and full range.
|
||||||
// Look it up from the current grid: find the user that has a cell
|
// Look it up from the current grid: find the user that has a cell
|
||||||
// with entryId === id, and read rangeFrom/rangeTo from that cell.
|
// with entryId === id, and read rangeFrom/rangeTo from that cell.
|
||||||
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
const grid = qc.getQueryData<GridData>(currentGridKey);
|
||||||
if (!grid) return null;
|
if (!grid) return null;
|
||||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||||
let entryRange: { from: string; to: string } | null = null;
|
let entryRange: { from: string; to: string } | null = null;
|
||||||
@@ -511,7 +511,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
onMutate: (vars): CellRollback => {
|
onMutate: (vars): CellRollback => {
|
||||||
// Find the user/date for this overrideId in the current grid, then
|
// Find the user/date for this overrideId in the current grid, then
|
||||||
// patch that single cell with the new values.
|
// patch that single cell with the new values.
|
||||||
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
const grid = qc.getQueryData<GridData>(currentGridKey);
|
||||||
if (!grid) return null;
|
if (!grid) return null;
|
||||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||||
for (const [date, cells] of Object.entries(byDate)) {
|
for (const [date, cells] of Object.entries(byDate)) {
|
||||||
@@ -559,7 +559,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
}),
|
}),
|
||||||
onMutate: (vars): CellRollback => {
|
onMutate: (vars): CellRollback => {
|
||||||
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
const grid = qc.getQueryData<GridData>(currentGridKey);
|
||||||
if (!grid) return null;
|
if (!grid) return null;
|
||||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||||
for (const [date, cells] of Object.entries(byDate)) {
|
for (const [date, cells] of Object.entries(byDate)) {
|
||||||
@@ -600,7 +600,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
// PlanWork.tsx so they continue to compile unchanged; by the time a
|
// PlanWork.tsx so they continue to compile unchanged; by the time a
|
||||||
// `mutateAsync` promise rejects, the mutation's own `onError` has already
|
// `mutateAsync` promise rejects, the mutation's own `onError` has already
|
||||||
// restored the patched cells, so there is nothing left to do here.
|
// restored the patched cells, so there is nothing left to do here.
|
||||||
|
|
||||||
function rollbackMutation(_mutation: unknown, _userId: number) {
|
function rollbackMutation(_mutation: unknown, _userId: number) {
|
||||||
/* no-op — see onMutate/onError above */
|
/* no-op — see onMutate/onError above */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export interface InvoiceItem {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
unit: string;
|
unit: string;
|
||||||
unit_price: number;
|
unit_price: number;
|
||||||
|
discount?: number;
|
||||||
vat_rate?: number;
|
vat_rate?: number;
|
||||||
is_included_in_total: boolean;
|
is_included_in_total: boolean;
|
||||||
position?: number;
|
position?: number;
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ export interface OfferItemData {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
unit: string;
|
unit: string;
|
||||||
unit_price: number;
|
unit_price: number;
|
||||||
|
discount?: number;
|
||||||
is_included_in_total: boolean;
|
is_included_in_total: boolean;
|
||||||
position?: number;
|
position?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ export default function AttendanceHistory() {
|
|||||||
const { data, isPending } = useQuery(
|
const { data, isPending } = useQuery(
|
||||||
attendanceHistoryOptions({ month, userId: user?.id }),
|
attendanceHistoryOptions({ month, userId: user?.id }),
|
||||||
);
|
);
|
||||||
const records = data ?? [];
|
const records = useMemo(() => data ?? [], [data]);
|
||||||
|
|
||||||
const computed = useMemo(() => {
|
const computed = useMemo(() => {
|
||||||
const [yearStr, monthStr] = month.split("-");
|
const [yearStr, monthStr] = month.split("-");
|
||||||
|
|||||||
@@ -185,9 +185,34 @@ interface InvoiceItem {
|
|||||||
quantity: string | number;
|
quantity: string | number;
|
||||||
unit: string;
|
unit: string;
|
||||||
unit_price: string | number;
|
unit_price: string | number;
|
||||||
|
/** Per-item discount as a percentage (0–100). */
|
||||||
|
discount: string | number;
|
||||||
vat_rate: number;
|
vat_rate: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discount input styling: centered, native number spinners hidden so a 3-digit
|
||||||
|
* value ("100") isn't clipped under the up/down arrows.
|
||||||
|
*/
|
||||||
|
const discountInputSx = {
|
||||||
|
"& input": { textAlign: "center" },
|
||||||
|
"& input[type=number]": { MozAppearance: "textfield" as const },
|
||||||
|
"& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button": {
|
||||||
|
WebkitAppearance: "none",
|
||||||
|
margin: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** NET of one invoice line after its per-item percentage discount. */
|
||||||
|
const invoiceLineNet = (item: {
|
||||||
|
quantity: string | number;
|
||||||
|
unit_price: string | number;
|
||||||
|
discount?: string | number | null;
|
||||||
|
}): number =>
|
||||||
|
(Number(item.quantity) || 0) *
|
||||||
|
(Number(item.unit_price) || 0) *
|
||||||
|
(1 - (Number(item.discount) || 0) / 100);
|
||||||
|
|
||||||
interface InvoiceForm {
|
interface InvoiceForm {
|
||||||
customer_id: number | null;
|
customer_id: number | null;
|
||||||
customer_name: string;
|
customer_name: string;
|
||||||
@@ -253,8 +278,7 @@ function SortableInvoiceRow({
|
|||||||
position: "relative" as const,
|
position: "relative" as const,
|
||||||
zIndex: isDragging ? 10 : undefined,
|
zIndex: isDragging ? 10 : undefined,
|
||||||
};
|
};
|
||||||
const lineTotal =
|
const lineTotal = invoiceLineNet(item);
|
||||||
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
@@ -349,6 +373,14 @@ function SortableInvoiceRow({
|
|||||||
onChange={(e) => onUpdate(index, "unit_price", e.target.value)}
|
onChange={(e) => onUpdate(index, "unit_price", e.target.value)}
|
||||||
slotProps={{ htmlInput: { step: "any" } }}
|
slotProps={{ htmlInput: { step: "any" } }}
|
||||||
/>
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Sleva %"
|
||||||
|
type="number"
|
||||||
|
value={item.discount ?? ""}
|
||||||
|
onChange={(e) => onUpdate(index, "discount", e.target.value)}
|
||||||
|
slotProps={{ htmlInput: { min: "0", max: "100", step: "any" } }}
|
||||||
|
sx={discountInputSx}
|
||||||
|
/>
|
||||||
{apply_vat && (
|
{apply_vat && (
|
||||||
<Select
|
<Select
|
||||||
label="DPH"
|
label="DPH"
|
||||||
@@ -458,6 +490,15 @@ function SortableInvoiceRow({
|
|||||||
sx={{ "& input": { textAlign: "right" } }}
|
sx={{ "& input": { textAlign: "right" } }}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
value={item.discount ?? ""}
|
||||||
|
onChange={(e) => onUpdate(index, "discount", e.target.value)}
|
||||||
|
slotProps={{ htmlInput: { min: "0", max: "100", step: "any" } }}
|
||||||
|
sx={discountInputSx}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
{apply_vat ? (
|
{apply_vat ? (
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Select
|
<Select
|
||||||
@@ -513,6 +554,7 @@ export default function InvoiceDetail() {
|
|||||||
quantity: 1,
|
quantity: 1,
|
||||||
unit: "ks",
|
unit: "ks",
|
||||||
unit_price: 0,
|
unit_price: 0,
|
||||||
|
discount: 0,
|
||||||
vat_rate: 21,
|
vat_rate: 21,
|
||||||
}),
|
}),
|
||||||
[],
|
[],
|
||||||
@@ -577,6 +619,7 @@ export default function InvoiceDetail() {
|
|||||||
quantity: 1,
|
quantity: 1,
|
||||||
unit: "ks",
|
unit: "ks",
|
||||||
unit_price: 0,
|
unit_price: 0,
|
||||||
|
discount: 0,
|
||||||
vat_rate: 21,
|
vat_rate: 21,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -621,10 +664,17 @@ export default function InvoiceDetail() {
|
|||||||
const customers = customersQuery.data ?? [];
|
const customers = customersQuery.data ?? [];
|
||||||
|
|
||||||
const bankAccountsQuery = useQuery(bankAccountsOptions());
|
const bankAccountsQuery = useQuery(bankAccountsOptions());
|
||||||
const bankAccounts = bankAccountsQuery.data ?? [];
|
const bankAccounts = useMemo(
|
||||||
|
() => bankAccountsQuery.data ?? [],
|
||||||
|
[bankAccountsQuery.data],
|
||||||
|
);
|
||||||
|
|
||||||
const invoiceQuery = useQuery(invoiceDetailOptions(id));
|
const invoiceQuery = useQuery(invoiceDetailOptions(id));
|
||||||
const invoice = invoiceQuery.data ?? null;
|
const invoice = invoiceQuery.data ?? null;
|
||||||
|
// Read-only view: show the Sleva column only when some line carries a discount.
|
||||||
|
const hasItemDiscount = (invoice?.items ?? []).some(
|
||||||
|
(it) => Number(it.discount) > 0,
|
||||||
|
);
|
||||||
|
|
||||||
const nextNumberQuery = useQuery({
|
const nextNumberQuery = useQuery({
|
||||||
queryKey: ["invoices", "next-number"],
|
queryKey: ["invoices", "next-number"],
|
||||||
@@ -656,8 +706,9 @@ export default function InvoiceDetail() {
|
|||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const blobTimeouts = blobTimeoutsRef.current;
|
||||||
return () => {
|
return () => {
|
||||||
blobTimeoutsRef.current.forEach(clearTimeout);
|
blobTimeouts.forEach(clearTimeout);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -747,6 +798,7 @@ export default function InvoiceDetail() {
|
|||||||
quantity: Number(item.quantity) || 1,
|
quantity: Number(item.quantity) || 1,
|
||||||
unit: item.unit || "",
|
unit: item.unit || "",
|
||||||
unit_price: Number(item.unit_price) || 0,
|
unit_price: Number(item.unit_price) || 0,
|
||||||
|
discount: Number(item.discount) || 0,
|
||||||
// Per-line VAT: hydrate from the ITEM's own stored rate (a real DB
|
// Per-line VAT: hydrate from the ITEM's own stored rate (a real DB
|
||||||
// column returned by the detail endpoint) — falling back to the
|
// column returned by the detail endpoint) — falling back to the
|
||||||
// invoice-level rate only when the line carries none. Hydrating
|
// invoice-level rate only when the line carries none. Hydrating
|
||||||
@@ -844,6 +896,7 @@ export default function InvoiceDetail() {
|
|||||||
quantity: Number(item.quantity) || 1,
|
quantity: Number(item.quantity) || 1,
|
||||||
unit: (item.unit as string) || "",
|
unit: (item.unit as string) || "",
|
||||||
unit_price: Number(item.unit_price) || 0,
|
unit_price: Number(item.unit_price) || 0,
|
||||||
|
discount: Number(item.discount) || 0,
|
||||||
vat_rate: vatRate,
|
vat_rate: vatRate,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
@@ -963,8 +1016,7 @@ export default function InvoiceDetail() {
|
|||||||
const vatByRate: Record<number, number> = {};
|
const vatByRate: Record<number, number> = {};
|
||||||
|
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
const lineTotal =
|
const lineTotal = invoiceLineNet(item);
|
||||||
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
|
||||||
subtotal += lineTotal;
|
subtotal += lineTotal;
|
||||||
|
|
||||||
if (form.apply_vat) {
|
if (form.apply_vat) {
|
||||||
@@ -1034,6 +1086,7 @@ export default function InvoiceDetail() {
|
|||||||
// field never reaches the server as NaN (mirrors createTotals).
|
// field never reaches the server as NaN (mirrors createTotals).
|
||||||
quantity: Number(item.quantity) || 0,
|
quantity: Number(item.quantity) || 0,
|
||||||
unit_price: Number(item.unit_price) || 0,
|
unit_price: Number(item.unit_price) || 0,
|
||||||
|
discount: Number(item.discount) || 0,
|
||||||
position: i,
|
position: i,
|
||||||
})),
|
})),
|
||||||
sections: sections.map((s, i) => ({
|
sections: sections.map((s, i) => ({
|
||||||
@@ -1357,9 +1410,7 @@ export default function InvoiceDetail() {
|
|||||||
isMobile ? (
|
isMobile ? (
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||||
{invoice.items.map((item, index) => {
|
{invoice.items.map((item, index) => {
|
||||||
const lineSubtotal =
|
const lineSubtotal = invoiceLineNet(item);
|
||||||
(Number(item.quantity) || 0) *
|
|
||||||
(Number(item.unit_price) || 0);
|
|
||||||
const lineVat = Number(invoice.apply_vat)
|
const lineVat = Number(invoice.apply_vat)
|
||||||
? (lineSubtotal * (Number(item.vat_rate) || 0)) / 100
|
? (lineSubtotal * (Number(item.vat_rate) || 0)) / 100
|
||||||
: 0;
|
: 0;
|
||||||
@@ -1490,6 +1541,11 @@ export default function InvoiceDetail() {
|
|||||||
<TableCell sx={{ width: "8rem" }} align="center">
|
<TableCell sx={{ width: "8rem" }} align="center">
|
||||||
Jedn. cena
|
Jedn. cena
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
{hasItemDiscount && (
|
||||||
|
<TableCell sx={{ width: "4rem" }} align="center">
|
||||||
|
Sleva %
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
<TableCell sx={{ width: "4rem" }} align="center">
|
<TableCell sx={{ width: "4rem" }} align="center">
|
||||||
DPH
|
DPH
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -1500,9 +1556,7 @@ export default function InvoiceDetail() {
|
|||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{invoice.items.map((item, index) => {
|
{invoice.items.map((item, index) => {
|
||||||
const lineSubtotal =
|
const lineSubtotal = invoiceLineNet(item);
|
||||||
(Number(item.quantity) || 0) *
|
|
||||||
(Number(item.unit_price) || 0);
|
|
||||||
const lineVat = Number(invoice.apply_vat)
|
const lineVat = Number(invoice.apply_vat)
|
||||||
? (lineSubtotal * (Number(item.vat_rate) || 0)) / 100
|
? (lineSubtotal * (Number(item.vat_rate) || 0)) / 100
|
||||||
: 0;
|
: 0;
|
||||||
@@ -1551,6 +1605,13 @@ export default function InvoiceDetail() {
|
|||||||
>
|
>
|
||||||
{formatCurrency(item.unit_price, invoice.currency)}
|
{formatCurrency(item.unit_price, invoice.currency)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
{hasItemDiscount && (
|
||||||
|
<TableCell align="center">
|
||||||
|
{Number(item.discount) > 0
|
||||||
|
? `${Number(item.discount)} %`
|
||||||
|
: "—"}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
<TableCell align="center">
|
<TableCell align="center">
|
||||||
{Number(invoice.apply_vat)
|
{Number(invoice.apply_vat)
|
||||||
? Number(item.vat_rate)
|
? Number(item.vat_rate)
|
||||||
@@ -2234,6 +2295,9 @@ export default function InvoiceDetail() {
|
|||||||
<TableCell sx={{ width: "8rem" }} align="center">
|
<TableCell sx={{ width: "8rem" }} align="center">
|
||||||
Jedn. cena
|
Jedn. cena
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell sx={{ width: "7rem" }} align="center">
|
||||||
|
Sleva %
|
||||||
|
</TableCell>
|
||||||
{form.apply_vat ? (
|
{form.apply_vat ? (
|
||||||
<TableCell sx={{ width: "5rem" }} align="center">
|
<TableCell sx={{ width: "5rem" }} align="center">
|
||||||
DPH
|
DPH
|
||||||
|
|||||||
@@ -293,6 +293,8 @@ export default function IssuedOrderDetail() {
|
|||||||
quantity: numberOr(it.quantity, 1),
|
quantity: numberOr(it.quantity, 1),
|
||||||
unit: it.unit || "",
|
unit: it.unit || "",
|
||||||
unit_price: Number(it.unit_price) || 0,
|
unit_price: Number(it.unit_price) || 0,
|
||||||
|
// Issued orders have no Sleva column; keep the shared item shape happy.
|
||||||
|
discount: 0,
|
||||||
}))
|
}))
|
||||||
: [emptyDocumentItem()];
|
: [emptyDocumentItem()];
|
||||||
setItems(mappedItems);
|
setItems(mappedItems);
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ interface OfferItemPayload {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
unit: string;
|
unit: string;
|
||||||
unit_price: number;
|
unit_price: number;
|
||||||
|
discount: number;
|
||||||
is_included_in_total: boolean;
|
is_included_in_total: boolean;
|
||||||
position: number;
|
position: number;
|
||||||
}
|
}
|
||||||
@@ -303,6 +304,7 @@ export default function OfferDetail() {
|
|||||||
quantity: numberOr(it.quantity, 1),
|
quantity: numberOr(it.quantity, 1),
|
||||||
unit: it.unit || "",
|
unit: it.unit || "",
|
||||||
unit_price: Number(it.unit_price) || 0,
|
unit_price: Number(it.unit_price) || 0,
|
||||||
|
discount: Number(it.discount) || 0,
|
||||||
is_included_in_total: it.is_included_in_total !== false,
|
is_included_in_total: it.is_included_in_total !== false,
|
||||||
}))
|
}))
|
||||||
: [emptyDocumentItem()];
|
: [emptyDocumentItem()];
|
||||||
@@ -433,6 +435,7 @@ export default function OfferDetail() {
|
|||||||
quantity: Number(item.quantity) || 0,
|
quantity: Number(item.quantity) || 0,
|
||||||
unit: item.unit,
|
unit: item.unit,
|
||||||
unit_price: Number(item.unit_price) || 0,
|
unit_price: Number(item.unit_price) || 0,
|
||||||
|
discount: Number(item.discount) || 0,
|
||||||
is_included_in_total: item.is_included_in_total !== false,
|
is_included_in_total: item.is_included_in_total !== false,
|
||||||
position: i,
|
position: i,
|
||||||
})),
|
})),
|
||||||
@@ -936,6 +939,7 @@ export default function OfferDetail() {
|
|||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
error={errors.items}
|
error={errors.items}
|
||||||
showIncludedInTotal
|
showIncludedInTotal
|
||||||
|
showDiscount
|
||||||
itemDescriptionMaxLength={8000}
|
itemDescriptionMaxLength={8000}
|
||||||
templatesSlot={
|
templatesSlot={
|
||||||
itemTemplates && itemTemplates.length > 0 ? (
|
itemTemplates && itemTemplates.length > 0 ? (
|
||||||
@@ -957,6 +961,7 @@ export default function OfferDetail() {
|
|||||||
quantity: 1,
|
quantity: 1,
|
||||||
unit: "ks",
|
unit: "ks",
|
||||||
unit_price: template.default_price || 0,
|
unit_price: template.default_price || 0,
|
||||||
|
discount: 0,
|
||||||
is_included_in_total: true,
|
is_included_in_total: true,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -121,8 +121,9 @@ export default function OrderDetail() {
|
|||||||
}, [orderQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [orderQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const blobTimeouts = blobTimeoutsRef.current;
|
||||||
return () => {
|
return () => {
|
||||||
blobTimeoutsRef.current.forEach(clearTimeout);
|
blobTimeouts.forEach(clearTimeout);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import {
|
|||||||
getCell,
|
getCell,
|
||||||
getCells,
|
getCells,
|
||||||
type ModalMode as HookModalMode,
|
type ModalMode as HookModalMode,
|
||||||
|
type PlanEntryBody,
|
||||||
|
type PlanEntryPatchBody,
|
||||||
|
type PlanOverrideBody,
|
||||||
|
type PlanOverridePatchBody,
|
||||||
} from "../hooks/usePlanWork";
|
} from "../hooks/usePlanWork";
|
||||||
import type { PlanCellModalMode } from "../components/PlanCellModal";
|
import type { PlanCellModalMode } from "../components/PlanCellModal";
|
||||||
import PlanGrid from "../components/PlanGrid";
|
import PlanGrid from "../components/PlanGrid";
|
||||||
@@ -182,7 +186,13 @@ export default function PlanWork() {
|
|||||||
if (bulkForm.user_ids.length === 0) return;
|
if (bulkForm.user_ids.length === 0) return;
|
||||||
setBulkSubmitting(true);
|
setBulkSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const data: any = await bulkCreate.mutateAsync({
|
// Counts can arrive either at the envelope root or nested under .data.
|
||||||
|
type BulkSummary = {
|
||||||
|
created_days?: number;
|
||||||
|
skipped_days?: number;
|
||||||
|
users?: number;
|
||||||
|
};
|
||||||
|
const data = (await bulkCreate.mutateAsync({
|
||||||
user_ids: bulkForm.user_ids.map(Number),
|
user_ids: bulkForm.user_ids.map(Number),
|
||||||
date_from: bulkForm.date_from,
|
date_from: bulkForm.date_from,
|
||||||
date_to: bulkForm.is_range ? bulkForm.date_to : bulkForm.date_from,
|
date_to: bulkForm.is_range ? bulkForm.date_to : bulkForm.date_from,
|
||||||
@@ -190,12 +200,12 @@ export default function PlanWork() {
|
|||||||
project_id: bulkForm.project_id ? Number(bulkForm.project_id) : null,
|
project_id: bulkForm.project_id ? Number(bulkForm.project_id) : null,
|
||||||
category: bulkForm.category,
|
category: bulkForm.category,
|
||||||
note: bulkForm.note,
|
note: bulkForm.note,
|
||||||
});
|
})) as BulkSummary & { data?: BulkSummary };
|
||||||
// apiCall returns the full envelope { success, data, message };
|
// apiCall returns the full envelope { success, data, message };
|
||||||
// the summary counts are under .data
|
// the summary counts are under .data
|
||||||
const summary = data?.data ?? data;
|
const summary: BulkSummary = data?.data ?? data;
|
||||||
const skipped =
|
const skipped =
|
||||||
summary?.skipped_days > 0
|
(summary?.skipped_days ?? 0) > 0
|
||||||
? ` ${summary.skipped_days} dní přeskočeno (limit 3 na den).`
|
? ` ${summary.skipped_days} dní přeskočeno (limit 3 na den).`
|
||||||
: "";
|
: "";
|
||||||
alert.success(
|
alert.success(
|
||||||
@@ -392,7 +402,7 @@ export default function PlanWork() {
|
|||||||
// optimistic cache patch the hook already applied.
|
// optimistic cache patch the hook already applied.
|
||||||
|
|
||||||
const saveEntry = useCallback(
|
const saveEntry = useCallback(
|
||||||
async (body: any) => {
|
async (body: PlanEntryBody) => {
|
||||||
try {
|
try {
|
||||||
await createEntry.mutateAsync(body);
|
await createEntry.mutateAsync(body);
|
||||||
// Pulse the first day of the new entry — for multi-day entries
|
// Pulse the first day of the new entry — for multi-day entries
|
||||||
@@ -409,7 +419,7 @@ export default function PlanWork() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const updateEntryFn = useCallback(
|
const updateEntryFn = useCallback(
|
||||||
async (id: number, body: any) => {
|
async (id: number, body: PlanEntryPatchBody) => {
|
||||||
// Find the user for this entry so we know which cell to pulse /
|
// Find the user for this entry so we know which cell to pulse /
|
||||||
// which cache to roll back. The hook's optimistic patch already
|
// which cache to roll back. The hook's optimistic patch already
|
||||||
// discovered this; we read it from the current grid.
|
// discovered this; we read it from the current grid.
|
||||||
@@ -473,7 +483,7 @@ export default function PlanWork() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const saveOverride = useCallback(
|
const saveOverride = useCallback(
|
||||||
async (body: any) => {
|
async (body: PlanOverrideBody) => {
|
||||||
try {
|
try {
|
||||||
await createOverride.mutateAsync(body);
|
await createOverride.mutateAsync(body);
|
||||||
firePulse(body.user_id, body.shift_date);
|
firePulse(body.user_id, body.shift_date);
|
||||||
@@ -488,7 +498,7 @@ export default function PlanWork() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const updateOverrideFn = useCallback(
|
const updateOverrideFn = useCallback(
|
||||||
async (id: number, body: any) => {
|
async (id: number, body: PlanOverridePatchBody) => {
|
||||||
// Find the user/date for the pulse + rollback.
|
// Find the user/date for the pulse + rollback.
|
||||||
let userId: number | null = null;
|
let userId: number | null = null;
|
||||||
let date: string | null = null;
|
let date: string | null = null;
|
||||||
|
|||||||
@@ -362,8 +362,9 @@ export default function ReceivedInvoices({
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const blobTimeouts = blobTimeoutsRef.current;
|
||||||
return () => {
|
return () => {
|
||||||
blobTimeoutsRef.current.forEach(clearTimeout);
|
blobTimeouts.forEach(clearTimeout);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -1149,11 +1150,9 @@ export default function ReceivedInvoices({
|
|||||||
? "Zavřít"
|
? "Zavřít"
|
||||||
: "Uložit"
|
: "Uložit"
|
||||||
}
|
}
|
||||||
cancelText={
|
// Read-only (paid) detail has only a close action — a single "Zavřít"
|
||||||
editInvoice && editInvoice._originalStatus === "paid"
|
// button, not a redundant cancel button beside it.
|
||||||
? "Zavřít"
|
hideCancel={editInvoice?._originalStatus === "paid"}
|
||||||
: "Zrušit"
|
|
||||||
}
|
|
||||||
loading={saving}
|
loading={saving}
|
||||||
>
|
>
|
||||||
{editInvoice &&
|
{editInvoice &&
|
||||||
|
|||||||
@@ -715,7 +715,25 @@ export default function Settings() {
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const si = systemInfo as Record<string, any>;
|
type NasInfo = { configured?: boolean; path?: string | null };
|
||||||
|
type SystemInfo = {
|
||||||
|
app_version?: string;
|
||||||
|
node_version?: string;
|
||||||
|
platform?: string;
|
||||||
|
uptime?: string;
|
||||||
|
environment?: string;
|
||||||
|
timezone?: string;
|
||||||
|
memory?: {
|
||||||
|
rss?: string;
|
||||||
|
heap_used?: string;
|
||||||
|
heap_total?: string;
|
||||||
|
system_free?: string;
|
||||||
|
system_total?: string;
|
||||||
|
};
|
||||||
|
database?: { status?: string; migrations_applied?: number | string };
|
||||||
|
nas?: Record<string, NasInfo | undefined>;
|
||||||
|
};
|
||||||
|
const si = systemInfo as SystemInfo;
|
||||||
const Row = ({
|
const Row = ({
|
||||||
label,
|
label,
|
||||||
children,
|
children,
|
||||||
@@ -800,7 +818,7 @@ export default function Settings() {
|
|||||||
["Faktury", si.nas?.invoices],
|
["Faktury", si.nas?.invoices],
|
||||||
["Objednávky", si.nas?.orders],
|
["Objednávky", si.nas?.orders],
|
||||||
["Nabídky", si.nas?.offers],
|
["Nabídky", si.nas?.offers],
|
||||||
] as [string, Record<string, any>][]
|
] as [string, NasInfo | undefined][]
|
||||||
).map(([label, info]) => (
|
).map(([label, info]) => (
|
||||||
<Row key={label} label={label}>
|
<Row key={label} label={label}>
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef, useId } from "react";
|
import { useState, useEffect, useRef, useId, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
useParams,
|
useParams,
|
||||||
useNavigate,
|
useNavigate,
|
||||||
@@ -174,7 +174,10 @@ export default function WarehouseIssueForm() {
|
|||||||
|
|
||||||
const baseId = useId();
|
const baseId = useId();
|
||||||
const counterRef = useRef(0);
|
const counterRef = useRef(0);
|
||||||
const nextKey = () => `${baseId}-item-${counterRef.current++}`;
|
const nextKey = useCallback(
|
||||||
|
() => `${baseId}-item-${counterRef.current++}`,
|
||||||
|
[baseId],
|
||||||
|
);
|
||||||
|
|
||||||
// Pre-fill data from reservation ("Create výdej" button)
|
// Pre-fill data from reservation ("Create výdej" button)
|
||||||
const prefill = location.state as {
|
const prefill = location.state as {
|
||||||
@@ -262,7 +265,7 @@ export default function WarehouseIssueForm() {
|
|||||||
}
|
}
|
||||||
formInitialized.current = true;
|
formInitialized.current = true;
|
||||||
}
|
}
|
||||||
}, [isEdit, issue]);
|
}, [isEdit, issue, nextKey]);
|
||||||
|
|
||||||
const saveMutation = useApiMutation<
|
const saveMutation = useApiMutation<
|
||||||
ReturnType<typeof buildPayload>,
|
ReturnType<typeof buildPayload>,
|
||||||
|
|||||||
@@ -126,7 +126,10 @@ export default function WarehouseReceiptForm() {
|
|||||||
|
|
||||||
const baseId = useId();
|
const baseId = useId();
|
||||||
const counterRef = useRef(0);
|
const counterRef = useRef(0);
|
||||||
const nextKey = () => `${baseId}-item-${counterRef.current++}`;
|
const nextKey = useCallback(
|
||||||
|
() => `${baseId}-item-${counterRef.current++}`,
|
||||||
|
[baseId],
|
||||||
|
);
|
||||||
|
|
||||||
const [form, setForm] = useState<ReceiptForm>({
|
const [form, setForm] = useState<ReceiptForm>({
|
||||||
supplier_id: null,
|
supplier_id: null,
|
||||||
@@ -198,7 +201,7 @@ export default function WarehouseReceiptForm() {
|
|||||||
}
|
}
|
||||||
formInitialized.current = true;
|
formInitialized.current = true;
|
||||||
}
|
}
|
||||||
}, [isEdit, receipt]);
|
}, [isEdit, receipt, nextKey]);
|
||||||
|
|
||||||
const saveMutation = useApiMutation<
|
const saveMutation = useApiMutation<
|
||||||
ReturnType<typeof buildPayload>,
|
ReturnType<typeof buildPayload>,
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { theme } from "./theme";
|
import { theme } from "./theme";
|
||||||
|
|
||||||
|
/** Loose shape for poking into MUI styleOverrides objects in assertions. */
|
||||||
|
type StyleObj = Record<string, unknown>;
|
||||||
|
|
||||||
/** Minimal palette shape used by the colorSchemes assertions below. */
|
/** Minimal palette shape used by the colorSchemes assertions below. */
|
||||||
interface SchemesPalette {
|
interface SchemesPalette {
|
||||||
primary: { main: string };
|
primary: { main: string };
|
||||||
@@ -44,18 +47,20 @@ describe("MUI theme", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("makes buttons pill-shaped and cards 16px by default", () => {
|
it("makes buttons pill-shaped and cards 16px by default", () => {
|
||||||
const btn = theme.components?.MuiButton?.styleOverrides?.root as any;
|
const btn = theme.components?.MuiButton?.styleOverrides?.root as StyleObj;
|
||||||
expect(btn?.borderRadius).toBe(999);
|
expect(btn.borderRadius).toBe(999);
|
||||||
const card = theme.components?.MuiCard?.styleOverrides?.root as any;
|
const card = theme.components?.MuiCard?.styleOverrides?.root as StyleObj;
|
||||||
expect(card?.borderRadius).toBe(16);
|
expect(card.borderRadius).toBe(16);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds 200-300ms interaction motion (press + focus ring)", () => {
|
it("adds 200-300ms interaction motion (press + focus ring)", () => {
|
||||||
const btn = theme.components?.MuiButton?.styleOverrides?.root as any;
|
const btn = theme.components?.MuiButton?.styleOverrides?.root as StyleObj;
|
||||||
expect(btn?.transition).toContain("transform");
|
expect(btn.transition).toContain("transform");
|
||||||
expect(btn?.["&:active"]?.transform).toBe("scale(0.97)");
|
const active = btn["&:active"] as StyleObj;
|
||||||
|
expect(active.transform).toBe("scale(0.97)");
|
||||||
const input = theme.components?.MuiOutlinedInput?.styleOverrides
|
const input = theme.components?.MuiOutlinedInput?.styleOverrides
|
||||||
?.root as any;
|
?.root as StyleObj;
|
||||||
expect(input?.["&.Mui-focused"]?.boxShadow).toContain("rgba");
|
const focused = input["&.Mui-focused"] as StyleObj;
|
||||||
|
expect(focused.boxShadow).toContain("rgba");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { parseBody } from "../../schemas/common";
|
|||||||
import { UpdateCompanySettingsSchema } from "../../schemas/settings.schema";
|
import { UpdateCompanySettingsSchema } from "../../schemas/settings.schema";
|
||||||
import { invalidateSettingsCache } from "../../services/system-settings";
|
import { invalidateSettingsCache } from "../../services/system-settings";
|
||||||
import os from "os";
|
import os from "os";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
import { config } from "../../config/env";
|
import { config } from "../../config/env";
|
||||||
import { NasFileManager } from "../../services/nas-file-manager";
|
import { NasFileManager } from "../../services/nas-file-manager";
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +23,15 @@ import {
|
|||||||
} from "../../services/nas-financials-manager";
|
} from "../../services/nas-financials-manager";
|
||||||
import { nasOffersManager } from "../../services/nas-offers-manager";
|
import { nasOffersManager } from "../../services/nas-offers-manager";
|
||||||
|
|
||||||
|
// Read once at load. A static `import` of the root package.json would pull a
|
||||||
|
// file outside `src` into the program, expanding tsc's rootDir and shifting the
|
||||||
|
// whole dist/ layout — so resolve it at runtime relative to the compiled file.
|
||||||
|
const APP_VERSION = (
|
||||||
|
JSON.parse(
|
||||||
|
readFileSync(join(__dirname, "../../../package.json"), "utf8"),
|
||||||
|
) as { version: string }
|
||||||
|
).version;
|
||||||
|
|
||||||
/** Encode custom_fields + supplier_field_order into a single JSON blob (matching PHP format) */
|
/** Encode custom_fields + supplier_field_order into a single JSON blob (matching PHP format) */
|
||||||
function encodeCustomFields(
|
function encodeCustomFields(
|
||||||
fields: unknown,
|
fields: unknown,
|
||||||
@@ -325,8 +336,6 @@ export default async function companySettingsRoutes(
|
|||||||
settings.custom_fields as string | null,
|
settings.custom_fields as string | null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const pkg = require("../../../package.json") as { version: string };
|
|
||||||
|
|
||||||
let available_vat_rates: number[] = [0, 10, 12, 15, 21];
|
let available_vat_rates: number[] = [0, 10, 12, 15, 21];
|
||||||
try {
|
try {
|
||||||
const raw = settings.available_vat_rates as string | null;
|
const raw = settings.available_vat_rates as string | null;
|
||||||
@@ -359,7 +368,7 @@ export default async function companySettingsRoutes(
|
|||||||
available_currencies,
|
available_currencies,
|
||||||
has_logo,
|
has_logo,
|
||||||
has_logo_dark,
|
has_logo_dark,
|
||||||
app_version: pkg.version,
|
app_version: APP_VERSION,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -368,7 +377,6 @@ export default async function companySettingsRoutes(
|
|||||||
"/system-info",
|
"/system-info",
|
||||||
{ preHandler: requirePermission("settings.system") },
|
{ preHandler: requirePermission("settings.system") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const pkg = require("../../../package.json") as { version: string };
|
|
||||||
const uptimeSec = process.uptime();
|
const uptimeSec = process.uptime();
|
||||||
const days = Math.floor(uptimeSec / 86400);
|
const days = Math.floor(uptimeSec / 86400);
|
||||||
const hours = Math.floor((uptimeSec % 86400) / 3600);
|
const hours = Math.floor((uptimeSec % 86400) / 3600);
|
||||||
@@ -401,7 +409,7 @@ export default async function companySettingsRoutes(
|
|||||||
const projectNas = new NasFileManager();
|
const projectNas = new NasFileManager();
|
||||||
|
|
||||||
return success(reply, {
|
return success(reply, {
|
||||||
app_version: pkg.version,
|
app_version: APP_VERSION,
|
||||||
node_version: process.version,
|
node_version: process.version,
|
||||||
platform: `${os.type()} ${os.release()}`,
|
platform: `${os.type()} ${os.release()}`,
|
||||||
uptime: uptimeStr,
|
uptime: uptimeStr,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { htmlToPdf } from "../../utils/html-to-pdf";
|
|||||||
import { getRate } from "../../services/exchange-rates";
|
import { getRate } from "../../services/exchange-rates";
|
||||||
import { localDateStr } from "../../utils/date";
|
import { localDateStr } from "../../utils/date";
|
||||||
import { parseId, success } from "../../utils/response";
|
import { parseId, success } from "../../utils/response";
|
||||||
|
import { lineNet } from "../../utils/money";
|
||||||
import {
|
import {
|
||||||
formatDate,
|
formatDate,
|
||||||
formatNum,
|
formatNum,
|
||||||
@@ -161,6 +162,7 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
col_desc: "Popis",
|
col_desc: "Popis",
|
||||||
col_qty: "Množství",
|
col_qty: "Množství",
|
||||||
col_unit_price: "Jedn. cena",
|
col_unit_price: "Jedn. cena",
|
||||||
|
col_discount: "Sleva",
|
||||||
col_price: "Cena",
|
col_price: "Cena",
|
||||||
col_vat_pct: "%DPH",
|
col_vat_pct: "%DPH",
|
||||||
col_vat: "DPH",
|
col_vat: "DPH",
|
||||||
@@ -208,6 +210,7 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
col_desc: "Description",
|
col_desc: "Description",
|
||||||
col_qty: "Quantity",
|
col_qty: "Quantity",
|
||||||
col_unit_price: "Unit price",
|
col_unit_price: "Unit price",
|
||||||
|
col_discount: "Discount",
|
||||||
col_price: "Price",
|
col_price: "Price",
|
||||||
col_vat_pct: "VAT%",
|
col_vat_pct: "VAT%",
|
||||||
col_vat: "VAT",
|
col_vat: "VAT",
|
||||||
@@ -312,6 +315,8 @@ export default async function invoicesPdfRoutes(
|
|||||||
where: { invoice_id: id },
|
where: { invoice_id: id },
|
||||||
orderBy: [{ position: "asc" }, { id: "asc" }],
|
orderBy: [{ position: "asc" }, { id: "asc" }],
|
||||||
});
|
});
|
||||||
|
// Sleva column is rendered only when at least one item has a discount.
|
||||||
|
const hasDiscount = items.some((it) => Number(it.discount) > 0);
|
||||||
|
|
||||||
let customer: Record<string, unknown> | null = null;
|
let customer: Record<string, unknown> | null = null;
|
||||||
if (invoice.customer_id) {
|
if (invoice.customer_id) {
|
||||||
@@ -362,7 +367,11 @@ export default async function invoicesPdfRoutes(
|
|||||||
let subtotal = 0;
|
let subtotal = 0;
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const lineSubtotal = Number(item.quantity) * Number(item.unit_price);
|
const lineSubtotal = lineNet(
|
||||||
|
Number(item.quantity),
|
||||||
|
Number(item.unit_price),
|
||||||
|
Number(item.discount),
|
||||||
|
);
|
||||||
subtotal += lineSubtotal;
|
subtotal += lineSubtotal;
|
||||||
const rate = Number(item.vat_rate);
|
const rate = Number(item.vat_rate);
|
||||||
const key = String(rate);
|
const key = String(rate);
|
||||||
@@ -489,11 +498,16 @@ export default async function invoicesPdfRoutes(
|
|||||||
.map((item, i) => {
|
.map((item, i) => {
|
||||||
const qty = Number(item.quantity);
|
const qty = Number(item.quantity);
|
||||||
const unitPrice = Number(item.unit_price);
|
const unitPrice = Number(item.unit_price);
|
||||||
const lineSubtotal = qty * unitPrice;
|
const disc = Number(item.discount) || 0;
|
||||||
|
const lineSubtotal = lineNet(qty, unitPrice, disc);
|
||||||
const vatRate = Number(item.vat_rate);
|
const vatRate = Number(item.vat_rate);
|
||||||
const lineVat = applyVat ? (lineSubtotal * vatRate) / 100 : 0;
|
const lineVat = applyVat ? (lineSubtotal * vatRate) / 100 : 0;
|
||||||
const lineTotal = lineSubtotal + lineVat;
|
const lineTotal = lineSubtotal + lineVat;
|
||||||
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
|
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
|
||||||
|
const discDecimals = Math.floor(disc) === disc ? 0 : 2;
|
||||||
|
const discCell = hasDiscount
|
||||||
|
? `<td class="center">${disc > 0 ? `${formatNum(disc, discDecimals)} %` : "—"}</td>`
|
||||||
|
: "";
|
||||||
const subDesc = item.item_description || "";
|
const subDesc = item.item_description || "";
|
||||||
|
|
||||||
return `<tr>
|
return `<tr>
|
||||||
@@ -501,6 +515,7 @@ export default async function invoicesPdfRoutes(
|
|||||||
<td class="desc">${escapeHtml(item.description)}${subDesc ? `<div class="item-subdesc">${escapeHtml(subDesc)}</div>` : ""}</td>
|
<td class="desc">${escapeHtml(item.description)}${subDesc ? `<div class="item-subdesc">${escapeHtml(subDesc)}</div>` : ""}</td>
|
||||||
<td class="center">${formatNum(qty, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
|
<td class="center">${formatNum(qty, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
|
||||||
<td class="right">${formatNum(unitPrice)}</td>
|
<td class="right">${formatNum(unitPrice)}</td>
|
||||||
|
${discCell}
|
||||||
<td class="right">${formatNum(lineSubtotal)}</td>
|
<td class="right">${formatNum(lineSubtotal)}</td>
|
||||||
<td class="center">${applyVat ? Math.floor(vatRate) : 0}%</td>
|
<td class="center">${applyVat ? Math.floor(vatRate) : 0}%</td>
|
||||||
<td class="right">${formatNum(lineVat)}</td>
|
<td class="right">${formatNum(lineVat)}</td>
|
||||||
@@ -1052,9 +1067,10 @@ ${indentCSS}
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
|
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
|
||||||
<th style="width:36%">${escapeHtml(t.col_desc)}</th>
|
<th style="width:${hasDiscount ? 29 : 36}%">${escapeHtml(t.col_desc)}</th>
|
||||||
<th class="center" style="width:10%">${escapeHtml(t.col_qty)}</th>
|
<th class="center" style="width:10%">${escapeHtml(t.col_qty)}</th>
|
||||||
<th class="right" style="width:10%">${escapeHtml(t.col_unit_price)}</th>
|
<th class="right" style="width:10%">${escapeHtml(t.col_unit_price)}</th>
|
||||||
|
${hasDiscount ? `<th class="center" style="width:7%">${escapeHtml(t.col_discount)}</th>` : ""}
|
||||||
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>
|
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>
|
||||||
<th class="center" style="width:5%">${escapeHtml(t.col_vat_pct)}</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:10%">${escapeHtml(t.col_vat)}</th>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { requirePermission } from "../../middleware/auth";
|
|||||||
import { nasOffersManager } from "../../services/nas-offers-manager";
|
import { nasOffersManager } from "../../services/nas-offers-manager";
|
||||||
import { htmlToPdf } from "../../utils/html-to-pdf";
|
import { htmlToPdf } from "../../utils/html-to-pdf";
|
||||||
import { parseId, success } from "../../utils/response";
|
import { parseId, success } from "../../utils/response";
|
||||||
|
import { lineNet } from "../../utils/money";
|
||||||
import {
|
import {
|
||||||
formatDate,
|
formatDate,
|
||||||
formatNum,
|
formatNum,
|
||||||
@@ -123,6 +124,7 @@ const TRANSLATIONS: Record<string, Record<string, string>> = {
|
|||||||
description: { EN: "Description", CZ: "Popis" },
|
description: { EN: "Description", CZ: "Popis" },
|
||||||
qty: { EN: "Qty", CZ: "Mn." },
|
qty: { EN: "Qty", CZ: "Mn." },
|
||||||
unit_price: { EN: "Unit Price", CZ: "Jedn. cena" },
|
unit_price: { EN: "Unit Price", CZ: "Jedn. cena" },
|
||||||
|
discount: { EN: "Discount", CZ: "Sleva" },
|
||||||
included: { EN: "Included", CZ: "Zahrnuto" },
|
included: { EN: "Included", CZ: "Zahrnuto" },
|
||||||
total: { EN: "Total", CZ: "Celkem" },
|
total: { EN: "Total", CZ: "Celkem" },
|
||||||
total_no_vat: { EN: "Total excl. VAT", CZ: "Celkem bez DPH" },
|
total_no_vat: { EN: "Total excl. VAT", CZ: "Celkem bez DPH" },
|
||||||
@@ -170,9 +172,15 @@ export function renderOfferHtml(
|
|||||||
let total = 0;
|
let total = 0;
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.is_included_in_total !== false) {
|
if (item.is_included_in_total !== false) {
|
||||||
total += (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
total += lineNet(
|
||||||
|
Number(item.quantity) || 0,
|
||||||
|
Number(item.unit_price) || 0,
|
||||||
|
Number(item.discount),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Sleva column is rendered only when at least one item carries a discount.
|
||||||
|
const hasDiscount = items.some((it) => Number(it.discount) > 0);
|
||||||
// Visibility uses the SAME per-language title rule as the render loop plus
|
// Visibility uses the SAME per-language title rule as the render loop plus
|
||||||
// a tag-stripped content check (an empty-Quill "<p><br></p>" doesn't count)
|
// a tag-stripped content check (an empty-Quill "<p><br></p>" doesn't count)
|
||||||
// — otherwise a section with only the other language's title would count as
|
// — otherwise a section with only the other language's title would count as
|
||||||
@@ -217,10 +225,15 @@ export function renderOfferHtml(
|
|||||||
let itemsHtml = "";
|
let itemsHtml = "";
|
||||||
items.forEach((item, i) => {
|
items.forEach((item, i) => {
|
||||||
const qty = Number(item.quantity) || 0;
|
const qty = Number(item.quantity) || 0;
|
||||||
const lineTotal = qty * (Number(item.unit_price) || 0);
|
const disc = Number(item.discount) || 0;
|
||||||
|
const lineTotal = lineNet(qty, Number(item.unit_price) || 0, disc);
|
||||||
// Integers render without decimals; fractional quantities keep 2 decimals
|
// Integers render without decimals; fractional quantities keep 2 decimals
|
||||||
// (the old toFixed(0) ROUNDED 1.5 → 2 on the printed document).
|
// (the old toFixed(0) ROUNDED 1.5 → 2 on the printed document).
|
||||||
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
|
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
|
||||||
|
const discDecimals = Math.floor(disc) === disc ? 0 : 2;
|
||||||
|
const discCell = hasDiscount
|
||||||
|
? `<td class="center">${disc > 0 ? `${formatNum(disc, discDecimals)} %` : "—"}</td>`
|
||||||
|
: "";
|
||||||
const subDesc = item.item_description || "";
|
const subDesc = item.item_description || "";
|
||||||
const evenClass = i % 2 === 1 ? ' class="even"' : "";
|
const evenClass = i % 2 === 1 ? ' class="even"' : "";
|
||||||
itemsHtml += `<tr${evenClass}>
|
itemsHtml += `<tr${evenClass}>
|
||||||
@@ -228,6 +241,7 @@ export function renderOfferHtml(
|
|||||||
<td class="desc">${escapeHtml(item.description)}${subDesc ? `<div class="item-subdesc">${escapeHtml(subDesc)}</div>` : ""}</td>
|
<td class="desc">${escapeHtml(item.description)}${subDesc ? `<div class="item-subdesc">${escapeHtml(subDesc)}</div>` : ""}</td>
|
||||||
<td class="center">${formatNum(qty, qtyDecimals)}${(item.unit || "").trim() ? ` / ${escapeHtml((item.unit || "").trim())}` : ""}</td>
|
<td class="center">${formatNum(qty, qtyDecimals)}${(item.unit || "").trim() ? ` / ${escapeHtml((item.unit || "").trim())}` : ""}</td>
|
||||||
<td class="right">${formatCurrency(Number(item.unit_price) || 0, currency)}</td>
|
<td class="right">${formatCurrency(Number(item.unit_price) || 0, currency)}</td>
|
||||||
|
${discCell}
|
||||||
<td class="right total-cell">${formatCurrency(lineTotal, currency)}</td>
|
<td class="right total-cell">${formatCurrency(lineTotal, currency)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
});
|
});
|
||||||
@@ -621,10 +635,11 @@ ${indentCSS}
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="center" style="width:5%">${escapeHtml(t("no"))}</th>
|
<th class="center" style="width:5%">${escapeHtml(t("no"))}</th>
|
||||||
<th style="width:44%">${escapeHtml(t("description"))}</th>
|
<th style="width:${hasDiscount ? 36 : 44}%">${escapeHtml(t("description"))}</th>
|
||||||
<th class="center" style="width:13%">${escapeHtml(t("qty"))}</th>
|
<th class="center" style="width:12%">${escapeHtml(t("qty"))}</th>
|
||||||
<th class="right" style="width:18%">${escapeHtml(t("unit_price"))}</th>
|
<th class="right" style="width:16%">${escapeHtml(t("unit_price"))}</th>
|
||||||
<th class="right" style="width:20%">${escapeHtml(t("total"))}</th>
|
${hasDiscount ? `<th class="center" style="width:9%">${escapeHtml(t("discount"))}</th>` : ""}
|
||||||
|
<th class="right" style="width:18%">${escapeHtml(t("total"))}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ export default async function planRoutes(app: FastifyInstance) {
|
|||||||
"/categories/:id",
|
"/categories/:id",
|
||||||
{ preHandler: requirePermission("attendance.manage") },
|
{ preHandler: requirePermission("attendance.manage") },
|
||||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const id = parseId((request.params as any).id, reply);
|
const id = parseId((request.params as { id: string }).id, reply);
|
||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
const body = parseBody(UpdatePlanCategorySchema, request.body);
|
const body = parseBody(UpdatePlanCategorySchema, request.body);
|
||||||
if ("error" in body) return error(reply, body.error, 400);
|
if ("error" in body) return error(reply, body.error, 400);
|
||||||
@@ -218,7 +218,7 @@ export default async function planRoutes(app: FastifyInstance) {
|
|||||||
"/categories/:id",
|
"/categories/:id",
|
||||||
{ preHandler: requirePermission("attendance.manage") },
|
{ preHandler: requirePermission("attendance.manage") },
|
||||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const id = parseId((request.params as any).id, reply);
|
const id = parseId((request.params as { id: string }).id, reply);
|
||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
const result = await deletePlanCategory(id);
|
const result = await deletePlanCategory(id);
|
||||||
if ("error" in result) return error(reply, result.error, result.status);
|
if ("error" in result) return error(reply, result.error, result.status);
|
||||||
@@ -289,7 +289,7 @@ export default async function planRoutes(app: FastifyInstance) {
|
|||||||
"/entries/:id",
|
"/entries/:id",
|
||||||
{ preHandler: requirePermission("attendance.manage") },
|
{ preHandler: requirePermission("attendance.manage") },
|
||||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const id = parseId((request.params as any).id, reply);
|
const id = parseId((request.params as { id: string }).id, reply);
|
||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
const body = parseBody(UpdatePlanEntrySchema, request.body);
|
const body = parseBody(UpdatePlanEntrySchema, request.body);
|
||||||
if ("error" in body) return error(reply, body.error, 400);
|
if ("error" in body) return error(reply, body.error, 400);
|
||||||
@@ -321,7 +321,7 @@ export default async function planRoutes(app: FastifyInstance) {
|
|||||||
"/entries/:id",
|
"/entries/:id",
|
||||||
{ preHandler: requirePermission("attendance.manage") },
|
{ preHandler: requirePermission("attendance.manage") },
|
||||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const id = parseId((request.params as any).id, reply);
|
const id = parseId((request.params as { id: string }).id, reply);
|
||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
const force = forceFromQuery(request.query);
|
const force = forceFromQuery(request.query);
|
||||||
const result = await deleteEntry(id, request.authData!.userId, force);
|
const result = await deleteEntry(id, request.authData!.userId, force);
|
||||||
@@ -373,7 +373,7 @@ export default async function planRoutes(app: FastifyInstance) {
|
|||||||
"/overrides/:id",
|
"/overrides/:id",
|
||||||
{ preHandler: requirePermission("attendance.manage") },
|
{ preHandler: requirePermission("attendance.manage") },
|
||||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const id = parseId((request.params as any).id, reply);
|
const id = parseId((request.params as { id: string }).id, reply);
|
||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
const body = parseBody(UpdatePlanOverrideSchema, request.body);
|
const body = parseBody(UpdatePlanOverrideSchema, request.body);
|
||||||
if ("error" in body) return error(reply, body.error, 400);
|
if ("error" in body) return error(reply, body.error, 400);
|
||||||
@@ -405,7 +405,7 @@ export default async function planRoutes(app: FastifyInstance) {
|
|||||||
"/overrides/:id",
|
"/overrides/:id",
|
||||||
{ preHandler: requirePermission("attendance.manage") },
|
{ preHandler: requirePermission("attendance.manage") },
|
||||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const id = parseId((request.params as any).id, reply);
|
const id = parseId((request.params as { id: string }).id, reply);
|
||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
const force = forceFromQuery(request.query);
|
const force = forceFromQuery(request.query);
|
||||||
const result = await deleteOverride(id, request.authData!.userId, force);
|
const result = await deleteOverride(id, request.authData!.userId, force);
|
||||||
|
|||||||
@@ -89,7 +89,11 @@ export default async function projectsRoutes(
|
|||||||
|
|
||||||
const result = await createProject(parsed.data);
|
const result = await createProject(parsed.data);
|
||||||
if ("error" in result)
|
if ("error" in result)
|
||||||
return error(reply, result.error, (result as any).status ?? 400);
|
return error(
|
||||||
|
reply,
|
||||||
|
result.error,
|
||||||
|
(result as { status?: number }).status ?? 400,
|
||||||
|
);
|
||||||
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
request,
|
request,
|
||||||
@@ -127,7 +131,11 @@ export default async function projectsRoutes(
|
|||||||
const result = await updateProject(id, parsed.data);
|
const result = await updateProject(id, parsed.data);
|
||||||
if (!result) return error(reply, "Projekt nenalezen", 404);
|
if (!result) return error(reply, "Projekt nenalezen", 404);
|
||||||
if ("error" in result) {
|
if ("error" in result) {
|
||||||
return error(reply, result.error, (result as any).status ?? 400);
|
return error(
|
||||||
|
reply,
|
||||||
|
result.error,
|
||||||
|
(result as { status?: number }).status ?? 400,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
@@ -160,7 +168,11 @@ export default async function projectsRoutes(
|
|||||||
content: parsed.data.content ?? undefined,
|
content: parsed.data.content ?? undefined,
|
||||||
});
|
});
|
||||||
if (note && "error" in note) {
|
if (note && "error" in note) {
|
||||||
return error(reply, note.error, (note as any).status ?? 400);
|
return error(
|
||||||
|
reply,
|
||||||
|
note.error,
|
||||||
|
(note as { status?: number }).status ?? 400,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export default async function sessionsRoutes(
|
|||||||
"/:id",
|
"/:id",
|
||||||
{ preHandler: requireAuth },
|
{ preHandler: requireAuth },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseId((request.params as any).id, reply);
|
const id = parseId(request.params.id, reply);
|
||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
const authData = request.authData!;
|
const authData = request.authData!;
|
||||||
|
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ export default async function tripsRoutes(
|
|||||||
"/last-km/:vehicleId",
|
"/last-km/:vehicleId",
|
||||||
{ preHandler: requirePermission("trips.manage") },
|
{ preHandler: requirePermission("trips.manage") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const vehicleId = parseId((request.params as any).vehicleId, reply);
|
const vehicleId = parseId(request.params.vehicleId, reply);
|
||||||
if (vehicleId === null) return;
|
if (vehicleId === null) return;
|
||||||
|
|
||||||
const [lastTrip, vehicle] = await Promise.all([
|
const [lastTrip, vehicle] = await Promise.all([
|
||||||
@@ -373,7 +373,7 @@ export default async function tripsRoutes(
|
|||||||
"/:id",
|
"/:id",
|
||||||
{ preHandler: requireAnyPermission("trips.manage", "trips.record") },
|
{ preHandler: requireAnyPermission("trips.manage", "trips.record") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseId((request.params as any).id, reply);
|
const id = parseId(request.params.id, reply);
|
||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
const parsed = parseBody(UpdateTripSchema, request.body);
|
const parsed = parseBody(UpdateTripSchema, request.body);
|
||||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||||
@@ -461,7 +461,7 @@ export default async function tripsRoutes(
|
|||||||
"/:id",
|
"/:id",
|
||||||
{ preHandler: requireAnyPermission("trips.manage", "trips.record") },
|
{ preHandler: requireAnyPermission("trips.manage", "trips.record") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseId((request.params as any).id, reply);
|
const id = parseId(request.params.id, reply);
|
||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
const authData = request.authData!;
|
const authData = request.authData!;
|
||||||
const existing = await prisma.trips.findUnique({ where: { id } });
|
const existing = await prisma.trips.findUnique({ where: { id } });
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const InvoiceItemSchema = z.object({
|
|||||||
quantity: positiveNumberFromForm.default(1),
|
quantity: positiveNumberFromForm.default(1),
|
||||||
unit: z.string().max(20).nullish(),
|
unit: z.string().max(20).nullish(),
|
||||||
unit_price: numberFromForm.default(0),
|
unit_price: numberFromForm.default(0),
|
||||||
|
// Per-item discount as a percentage (0–100); column is Decimal(5,2).
|
||||||
|
discount: numberInRange(0, 100).optional().default(0),
|
||||||
vat_rate: numberInRange(0, 100).optional().default(21.0),
|
vat_rate: numberInRange(0, 100).optional().default(21.0),
|
||||||
position: nonNegativeNumberFromForm.optional(),
|
position: nonNegativeNumberFromForm.optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
nonNegativeIntFromForm,
|
nonNegativeIntFromForm,
|
||||||
nonNegativeNumberFromForm,
|
nonNegativeNumberFromForm,
|
||||||
positiveNumberFromForm,
|
positiveNumberFromForm,
|
||||||
|
numberInRange,
|
||||||
nullableIntIdFromForm,
|
nullableIntIdFromForm,
|
||||||
booleanFromForm,
|
booleanFromForm,
|
||||||
isoDateString,
|
isoDateString,
|
||||||
@@ -17,6 +18,8 @@ const QuotationItemSchema = z.object({
|
|||||||
quantity: positiveNumberFromForm.default(1),
|
quantity: positiveNumberFromForm.default(1),
|
||||||
unit: z.string().max(20).nullish(),
|
unit: z.string().max(20).nullish(),
|
||||||
unit_price: nonNegativeNumberFromForm.default(0),
|
unit_price: nonNegativeNumberFromForm.default(0),
|
||||||
|
// Per-item discount as a percentage (0–100); column is Decimal(5,2).
|
||||||
|
discount: numberInRange(0, 100).optional().default(0),
|
||||||
is_included_in_total: booleanFromForm.optional().default(true),
|
is_included_in_total: booleanFromForm.optional().default(true),
|
||||||
// Int column — a float position would 500 at Prisma.
|
// Int column — a float position would 500 at Prisma.
|
||||||
position: nonNegativeIntFromForm.optional(),
|
position: nonNegativeIntFromForm.optional(),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Fastify from "fastify";
|
import Fastify from "fastify";
|
||||||
|
import type { IncomingMessage, ServerResponse } from "http";
|
||||||
import type { ScheduledTask } from "node-cron";
|
import type { ScheduledTask } from "node-cron";
|
||||||
import cors from "@fastify/cors";
|
import cors from "@fastify/cors";
|
||||||
import cookie from "@fastify/cookie";
|
import cookie from "@fastify/cookie";
|
||||||
@@ -167,13 +168,26 @@ async function start() {
|
|||||||
|
|
||||||
// --- Frontend: Vite dev middleware (dev only) ---
|
// --- Frontend: Vite dev middleware (dev only) ---
|
||||||
if (!config.isProduction) {
|
if (!config.isProduction) {
|
||||||
|
// `vite` is a dev-only dependency loaded via a runtime import the bundler
|
||||||
|
// can't see (the Function() trick), so its types aren't resolvable under the
|
||||||
|
// server's moduleResolution — describe just the slice we use.
|
||||||
|
type ViteDevServerLike = {
|
||||||
|
middlewares: (
|
||||||
|
req: IncomingMessage,
|
||||||
|
res: ServerResponse,
|
||||||
|
next: (err?: Error) => void,
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
type ViteModuleLike = {
|
||||||
|
createServer: (opts: {
|
||||||
|
server: { middlewareMode: boolean };
|
||||||
|
appType: string;
|
||||||
|
}) => Promise<ViteDevServerLike>;
|
||||||
|
};
|
||||||
const viteModule = await (Function(
|
const viteModule = await (Function(
|
||||||
'return import("vite")',
|
'return import("vite")',
|
||||||
)() as Promise<any>);
|
)() as Promise<ViteModuleLike>);
|
||||||
const createViteServer = viteModule.createServer as (
|
const vite = await viteModule.createServer({
|
||||||
opts: any,
|
|
||||||
) => Promise<any>;
|
|
||||||
const vite = await createViteServer({
|
|
||||||
server: { middlewareMode: true },
|
server: { middlewareMode: true },
|
||||||
appType: "spa",
|
appType: "spa",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -523,6 +523,8 @@ export async function extractInvoice(
|
|||||||
return JSON.parse(text) as ExtractedInvoice;
|
return JSON.parse(text) as ExtractedInvoice;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[ai.service] extractInvoice: model reply was not JSON", e);
|
console.error("[ai.service] extractInvoice: model reply was not JSON", e);
|
||||||
throw new Error("Model nevrátil platná data faktury (neplatný JSON)");
|
throw new Error("Model nevrátil platná data faktury (neplatný JSON)", {
|
||||||
|
cause: e,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ async function fetchRatesForDate(
|
|||||||
// an entry that is past TTL — better to serve yesterday's rate than fail).
|
// an entry that is past TTL — better to serve yesterday's rate than fail).
|
||||||
const stale = rateCache.get(key);
|
const stale = rateCache.get(key);
|
||||||
if (stale) return stale.rates;
|
if (stale) return stale.rates;
|
||||||
throw new Error("Nepodařilo se získat aktuální kurzy z ČNB");
|
throw new Error("Nepodařilo se získat aktuální kurzy z ČNB", {
|
||||||
|
cause: err,
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
inflight.delete(key);
|
inflight.delete(key);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import prisma from "../config/database";
|
import prisma from "../config/database";
|
||||||
import { utcMidnightOfLocalDay } from "../utils/date";
|
import { utcMidnightOfLocalDay } from "../utils/date";
|
||||||
|
import { lineNet } from "../utils/money";
|
||||||
import { toCzk } from "./exchange-rates";
|
import { toCzk } from "./exchange-rates";
|
||||||
import {
|
import {
|
||||||
generateInvoiceNumber,
|
generateInvoiceNumber,
|
||||||
@@ -31,6 +32,8 @@ interface InvoiceItemInput {
|
|||||||
quantity?: number;
|
quantity?: number;
|
||||||
unit?: string | null;
|
unit?: string | null;
|
||||||
unit_price?: number;
|
unit_price?: number;
|
||||||
|
/** Per-item discount as a percentage (0–100). */
|
||||||
|
discount?: number;
|
||||||
vat_rate?: number;
|
vat_rate?: number;
|
||||||
position?: number;
|
position?: number;
|
||||||
}
|
}
|
||||||
@@ -165,14 +168,24 @@ function buildInvoiceWhere(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function computeInvoiceTotals(
|
function computeInvoiceTotals(
|
||||||
items: Array<{ quantity: unknown; unit_price: unknown; vat_rate: unknown }>,
|
items: Array<{
|
||||||
|
quantity: unknown;
|
||||||
|
unit_price: unknown;
|
||||||
|
discount?: unknown;
|
||||||
|
vat_rate: unknown;
|
||||||
|
}>,
|
||||||
applyVat: boolean | null,
|
applyVat: boolean | null,
|
||||||
defaultVatRate: unknown,
|
defaultVatRate: unknown,
|
||||||
) {
|
) {
|
||||||
const { subtotal, vat: vatAmount } = computeMoney(
|
const { subtotal, vat: vatAmount } = computeMoney(
|
||||||
items,
|
items,
|
||||||
applyVat,
|
applyVat,
|
||||||
(i) => (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
|
(i) =>
|
||||||
|
lineNet(
|
||||||
|
Number(i.quantity) || 0,
|
||||||
|
Number(i.unit_price) || 0,
|
||||||
|
Number(i.discount),
|
||||||
|
),
|
||||||
// Preserve original semantics: a line vat_rate of 0 (non-null, non-"") IS used.
|
// Preserve original semantics: a line vat_rate of 0 (non-null, non-"") IS used.
|
||||||
(i) =>
|
(i) =>
|
||||||
i.vat_rate != null && i.vat_rate !== ""
|
i.vat_rate != null && i.vat_rate !== ""
|
||||||
@@ -305,6 +318,7 @@ export function invoiceTotalWithVat(inv: {
|
|||||||
invoice_items: Array<{
|
invoice_items: Array<{
|
||||||
quantity: { toNumber(): number } | null;
|
quantity: { toNumber(): number } | null;
|
||||||
unit_price: { toNumber(): number } | null;
|
unit_price: { toNumber(): number } | null;
|
||||||
|
discount?: { toNumber(): number } | null;
|
||||||
vat_rate: { toNumber(): number } | null;
|
vat_rate: { toNumber(): number } | null;
|
||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
@@ -312,8 +326,11 @@ export function invoiceTotalWithVat(inv: {
|
|||||||
inv.invoice_items,
|
inv.invoice_items,
|
||||||
inv.apply_vat,
|
inv.apply_vat,
|
||||||
(i) =>
|
(i) =>
|
||||||
(Number(i.quantity?.toNumber()) || 0) *
|
lineNet(
|
||||||
(Number(i.unit_price?.toNumber()) || 0),
|
Number(i.quantity?.toNumber()) || 0,
|
||||||
|
Number(i.unit_price?.toNumber()) || 0,
|
||||||
|
i.discount?.toNumber(),
|
||||||
|
),
|
||||||
// Preserve original `||` semantics: a line rate of 0/NaN falls through to
|
// Preserve original `||` semantics: a line rate of 0/NaN falls through to
|
||||||
// the invoice default, then 21.
|
// the invoice default, then 21.
|
||||||
(i) =>
|
(i) =>
|
||||||
@@ -401,7 +418,12 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
|
|||||||
const { vat } = computeMoney(
|
const { vat } = computeMoney(
|
||||||
inv.invoice_items,
|
inv.invoice_items,
|
||||||
inv.apply_vat,
|
inv.apply_vat,
|
||||||
(item) => (Number(item.quantity) || 0) * (Number(item.unit_price) || 0),
|
(item) =>
|
||||||
|
lineNet(
|
||||||
|
Number(item.quantity) || 0,
|
||||||
|
Number(item.unit_price) || 0,
|
||||||
|
Number(item.discount),
|
||||||
|
),
|
||||||
(item) => Number(item.vat_rate) || Number(inv.vat_rate) || 21,
|
(item) => Number(item.vat_rate) || Number(inv.vat_rate) || 21,
|
||||||
false, // accumulate raw per-line VAT; round only the per-currency sum
|
false, // accumulate raw per-line VAT; round only the per-currency sum
|
||||||
);
|
);
|
||||||
@@ -551,6 +573,7 @@ export async function createInvoice(body: InvoiceInput) {
|
|||||||
quantity: item.quantity ?? 1,
|
quantity: item.quantity ?? 1,
|
||||||
unit: item.unit ?? null,
|
unit: item.unit ?? null,
|
||||||
unit_price: item.unit_price ?? 0,
|
unit_price: item.unit_price ?? 0,
|
||||||
|
discount: item.discount ?? 0,
|
||||||
vat_rate: item.vat_rate ?? 21.0,
|
vat_rate: item.vat_rate ?? 21.0,
|
||||||
position: item.position ?? i,
|
position: item.position ?? i,
|
||||||
})),
|
})),
|
||||||
@@ -688,6 +711,7 @@ export async function updateInvoice(id: number, body: InvoiceInput) {
|
|||||||
quantity: item.quantity ?? 1,
|
quantity: item.quantity ?? 1,
|
||||||
unit: item.unit ?? null,
|
unit: item.unit ?? null,
|
||||||
unit_price: item.unit_price ?? 0,
|
unit_price: item.unit_price ?? 0,
|
||||||
|
discount: item.discount ?? 0,
|
||||||
vat_rate: item.vat_rate ?? 21.0,
|
vat_rate: item.vat_rate ?? 21.0,
|
||||||
position: item.position ?? i,
|
position: item.position ?? i,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import prisma from "../config/database";
|
import prisma from "../config/database";
|
||||||
|
import { lineNet } from "../utils/money";
|
||||||
import {
|
import {
|
||||||
generateOfferNumber,
|
generateOfferNumber,
|
||||||
releaseOfferNumber,
|
releaseOfferNumber,
|
||||||
@@ -14,6 +15,8 @@ interface QuotationItemInput {
|
|||||||
quantity?: number;
|
quantity?: number;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
unit_price?: number;
|
unit_price?: number;
|
||||||
|
/** Per-item discount as a percentage (0–100). */
|
||||||
|
discount?: number;
|
||||||
is_included_in_total?: boolean;
|
is_included_in_total?: boolean;
|
||||||
position?: number;
|
position?: number;
|
||||||
}
|
}
|
||||||
@@ -115,13 +118,36 @@ function buildOfferWhere(params: OfferFilterParams): Record<string, unknown> {
|
|||||||
return where;
|
return where;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Structural shape for enrichQuotation — it's called with different selects
|
||||||
|
// (full include vs. the minimal totals select), so a single Prisma payload type
|
||||||
|
// doesn't fit. Only the fields actually read are pinned; the rest is spread.
|
||||||
|
type EnrichableLineItem = {
|
||||||
|
is_included_in_total?: unknown;
|
||||||
|
quantity?: unknown;
|
||||||
|
unit_price?: unknown;
|
||||||
|
discount?: unknown;
|
||||||
|
};
|
||||||
|
type EnrichableQuotation = {
|
||||||
|
quotation_items: EnrichableLineItem[];
|
||||||
|
scope_sections?: unknown;
|
||||||
|
customers?: { name?: string | null } | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
// Offers are NOT tax documents — totals are NET only (no VAT anywhere).
|
// Offers are NOT tax documents — totals are NET only (no VAT anywhere).
|
||||||
function enrichQuotation(q: any) {
|
// Generic over the concrete payload so `...rest` keeps every field the caller's
|
||||||
|
// select actually returned (the return type must stay as rich as the input).
|
||||||
|
function enrichQuotation<T extends EnrichableQuotation>(q: T) {
|
||||||
const total = q.quotation_items
|
const total = q.quotation_items
|
||||||
.filter((i: any) => i.is_included_in_total !== false)
|
.filter((i) => i.is_included_in_total !== false)
|
||||||
.reduce(
|
.reduce(
|
||||||
(s: number, i: any) =>
|
(s: number, i) =>
|
||||||
s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
|
s +
|
||||||
|
lineNet(
|
||||||
|
Number(i.quantity) || 0,
|
||||||
|
Number(i.unit_price) || 0,
|
||||||
|
Number(i.discount),
|
||||||
|
),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const { quotation_items, scope_sections, ...rest } = q;
|
const { quotation_items, scope_sections, ...rest } = q;
|
||||||
@@ -260,7 +286,7 @@ export async function getOffer(id: number) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createOffer(body: Record<string, any>) {
|
export async function createOffer(body: Record<string, unknown>) {
|
||||||
try {
|
try {
|
||||||
return await prisma.$transaction(async (tx) => {
|
return await prisma.$transaction(async (tx) => {
|
||||||
const status = body.status ? String(body.status) : "draft";
|
const status = body.status ? String(body.status) : "draft";
|
||||||
@@ -328,6 +354,7 @@ export async function createOffer(body: Record<string, any>) {
|
|||||||
quantity: item.quantity ?? 1,
|
quantity: item.quantity ?? 1,
|
||||||
unit: item.unit ?? null,
|
unit: item.unit ?? null,
|
||||||
unit_price: item.unit_price ?? 0,
|
unit_price: item.unit_price ?? 0,
|
||||||
|
discount: item.discount ?? 0,
|
||||||
is_included_in_total: item.is_included_in_total !== false,
|
is_included_in_total: item.is_included_in_total !== false,
|
||||||
position: item.position ?? i,
|
position: item.position ?? i,
|
||||||
})),
|
})),
|
||||||
@@ -362,7 +389,7 @@ export async function createOffer(body: Record<string, any>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateOffer(id: number, body: Record<string, any>) {
|
export async function updateOffer(id: number, body: Record<string, unknown>) {
|
||||||
const existing = await prisma.quotations.findUnique({ where: { id } });
|
const existing = await prisma.quotations.findUnique({ where: { id } });
|
||||||
if (!existing) return { error: "not_found" as const };
|
if (!existing) return { error: "not_found" as const };
|
||||||
|
|
||||||
@@ -463,6 +490,7 @@ export async function updateOffer(id: number, body: Record<string, any>) {
|
|||||||
quantity: item.quantity ?? 1,
|
quantity: item.quantity ?? 1,
|
||||||
unit: item.unit ?? null,
|
unit: item.unit ?? null,
|
||||||
unit_price: item.unit_price ?? 0,
|
unit_price: item.unit_price ?? 0,
|
||||||
|
discount: item.discount ?? 0,
|
||||||
is_included_in_total: item.is_included_in_total !== false,
|
is_included_in_total: item.is_included_in_total !== false,
|
||||||
position: item.position ?? i,
|
position: item.position ?? i,
|
||||||
})),
|
})),
|
||||||
@@ -585,6 +613,7 @@ export async function duplicateOffer(id: number) {
|
|||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
unit: item.unit,
|
unit: item.unit,
|
||||||
unit_price: item.unit_price,
|
unit_price: item.unit_price,
|
||||||
|
discount: item.discount,
|
||||||
is_included_in_total: item.is_included_in_total,
|
is_included_in_total: item.is_included_in_total,
|
||||||
position: item.position,
|
position: item.position,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -79,11 +79,36 @@ async function syncProjectStatus(
|
|||||||
// silently 0 in the per-currency totals.
|
// silently 0 in the per-currency totals.
|
||||||
//
|
//
|
||||||
// Orders are NOT tax documents — totals are NET only (no VAT anywhere).
|
// Orders are NOT tax documents — totals are NET only (no VAT anywhere).
|
||||||
function enrichOrder(o: any) {
|
// Structural shape for enrichOrder — like enrichQuotation it runs over several
|
||||||
|
// different selects (full include vs. the minimal totals select), so only the
|
||||||
|
// fields actually read are pinned; everything else is spread through.
|
||||||
|
type EnrichableLineItem = {
|
||||||
|
is_included_in_total?: unknown;
|
||||||
|
quantity?: unknown;
|
||||||
|
unit_price?: unknown;
|
||||||
|
};
|
||||||
|
type EnrichableOrder = {
|
||||||
|
order_items: EnrichableLineItem[];
|
||||||
|
order_sections?: unknown;
|
||||||
|
invoices?: Array<{
|
||||||
|
id?: number | null;
|
||||||
|
invoice_number?: string | null;
|
||||||
|
}> | null;
|
||||||
|
customers?: { name?: string | null } | null;
|
||||||
|
quotations?: {
|
||||||
|
quotation_number?: string | null;
|
||||||
|
project_code?: string | null;
|
||||||
|
} | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generic over the concrete payload so `...rest` keeps every field the caller's
|
||||||
|
// select actually returned (the return type must stay as rich as the input).
|
||||||
|
function enrichOrder<T extends EnrichableOrder>(o: T) {
|
||||||
const total = o.order_items
|
const total = o.order_items
|
||||||
.filter((i: any) => i.is_included_in_total !== false)
|
.filter((i) => i.is_included_in_total !== false)
|
||||||
.reduce(
|
.reduce(
|
||||||
(s: number, i: any) =>
|
(s: number, i) =>
|
||||||
s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
|
s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -680,6 +680,11 @@ export async function deleteEntry(
|
|||||||
// Override CRUD
|
// Override CRUD
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** The work_plan_overrides row shape, derived straight from the Prisma client. */
|
||||||
|
type WorkPlanOverride = Awaited<
|
||||||
|
ReturnType<typeof prisma.work_plan_overrides.create>
|
||||||
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a work_plan_overrides row. Additive: stacks up to
|
* Create a work_plan_overrides row. Additive: stacks up to
|
||||||
* MAX_RECORDS_PER_CELL active overrides per (user_id, shift_date); a create
|
* MAX_RECORDS_PER_CELL active overrides per (user_id, shift_date); a create
|
||||||
@@ -696,7 +701,7 @@ export async function createOverride(
|
|||||||
},
|
},
|
||||||
actorUserId: number,
|
actorUserId: number,
|
||||||
force: boolean,
|
force: boolean,
|
||||||
): Promise<Result<any>> {
|
): Promise<Result<WorkPlanOverride>> {
|
||||||
const lock = assertNotPastDate(input.shift_date, force);
|
const lock = assertNotPastDate(input.shift_date, force);
|
||||||
if (lock) return lock;
|
if (lock) return lock;
|
||||||
|
|
||||||
@@ -762,7 +767,7 @@ export async function updateOverride(
|
|||||||
input: { project_id?: number | null; category?: string; note?: string },
|
input: { project_id?: number | null; category?: string; note?: string },
|
||||||
actorUserId: number,
|
actorUserId: number,
|
||||||
force: boolean,
|
force: boolean,
|
||||||
): Promise<Result<any>> {
|
): Promise<Result<WorkPlanOverride>> {
|
||||||
const existing = await prisma.work_plan_overrides.findUnique({
|
const existing = await prisma.work_plan_overrides.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export async function getProject(id: number) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProject(id: number, body: Record<string, any>) {
|
export async function updateProject(id: number, body: Record<string, unknown>) {
|
||||||
const existing = await prisma.projects.findUnique({ where: { id } });
|
const existing = await prisma.projects.findUnique({ where: { id } });
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export async function listUsers(params: ListUsersParams) {
|
|||||||
const { page, limit, skip, sort, order, search, permission } = params;
|
const { page, limit, skip, sort, order, search, permission } = params;
|
||||||
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
|
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
|
||||||
|
|
||||||
const where: any = search
|
const where: Prisma.usersWhereInput = search
|
||||||
? {
|
? {
|
||||||
OR: [
|
OR: [
|
||||||
{ username: { contains: search } },
|
{ username: { contains: search } },
|
||||||
|
|||||||
17
src/utils/money.ts
Normal file
17
src/utils/money.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Net amount of a single document line after its per-item percentage discount
|
||||||
|
* ("Sleva"). `net = qty × unit_price × (1 − discount/100)`.
|
||||||
|
*
|
||||||
|
* The discount is tolerant of null/undefined/NaN (a missing discount = none)
|
||||||
|
* and clamped to 0–100 so a stray value can never make the net negative.
|
||||||
|
* Single source of truth for offers + invoices (totals, VAT base, PDFs).
|
||||||
|
*/
|
||||||
|
export function lineNet(
|
||||||
|
qty: number,
|
||||||
|
unitPrice: number,
|
||||||
|
discountPct: number | null | undefined,
|
||||||
|
): number {
|
||||||
|
const pct = Number(discountPct);
|
||||||
|
const safePct = Number.isFinite(pct) ? Math.min(100, Math.max(0, pct)) : 0;
|
||||||
|
return qty * unitPrice * (1 - safePct / 100);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user