chore(release): v2.4.35 — lint cleanup (68→0) + duplicate-Zavřít modal fix
UI fix:
- Close-only modals showing a redundant second close button now use
hideCancel: ReceivedInvoices paid-detail modal (was two identical "Zavřít"
buttons) and PlanCellModal "Den je součástí rozsahu".
- Add modal-duplicate-close test enforcing close-only modals set hideCancel.
Lint: cleared all 68 warnings → 0.
- preserve-caught-error: attach { cause } in ai.service / exchange-rates.
- no-require-imports: package.json version read via fs (APP_VERSION) instead
of require(), avoiding a rootDir-expanding static JSON import.
- react-hooks/exhaustive-deps (11): ref-in-cleanup copies, derived-value
useMemo wrapping, PlanGrid field extraction, stable nextKey useCallback,
AuthContext documented cycle-break.
- no-explicit-any (53): precise route param/Prisma types, generic enrich*()
preserving payload shape, minimal vite module type, frontend body/query-key
types, SystemInfo for Settings.
Refactor (test enablement): shift-form types moved to dependency-free
shiftFormTypes.ts so the print-HTML builders are unit-testable without the
component graph; characterization test pins their output.
Gates: 649 tests pass, tsc -b clean, lint 0. Verified touched flows live
via Playwright (PlanWork CRUD + optimistic cache, warehouse form keys,
Settings system info, invoice detail).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "2.4.34",
|
||||
"version": "2.4.35",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "app-ts",
|
||||
"version": "2.4.34",
|
||||
"version": "2.4.35",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.102.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "2.4.34",
|
||||
"version": "2.4.35",
|
||||
"description": "",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -488,6 +488,7 @@ function DayInRangeModal(
|
||||
onClose={onClose}
|
||||
onSubmit={onClose}
|
||||
submitText="Zavřít — ponechat rozsah beze změny"
|
||||
hideCancel
|
||||
maxWidth="md"
|
||||
>
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
|
||||
@@ -491,9 +491,11 @@ export default function PlanGrid({
|
||||
}, [projects]);
|
||||
// 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.
|
||||
const dateFrom = data?.date_from;
|
||||
const dateTo = data?.date_to;
|
||||
const days = useMemo(
|
||||
() => (data ? eachDay(data.date_from, data.date_to) : []),
|
||||
[data?.date_from, data?.date_to],
|
||||
() => (dateFrom && dateTo ? eachDay(dateFrom, dateTo) : []),
|
||||
[dateFrom, dateTo],
|
||||
);
|
||||
|
||||
if (!data) return <LoadingState />;
|
||||
|
||||
@@ -19,46 +19,22 @@ import {
|
||||
} from "../utils/attendanceHelpers";
|
||||
|
||||
// ---------- Shared types ----------
|
||||
|
||||
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;
|
||||
}
|
||||
// Definitions live in the dependency-free ./shiftFormTypes module; re-exported
|
||||
// here so existing importers of ShiftFormModal keep working.
|
||||
export type {
|
||||
ShiftFormData,
|
||||
ProjectLog,
|
||||
Project,
|
||||
User,
|
||||
EditingRecord,
|
||||
} from "./shiftFormTypes";
|
||||
import type {
|
||||
ShiftFormData,
|
||||
ProjectLog,
|
||||
Project,
|
||||
User,
|
||||
EditingRecord,
|
||||
} from "./shiftFormTypes";
|
||||
|
||||
// ---------- Sub-component props ----------
|
||||
|
||||
|
||||
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 MenuItem from "@mui/material/MenuItem";
|
||||
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
|
||||
// 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());
|
||||
|
||||
useEffect(() => {
|
||||
const timeouts = timeoutsRef.current;
|
||||
return () => {
|
||||
timeoutsRef.current.forEach(clearTimeout);
|
||||
timeoutsRef.current.clear();
|
||||
timeouts.forEach(clearTimeout);
|
||||
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,
|
||||
Project,
|
||||
User,
|
||||
} from "../components/ShiftFormModal";
|
||||
} from "../components/shiftFormTypes";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -344,14 +344,25 @@ function escapeHtml(str: string): string {
|
||||
.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) {
|
||||
return record.project_logs
|
||||
.map((log: any) => {
|
||||
.map((log) => {
|
||||
let h: number, m: number;
|
||||
if (log.hours !== null && log.hours !== undefined) {
|
||||
h = parseInt(log.hours) || 0;
|
||||
m = parseInt(log.minutes) || 0;
|
||||
// hours/minutes arrive as string or number — String() before parseInt
|
||||
// (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) {
|
||||
const mins = Math.max(
|
||||
0,
|
||||
@@ -374,13 +385,13 @@ function buildProjectLogsHtml(record: Record<string, any>): string {
|
||||
return escapeHtml(record.project_name || "—");
|
||||
}
|
||||
|
||||
function buildUserSectionHtml(
|
||||
export function buildUserSectionHtml(
|
||||
userId: string,
|
||||
userData: Record<string, any>,
|
||||
printData: Record<string, any>,
|
||||
userData: UserTotal,
|
||||
printData: PrintData,
|
||||
): string {
|
||||
// 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 || []) {
|
||||
recordsByDate.set(normalizeDateStr(r.shift_date), r);
|
||||
}
|
||||
@@ -391,7 +402,7 @@ function buildUserSectionHtml(
|
||||
const mo = parseInt(monthStr, 10);
|
||||
const daysInMonth = getDaysInMonth(yr, mo);
|
||||
|
||||
const userRecords = (userData.records || []) as Record<string, any>[];
|
||||
const userRecords = userData.records ?? [];
|
||||
|
||||
const rows: string[] = [];
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
@@ -588,8 +599,8 @@ function buildUserSectionHtml(
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function buildPrintHtml(
|
||||
pData: Record<string, any>,
|
||||
export function buildPrintHtml(
|
||||
pData: PrintData,
|
||||
userSections: string,
|
||||
emptyMsg: string,
|
||||
filterNote: string,
|
||||
@@ -1330,7 +1341,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
||||
const pData = result.data;
|
||||
const userSections = Object.entries(pData.user_totals)
|
||||
.map(([uid, uData]) =>
|
||||
buildUserSectionHtml(uid, uData as Record<string, any>, pData),
|
||||
buildUserSectionHtml(uid, uData as UserTotal, pData),
|
||||
)
|
||||
.join("");
|
||||
const emptyMsg =
|
||||
|
||||
@@ -200,7 +200,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
// Snapshot the currently-cached grid for a given key. Returns
|
||||
// `undefined` if there's nothing cached (we don't patch empty data).
|
||||
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
|
||||
@@ -312,7 +312,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
// `ctx` may be `undefined` (e.g. if onMutate itself threw) — guarded below.
|
||||
function rollbackCells(ctx: CellRollback | undefined) {
|
||||
if (!ctx || !ctx.rolled) return;
|
||||
const prev = qc.getQueryData<GridData>(ctx.key as any);
|
||||
const prev = qc.getQueryData<GridData>(ctx.key);
|
||||
if (!prev) return;
|
||||
const userPrev = prev.cells[ctx.userId] ?? {};
|
||||
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
|
||||
// looking for any cell with entryId === id (we already know
|
||||
// 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;
|
||||
if (grid) {
|
||||
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.
|
||||
// Look it up from the current grid: find the user that has a 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;
|
||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||
let entryRange: { from: string; to: string } | null = null;
|
||||
@@ -511,7 +511,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
onMutate: (vars): CellRollback => {
|
||||
// Find the user/date for this overrideId in the current grid, then
|
||||
// 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;
|
||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||
for (const [date, cells] of Object.entries(byDate)) {
|
||||
@@ -559,7 +559,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
method: "DELETE",
|
||||
}),
|
||||
onMutate: (vars): CellRollback => {
|
||||
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
||||
const grid = qc.getQueryData<GridData>(currentGridKey);
|
||||
if (!grid) return null;
|
||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||
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
|
||||
// `mutateAsync` promise rejects, the mutation's own `onError` has already
|
||||
// restored the patched cells, so there is nothing left to do here.
|
||||
|
||||
|
||||
function rollbackMutation(_mutation: unknown, _userId: number) {
|
||||
/* no-op — see onMutate/onError above */
|
||||
}
|
||||
|
||||
@@ -210,7 +210,7 @@ export default function AttendanceHistory() {
|
||||
const { data, isPending } = useQuery(
|
||||
attendanceHistoryOptions({ month, userId: user?.id }),
|
||||
);
|
||||
const records = data ?? [];
|
||||
const records = useMemo(() => data ?? [], [data]);
|
||||
|
||||
const computed = useMemo(() => {
|
||||
const [yearStr, monthStr] = month.split("-");
|
||||
|
||||
@@ -621,7 +621,10 @@ export default function InvoiceDetail() {
|
||||
const customers = customersQuery.data ?? [];
|
||||
|
||||
const bankAccountsQuery = useQuery(bankAccountsOptions());
|
||||
const bankAccounts = bankAccountsQuery.data ?? [];
|
||||
const bankAccounts = useMemo(
|
||||
() => bankAccountsQuery.data ?? [],
|
||||
[bankAccountsQuery.data],
|
||||
);
|
||||
|
||||
const invoiceQuery = useQuery(invoiceDetailOptions(id));
|
||||
const invoice = invoiceQuery.data ?? null;
|
||||
@@ -656,8 +659,9 @@ export default function InvoiceDetail() {
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const blobTimeouts = blobTimeoutsRef.current;
|
||||
return () => {
|
||||
blobTimeoutsRef.current.forEach(clearTimeout);
|
||||
blobTimeouts.forEach(clearTimeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -121,8 +121,9 @@ export default function OrderDetail() {
|
||||
}, [orderQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
const blobTimeouts = blobTimeoutsRef.current;
|
||||
return () => {
|
||||
blobTimeoutsRef.current.forEach(clearTimeout);
|
||||
blobTimeouts.forEach(clearTimeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
getCell,
|
||||
getCells,
|
||||
type ModalMode as HookModalMode,
|
||||
type PlanEntryBody,
|
||||
type PlanEntryPatchBody,
|
||||
type PlanOverrideBody,
|
||||
type PlanOverridePatchBody,
|
||||
} from "../hooks/usePlanWork";
|
||||
import type { PlanCellModalMode } from "../components/PlanCellModal";
|
||||
import PlanGrid from "../components/PlanGrid";
|
||||
@@ -182,7 +186,13 @@ export default function PlanWork() {
|
||||
if (bulkForm.user_ids.length === 0) return;
|
||||
setBulkSubmitting(true);
|
||||
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),
|
||||
date_from: 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,
|
||||
category: bulkForm.category,
|
||||
note: bulkForm.note,
|
||||
});
|
||||
})) as BulkSummary & { data?: BulkSummary };
|
||||
// apiCall returns the full envelope { success, data, message };
|
||||
// the summary counts are under .data
|
||||
const summary = data?.data ?? data;
|
||||
const summary: BulkSummary = data?.data ?? data;
|
||||
const skipped =
|
||||
summary?.skipped_days > 0
|
||||
(summary?.skipped_days ?? 0) > 0
|
||||
? ` ${summary.skipped_days} dní přeskočeno (limit 3 na den).`
|
||||
: "";
|
||||
alert.success(
|
||||
@@ -392,7 +402,7 @@ export default function PlanWork() {
|
||||
// optimistic cache patch the hook already applied.
|
||||
|
||||
const saveEntry = useCallback(
|
||||
async (body: any) => {
|
||||
async (body: PlanEntryBody) => {
|
||||
try {
|
||||
await createEntry.mutateAsync(body);
|
||||
// Pulse the first day of the new entry — for multi-day entries
|
||||
@@ -409,7 +419,7 @@ export default function PlanWork() {
|
||||
);
|
||||
|
||||
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 /
|
||||
// which cache to roll back. The hook's optimistic patch already
|
||||
// discovered this; we read it from the current grid.
|
||||
@@ -473,7 +483,7 @@ export default function PlanWork() {
|
||||
);
|
||||
|
||||
const saveOverride = useCallback(
|
||||
async (body: any) => {
|
||||
async (body: PlanOverrideBody) => {
|
||||
try {
|
||||
await createOverride.mutateAsync(body);
|
||||
firePulse(body.user_id, body.shift_date);
|
||||
@@ -488,7 +498,7 @@ export default function PlanWork() {
|
||||
);
|
||||
|
||||
const updateOverrideFn = useCallback(
|
||||
async (id: number, body: any) => {
|
||||
async (id: number, body: PlanOverridePatchBody) => {
|
||||
// Find the user/date for the pulse + rollback.
|
||||
let userId: number | null = null;
|
||||
let date: string | null = null;
|
||||
|
||||
@@ -362,8 +362,9 @@ export default function ReceivedInvoices({
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const blobTimeouts = blobTimeoutsRef.current;
|
||||
return () => {
|
||||
blobTimeoutsRef.current.forEach(clearTimeout);
|
||||
blobTimeouts.forEach(clearTimeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -1149,11 +1150,9 @@ export default function ReceivedInvoices({
|
||||
? "Zavřít"
|
||||
: "Uložit"
|
||||
}
|
||||
cancelText={
|
||||
editInvoice && editInvoice._originalStatus === "paid"
|
||||
? "Zavřít"
|
||||
: "Zrušit"
|
||||
}
|
||||
// Read-only (paid) detail has only a close action — a single "Zavřít"
|
||||
// button, not a redundant cancel button beside it.
|
||||
hideCancel={editInvoice?._originalStatus === "paid"}
|
||||
loading={saving}
|
||||
>
|
||||
{editInvoice &&
|
||||
|
||||
@@ -715,7 +715,25 @@ export default function Settings() {
|
||||
</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 = ({
|
||||
label,
|
||||
children,
|
||||
@@ -800,7 +818,7 @@ export default function Settings() {
|
||||
["Faktury", si.nas?.invoices],
|
||||
["Objednávky", si.nas?.orders],
|
||||
["Nabídky", si.nas?.offers],
|
||||
] as [string, Record<string, any>][]
|
||||
] as [string, NasInfo | undefined][]
|
||||
).map(([label, info]) => (
|
||||
<Row key={label} label={label}>
|
||||
<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 {
|
||||
useParams,
|
||||
useNavigate,
|
||||
@@ -174,7 +174,10 @@ export default function WarehouseIssueForm() {
|
||||
|
||||
const baseId = useId();
|
||||
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)
|
||||
const prefill = location.state as {
|
||||
@@ -262,7 +265,7 @@ export default function WarehouseIssueForm() {
|
||||
}
|
||||
formInitialized.current = true;
|
||||
}
|
||||
}, [isEdit, issue]);
|
||||
}, [isEdit, issue, nextKey]);
|
||||
|
||||
const saveMutation = useApiMutation<
|
||||
ReturnType<typeof buildPayload>,
|
||||
|
||||
@@ -126,7 +126,10 @@ export default function WarehouseReceiptForm() {
|
||||
|
||||
const baseId = useId();
|
||||
const counterRef = useRef(0);
|
||||
const nextKey = () => `${baseId}-item-${counterRef.current++}`;
|
||||
const nextKey = useCallback(
|
||||
() => `${baseId}-item-${counterRef.current++}`,
|
||||
[baseId],
|
||||
);
|
||||
|
||||
const [form, setForm] = useState<ReceiptForm>({
|
||||
supplier_id: null,
|
||||
@@ -198,7 +201,7 @@ export default function WarehouseReceiptForm() {
|
||||
}
|
||||
formInitialized.current = true;
|
||||
}
|
||||
}, [isEdit, receipt]);
|
||||
}, [isEdit, receipt, nextKey]);
|
||||
|
||||
const saveMutation = useApiMutation<
|
||||
ReturnType<typeof buildPayload>,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
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. */
|
||||
interface SchemesPalette {
|
||||
primary: { main: string };
|
||||
@@ -44,18 +47,20 @@ describe("MUI theme", () => {
|
||||
});
|
||||
|
||||
it("makes buttons pill-shaped and cards 16px by default", () => {
|
||||
const btn = theme.components?.MuiButton?.styleOverrides?.root as any;
|
||||
expect(btn?.borderRadius).toBe(999);
|
||||
const card = theme.components?.MuiCard?.styleOverrides?.root as any;
|
||||
expect(card?.borderRadius).toBe(16);
|
||||
const btn = theme.components?.MuiButton?.styleOverrides?.root as StyleObj;
|
||||
expect(btn.borderRadius).toBe(999);
|
||||
const card = theme.components?.MuiCard?.styleOverrides?.root as StyleObj;
|
||||
expect(card.borderRadius).toBe(16);
|
||||
});
|
||||
|
||||
it("adds 200-300ms interaction motion (press + focus ring)", () => {
|
||||
const btn = theme.components?.MuiButton?.styleOverrides?.root as any;
|
||||
expect(btn?.transition).toContain("transform");
|
||||
expect(btn?.["&:active"]?.transform).toBe("scale(0.97)");
|
||||
const btn = theme.components?.MuiButton?.styleOverrides?.root as StyleObj;
|
||||
expect(btn.transition).toContain("transform");
|
||||
const active = btn["&:active"] as StyleObj;
|
||||
expect(active.transform).toBe("scale(0.97)");
|
||||
const input = theme.components?.MuiOutlinedInput?.styleOverrides
|
||||
?.root as any;
|
||||
expect(input?.["&.Mui-focused"]?.boxShadow).toContain("rgba");
|
||||
?.root as StyleObj;
|
||||
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 { invalidateSettingsCache } from "../../services/system-settings";
|
||||
import os from "os";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { config } from "../../config/env";
|
||||
import { NasFileManager } from "../../services/nas-file-manager";
|
||||
import {
|
||||
@@ -21,6 +23,15 @@ import {
|
||||
} from "../../services/nas-financials-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) */
|
||||
function encodeCustomFields(
|
||||
fields: unknown,
|
||||
@@ -325,8 +336,6 @@ export default async function companySettingsRoutes(
|
||||
settings.custom_fields as string | null,
|
||||
);
|
||||
|
||||
const pkg = require("../../../package.json") as { version: string };
|
||||
|
||||
let available_vat_rates: number[] = [0, 10, 12, 15, 21];
|
||||
try {
|
||||
const raw = settings.available_vat_rates as string | null;
|
||||
@@ -359,7 +368,7 @@ export default async function companySettingsRoutes(
|
||||
available_currencies,
|
||||
has_logo,
|
||||
has_logo_dark,
|
||||
app_version: pkg.version,
|
||||
app_version: APP_VERSION,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -368,7 +377,6 @@ export default async function companySettingsRoutes(
|
||||
"/system-info",
|
||||
{ preHandler: requirePermission("settings.system") },
|
||||
async (request, reply) => {
|
||||
const pkg = require("../../../package.json") as { version: string };
|
||||
const uptimeSec = process.uptime();
|
||||
const days = Math.floor(uptimeSec / 86400);
|
||||
const hours = Math.floor((uptimeSec % 86400) / 3600);
|
||||
@@ -401,7 +409,7 @@ export default async function companySettingsRoutes(
|
||||
const projectNas = new NasFileManager();
|
||||
|
||||
return success(reply, {
|
||||
app_version: pkg.version,
|
||||
app_version: APP_VERSION,
|
||||
node_version: process.version,
|
||||
platform: `${os.type()} ${os.release()}`,
|
||||
uptime: uptimeStr,
|
||||
|
||||
@@ -194,7 +194,7 @@ export default async function planRoutes(app: FastifyInstance) {
|
||||
"/categories/:id",
|
||||
{ preHandler: requirePermission("attendance.manage") },
|
||||
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;
|
||||
const body = parseBody(UpdatePlanCategorySchema, request.body);
|
||||
if ("error" in body) return error(reply, body.error, 400);
|
||||
@@ -218,7 +218,7 @@ export default async function planRoutes(app: FastifyInstance) {
|
||||
"/categories/:id",
|
||||
{ preHandler: requirePermission("attendance.manage") },
|
||||
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;
|
||||
const result = await deletePlanCategory(id);
|
||||
if ("error" in result) return error(reply, result.error, result.status);
|
||||
@@ -289,7 +289,7 @@ export default async function planRoutes(app: FastifyInstance) {
|
||||
"/entries/:id",
|
||||
{ preHandler: requirePermission("attendance.manage") },
|
||||
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;
|
||||
const body = parseBody(UpdatePlanEntrySchema, request.body);
|
||||
if ("error" in body) return error(reply, body.error, 400);
|
||||
@@ -321,7 +321,7 @@ export default async function planRoutes(app: FastifyInstance) {
|
||||
"/entries/:id",
|
||||
{ preHandler: requirePermission("attendance.manage") },
|
||||
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;
|
||||
const force = forceFromQuery(request.query);
|
||||
const result = await deleteEntry(id, request.authData!.userId, force);
|
||||
@@ -373,7 +373,7 @@ export default async function planRoutes(app: FastifyInstance) {
|
||||
"/overrides/:id",
|
||||
{ preHandler: requirePermission("attendance.manage") },
|
||||
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;
|
||||
const body = parseBody(UpdatePlanOverrideSchema, request.body);
|
||||
if ("error" in body) return error(reply, body.error, 400);
|
||||
@@ -405,7 +405,7 @@ export default async function planRoutes(app: FastifyInstance) {
|
||||
"/overrides/:id",
|
||||
{ preHandler: requirePermission("attendance.manage") },
|
||||
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;
|
||||
const force = forceFromQuery(request.query);
|
||||
const result = await deleteOverride(id, request.authData!.userId, force);
|
||||
|
||||
@@ -89,7 +89,11 @@ export default async function projectsRoutes(
|
||||
|
||||
const result = await createProject(parsed.data);
|
||||
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({
|
||||
request,
|
||||
@@ -127,7 +131,11 @@ export default async function projectsRoutes(
|
||||
const result = await updateProject(id, parsed.data);
|
||||
if (!result) return error(reply, "Projekt nenalezen", 404);
|
||||
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({
|
||||
@@ -160,7 +168,11 @@ export default async function projectsRoutes(
|
||||
content: parsed.data.content ?? undefined,
|
||||
});
|
||||
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({
|
||||
|
||||
@@ -82,7 +82,7 @@ export default async function sessionsRoutes(
|
||||
"/:id",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const id = parseId((request.params as any).id, reply);
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const authData = request.authData!;
|
||||
|
||||
|
||||
@@ -293,7 +293,7 @@ export default async function tripsRoutes(
|
||||
"/last-km/:vehicleId",
|
||||
{ preHandler: requirePermission("trips.manage") },
|
||||
async (request, reply) => {
|
||||
const vehicleId = parseId((request.params as any).vehicleId, reply);
|
||||
const vehicleId = parseId(request.params.vehicleId, reply);
|
||||
if (vehicleId === null) return;
|
||||
|
||||
const [lastTrip, vehicle] = await Promise.all([
|
||||
@@ -373,7 +373,7 @@ export default async function tripsRoutes(
|
||||
"/:id",
|
||||
{ preHandler: requireAnyPermission("trips.manage", "trips.record") },
|
||||
async (request, reply) => {
|
||||
const id = parseId((request.params as any).id, reply);
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const parsed = parseBody(UpdateTripSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
@@ -461,7 +461,7 @@ export default async function tripsRoutes(
|
||||
"/:id",
|
||||
{ preHandler: requireAnyPermission("trips.manage", "trips.record") },
|
||||
async (request, reply) => {
|
||||
const id = parseId((request.params as any).id, reply);
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const authData = request.authData!;
|
||||
const existing = await prisma.trips.findUnique({ where: { id } });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Fastify from "fastify";
|
||||
import type { IncomingMessage, ServerResponse } from "http";
|
||||
import type { ScheduledTask } from "node-cron";
|
||||
import cors from "@fastify/cors";
|
||||
import cookie from "@fastify/cookie";
|
||||
@@ -167,13 +168,26 @@ async function start() {
|
||||
|
||||
// --- Frontend: Vite dev middleware (dev only) ---
|
||||
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(
|
||||
'return import("vite")',
|
||||
)() as Promise<any>);
|
||||
const createViteServer = viteModule.createServer as (
|
||||
opts: any,
|
||||
) => Promise<any>;
|
||||
const vite = await createViteServer({
|
||||
)() as Promise<ViteModuleLike>);
|
||||
const vite = await viteModule.createServer({
|
||||
server: { middlewareMode: true },
|
||||
appType: "spa",
|
||||
});
|
||||
|
||||
@@ -523,6 +523,8 @@ export async function extractInvoice(
|
||||
return JSON.parse(text) as ExtractedInvoice;
|
||||
} catch (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).
|
||||
const stale = rateCache.get(key);
|
||||
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 {
|
||||
inflight.delete(key);
|
||||
}
|
||||
|
||||
@@ -115,12 +115,29 @@ function buildOfferWhere(params: OfferFilterParams): Record<string, unknown> {
|
||||
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;
|
||||
};
|
||||
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).
|
||||
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
|
||||
.filter((i: any) => i.is_included_in_total !== false)
|
||||
.filter((i) => i.is_included_in_total !== false)
|
||||
.reduce(
|
||||
(s: number, i: any) =>
|
||||
(s: number, i) =>
|
||||
s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
|
||||
0,
|
||||
);
|
||||
@@ -260,7 +277,7 @@ export async function getOffer(id: number) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function createOffer(body: Record<string, any>) {
|
||||
export async function createOffer(body: Record<string, unknown>) {
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const status = body.status ? String(body.status) : "draft";
|
||||
@@ -362,7 +379,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 } });
|
||||
if (!existing) return { error: "not_found" as const };
|
||||
|
||||
|
||||
@@ -79,11 +79,36 @@ async function syncProjectStatus(
|
||||
// silently 0 in the per-currency totals.
|
||||
//
|
||||
// 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
|
||||
.filter((i: any) => i.is_included_in_total !== false)
|
||||
.filter((i) => i.is_included_in_total !== false)
|
||||
.reduce(
|
||||
(s: number, i: any) =>
|
||||
(s: number, i) =>
|
||||
s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
@@ -680,6 +680,11 @@ export async function deleteEntry(
|
||||
// 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
|
||||
* MAX_RECORDS_PER_CELL active overrides per (user_id, shift_date); a create
|
||||
@@ -696,7 +701,7 @@ export async function createOverride(
|
||||
},
|
||||
actorUserId: number,
|
||||
force: boolean,
|
||||
): Promise<Result<any>> {
|
||||
): Promise<Result<WorkPlanOverride>> {
|
||||
const lock = assertNotPastDate(input.shift_date, force);
|
||||
if (lock) return lock;
|
||||
|
||||
@@ -762,7 +767,7 @@ export async function updateOverride(
|
||||
input: { project_id?: number | null; category?: string; note?: string },
|
||||
actorUserId: number,
|
||||
force: boolean,
|
||||
): Promise<Result<any>> {
|
||||
): Promise<Result<WorkPlanOverride>> {
|
||||
const existing = await prisma.work_plan_overrides.findUnique({
|
||||
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 } });
|
||||
if (!existing) return null;
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export async function listUsers(params: ListUsersParams) {
|
||||
const { page, limit, skip, sort, order, search, permission } = params;
|
||||
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
|
||||
|
||||
const where: any = search
|
||||
const where: Prisma.usersWhereInput = search
|
||||
? {
|
||||
OR: [
|
||||
{ username: { contains: search } },
|
||||
|
||||
Reference in New Issue
Block a user