Compare commits

...

4 Commits

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:40:06 +02:00
11 changed files with 120 additions and 32 deletions

View File

@@ -520,13 +520,19 @@ The 2026-06-09 file-by-file audit traced most bugs to a handful of patterns. The
2. `npm run build`
3. Commit and tag (`git tag -a vX.Y.Z`)
4. Push to Gitea (`git push origin master && git push origin vX.Y.Z`)
5. Create tarball: `tar -czf app-ts-X.Y.Z.tar.gz dist dist-client prisma package.json package-lock.json scripts`
5. Create tarball: `tar -czf app-ts-X.Y.Z.tar.gz dist dist-client prisma prisma.config.ts package.json package-lock.json scripts`
(⚠️ `prisma.config.ts` is REQUIRED — Prisma 7 keeps the datasource URL there;
without it, `prisma generate`/`migrate deploy` on prod have no datasource)
6. Deploy via SSH to production server (`boha_admin@192.168.50.100`):
- Path: `/var/www/app-ts`
- Remove old files: `rm -rf dist dist-client prisma scripts package.json package-lock.json`
- Remove old files: `rm -rf dist dist-client prisma prisma.config.ts scripts package.json package-lock.json`
- Copy tarball to server: `scp app-ts-X.Y.Z.tar.gz boha_admin@192.168.50.100:/tmp/`
- Extract tarball: `tar -xzf /tmp/app-ts-X.Y.Z.tar.gz`
- Install dependencies: `npm install --omit=dev`
- Regenerate the Prisma client: `npx prisma generate`**MANDATORY**.
`npm install` skips regeneration when dependencies didn't change, leaving a
stale client that still selects dropped/renamed columns → P2022 500s in
prod (bit the v2.4.0 supplier release).
- Apply Prisma migrations: `npx prisma migrate deploy`
- Restart: `pm2 restart app-ts --update-env`

4
package-lock.json generated
View File

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

View File

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

View File

@@ -507,6 +507,35 @@ describe("renderIssuedOrderHtml", () => {
expect(html).not.toContain("alert(1)");
});
it("with VAT: keeps the VAT columns and the DPH cell holds ONLY the line VAT", () => {
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
expect(html).toContain("%DPH");
expect(html).toContain(">DPH<");
// 2 × 100 @ 21 %: DPH cell = 42,00 (VAT only), Celkem cell = 242,00.
expect(html).toContain('<td class="right">42,00</td>');
expect(html).toContain('<td class="right total-cell">242,00</td>');
expect(html).not.toContain("Celkem bez DPH");
});
it("without VAT: hides the VAT columns and labels the total 'Celkem bez DPH'", () => {
const html = renderIssuedOrderHtml(
{ ...order, apply_vat: false },
items,
null,
null,
"cs",
issuer,
);
expect(html).not.toContain("%DPH");
expect(html).not.toContain(">DPH<");
// No per-line VAT cell, line total = netto.
expect(html).not.toContain('<td class="right">42,00</td>');
expect(html).toContain('<td class="right total-cell">200,00</td>');
expect(html).toContain("Celkem bez DPH");
// The subtotal detail row is dropped (it would duplicate the grand total).
expect(html).not.toContain("Mezisoučet");
});
it("footer shows the logged-in user's name, no e-mail, no Schválil column", () => {
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
// Footer: Vystavil <name> from authData. No e-mail line.

View File

@@ -1048,6 +1048,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
if (result.success) {
setShowCreateModal(false);
queryClient.invalidateQueries({ queryKey: ["attendance"] });
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
// punch-button state) — without this it serves a stale cache for up
// to its staleTime after records change here.
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
@@ -1120,6 +1124,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
if (result.success) {
setShowBulkModal(false);
queryClient.invalidateQueries({ queryKey: ["attendance"] });
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
// punch-button state) — without this it serves a stale cache for up
// to its staleTime after records change here.
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
@@ -1255,6 +1263,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
if (result.success) {
setShowEditModal(false);
queryClient.invalidateQueries({ queryKey: ["attendance"] });
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
// punch-button state) — without this it serves a stale cache for up
// to its staleTime after records change here.
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
@@ -1285,6 +1297,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
if (result.success) {
setDeleteConfirm({ show: false, record: null });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
// punch-button state) — without this it serves a stale cache for up
// to its staleTime after records change here.
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
await fetchData(false);
alert.success(
result.message || result.data?.message || "Záznam smazán",

View File

@@ -6,6 +6,11 @@ export const dashboardOptions = () =>
queryKey: ["dashboard"],
queryFn: () => jsonQuery<Record<string, unknown>>("/api/admin/dashboard"),
staleTime: 60_000,
// The dashboard aggregates MANY domains (attendance, offers, invoices,
// orders, projects, leave). Mutations in those domains can't all be
// expected to invalidate ["dashboard"], so always refetch on mount —
// navigating back to the dashboard must never show pre-mutation data.
refetchOnMount: "always",
});
// require2FAOptions lives in ./settings.ts (the single definition consumers

View File

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

View File

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

View File

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

View File

@@ -207,6 +207,7 @@ const translations: Record<Lang, Record<string, string>> = {
subtotal: "Mezisoučet:",
vat_label: "DPH",
total: "Celkem",
total_no_vat: "Celkem bez DPH",
amounts_in: "Částky jsou uvedeny v",
notes: "Poznámky",
delivery_terms: "Dodací podmínky:",
@@ -234,6 +235,7 @@ const translations: Record<Lang, Record<string, string>> = {
subtotal: "Subtotal:",
vat_label: "VAT",
total: "Total",
total_no_vat: "Total excl. VAT",
amounts_in: "Amounts are in",
notes: "Notes",
delivery_terms: "Delivery terms:",
@@ -336,14 +338,19 @@ export function renderIssuedOrderHtml(
? `<div class="item-sub">${escapeHtml(it.item_description)}</div>`
: ""
}`;
// Without "Uplatnit DPH" the VAT columns are dropped entirely (the
// header does the same) instead of printing meaningless 0% / 0.00.
const vatCells = applyVat
? `
<td class="center">${Math.floor(rate)}%</td>
<td class="right">${formatNum(lineVat)}</td>`
: "";
return `<tr>
<td class="row-num">${i + 1}</td>
<td class="desc">${descHtml}</td>
<td class="center">${formatNum(qty, qtyDecimals)}${it.unit ? ` / ${escapeHtml(it.unit)}` : ""}</td>
<td class="right">${formatNum(unitPrice)}</td>
<td class="right">${formatNum(lineSubtotal)}</td>
<td class="center">${applyVat ? Math.floor(rate) : 0}%</td>
<td class="right">${formatNum(lineVat)}</td>
<td class="right">${formatNum(lineSubtotal)}</td>${vatCells}
<td class="right total-cell">${formatNum(lineTotal)}</td>
</tr>`;
})
@@ -769,13 +776,17 @@ ${indentCSS}
<thead>
<tr>
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
<th style="width:36%">${escapeHtml(t.col_desc)}</th>
<th style="width:${applyVat ? 36 : 46}%">${escapeHtml(t.col_desc)}</th>
<th class="center" style="width:10%">${escapeHtml(t.col_qty)}</th>
<th class="right" style="width:10%">${escapeHtml(t.col_unit_price)}</th>
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>${
applyVat
? `
<th class="center" style="width:5%">${escapeHtml(t.col_vat_pct)}</th>
<th class="right" style="width:10%">${escapeHtml(t.col_vat)}</th>
<th class="right" style="width:16%">${escapeHtml(t.col_total)}</th>
<th class="right" style="width:10%">${escapeHtml(t.col_vat)}</th>`
: ""
}
<th class="right" style="width:${applyVat ? 16 : 21}%">${escapeHtml(t.col_total)}</th>
</tr>
</thead>
<tbody>
@@ -783,17 +794,21 @@ ${indentCSS}
</tbody>
</table>
<!-- Soucty -->
<!-- Soucty (bez DPH jen souhrnny radek - mezisoucet by ho jen opakoval) -->
<div class="totals-wrapper">
<div class="totals">
<div class="totals">${
applyVat
? `
<div class="detail-rows">
<div class="row">
<span class="label">${escapeHtml(t.subtotal)}</span>
<span class="value">${formatNum(subtotal)} ${escapeHtml(currency)}</span>
</div>${vatDetailHtml}
</div>
</div>`
: ""
}
<div class="grand">
<span class="label">${escapeHtml(t.total)}</span>
<span class="label">${escapeHtml(applyVat ? t.total : t.total_no_vat)}</span>
<span class="value">${formatNum(totalToPay)} ${escapeHtml(currency)}</span>
</div>
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>

View File

@@ -200,6 +200,7 @@ const translations: Record<string, Record<string, string>> = {
subtotal: "Mezisoučet:",
vat_label: "DPH",
total: "Celkem",
total_no_vat: "Celkem bez DPH",
amounts_in: "Částky jsou uvedeny v",
notes: "Poznámky",
issued_by: "Vystavil:",
@@ -228,6 +229,7 @@ const translations: Record<string, Record<string, string>> = {
subtotal: "Subtotal:",
vat_label: "VAT",
total: "Total",
total_no_vat: "Total excl. VAT",
amounts_in: "Amounts are in",
notes: "Notes",
issued_by: "Issued by:",
@@ -388,14 +390,19 @@ export default async function ordersPdfRoutes(
const lineTotal = lineSubtotal + lineVat;
const qtyDecimals =
Math.floor(item.quantity) === item.quantity ? 0 : 2;
// Without "Uplatnit DPH" the VAT columns are dropped entirely
// (the header does the same) instead of printing 0% / 0.00.
const vatCells = applyVat
? `
<td class="center">${Math.floor(item.vat_rate)}%</td>
<td class="right">${formatNum(lineVat)}</td>`
: "";
return `<tr>
<td class="row-num">${i + 1}</td>
<td class="desc">${escapeHtml(item.description)}</td>
<td class="center">${formatNum(item.quantity, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
<td class="right">${formatNum(item.unit_price)}</td>
<td class="right">${formatNum(lineSubtotal)}</td>
<td class="center">${applyVat ? Math.floor(item.vat_rate) : 0}%</td>
<td class="right">${formatNum(lineVat)}</td>
<td class="right">${formatNum(lineSubtotal)}</td>${vatCells}
<td class="right total-cell">${formatNum(lineTotal)}</td>
</tr>`;
})
@@ -848,13 +855,17 @@ ${indentCSS}
<thead>
<tr>
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
<th style="width:36%">${escapeHtml(t.col_desc)}</th>
<th style="width:${applyVat ? 36 : 46}%">${escapeHtml(t.col_desc)}</th>
<th class="center" style="width:10%">${escapeHtml(t.col_qty)}</th>
<th class="right" style="width:10%">${escapeHtml(t.col_unit_price)}</th>
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>${
applyVat
? `
<th class="center" style="width:5%">${escapeHtml(t.col_vat_pct)}</th>
<th class="right" style="width:10%">${escapeHtml(t.col_vat)}</th>
<th class="right" style="width:16%">${escapeHtml(t.col_total)}</th>
<th class="right" style="width:10%">${escapeHtml(t.col_vat)}</th>`
: ""
}
<th class="right" style="width:${applyVat ? 16 : 21}%">${escapeHtml(t.col_total)}</th>
</tr>
</thead>
<tbody>
@@ -862,17 +873,21 @@ ${indentCSS}
</tbody>
</table>
<!-- Soucty -->
<!-- Soucty (bez DPH jen souhrnny radek - mezisoucet by ho jen opakoval) -->
<div class="totals-wrapper">
<div class="totals">
<div class="totals">${
applyVat
? `
<div class="detail-rows">
<div class="row">
<span class="label">${escapeHtml(t.subtotal)}</span>
<span class="value">${formatNum(subtotal)} ${escapeHtml(currency)}</span>
</div>${vatDetailHtml}
</div>
</div>`
: ""
}
<div class="grand">
<span class="label">${escapeHtml(t.total)}</span>
<span class="label">${escapeHtml(applyVat ? t.total : t.total_no_vat)}</span>
<span class="value">${formatNum(totalToPay)} ${escapeHtml(currency)}</span>
</div>
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>