Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ee59b54bc | ||
|
|
907ee574e6 | ||
|
|
b51ea2505e | ||
|
|
fd2e613aa5 |
52
docs/superpowers/specs/2026-06-12-sidebar-icons-design.md
Normal file
52
docs/superpowers/specs/2026-06-12-sidebar-icons-design.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Sidebar icon refresh — design
|
||||||
|
|
||||||
|
**Date:** 2026-06-12 · **Status:** approved (visual companion session, 3 screens)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
All 31 sidebar items have icons, but 13 share a glyph with another item
|
||||||
|
(3× "people", 3× "sliders", 2× grid/bar-chart/box/history-clock) and Faktury
|
||||||
|
uses a dollar sign in an app that bills in Kč/EUR. Duplicated glyphs defeat
|
||||||
|
the purpose of icons as unique visual anchors.
|
||||||
|
|
||||||
|
## Decisions (user-approved)
|
||||||
|
|
||||||
|
1. **Full refresh** of all items (option B over targeted fix).
|
||||||
|
2. **Tile language (option B of 3 rendered directions):** every item's glyph
|
||||||
|
sits in a rounded-square tile (border-radius 32%, echoing OdinMark), as a
|
||||||
|
**soft color wash — one muted hue per menu section**:
|
||||||
|
Přehled `primary` (red) · Docházka `info` (blue) · Kniha jízd `teal` ·
|
||||||
|
Administrativa `warning` (amber) · Sklad `violet` · Systém `slate`.
|
||||||
|
3. **Odin stays unique:** the only **solid** (gradient) tile and the only
|
||||||
|
animated icon. No animation anywhere else. Red stays reserved for
|
||||||
|
Odin/primary.
|
||||||
|
4. **Glyph set** approved on the full-set screen: receipt for Faktury,
|
||||||
|
business card for Zákazníci, truck for Dodavatelé, stamp for Schvalování,
|
||||||
|
ledger ± for Správa bilancí, route/pin/car for Kniha jízd, barcode for
|
||||||
|
Inventura, shelf rack for Lokace, arrows into/out of a box for
|
||||||
|
Příjmy/Výdeje, warehouse hall for Sklad přehled, tag/cart/folder for
|
||||||
|
Nabídky/Objednávky/Projekty, gauge for Přehled, paper plane for Žádosti,
|
||||||
|
timesheet for Moje historie (docházka), shapes for Kategorie, chart frame
|
||||||
|
for Reporty, magnifier-over-list for Audit log. Kept concepts (now unique
|
||||||
|
in the set): clock (Záznam docházky), calendar (Plán prací), people
|
||||||
|
(Uživatelé), gear (Nastavení), box (Položky).
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
- `theme.ts`: add `teal`, `violet`, `slate` palette entries (light + dark
|
||||||
|
mains, full main/light/dark/contrastText so cssVariables emits `*Channel`
|
||||||
|
tokens) + TS module augmentation.
|
||||||
|
- `navData.tsx`: `MenuSection.hue` (palette key), new glyph SVGs
|
||||||
|
(stroke, 24-viewBox, width 2, round caps), `MenuItem.rawIcon` flag for
|
||||||
|
Odin (its icon ships its own tile).
|
||||||
|
- `SidebarNav.tsx`: renders the tile (26 px, radius 32%, `bgcolor:
|
||||||
|
rgba(<hue>.mainChannel / 0.12)`, glyph color `<hue>.main`, svg 16 px)
|
||||||
|
around every non-`rawIcon` item. No hardcoded hex; washes are
|
||||||
|
channel-alpha so both schemes work.
|
||||||
|
- Pinning test `navdata-icons.test.ts`: every non-raw icon renders to unique
|
||||||
|
static markup (no two items may ever share a glyph again).
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Animation for non-Odin icons (explicitly rejected), page-level icons,
|
||||||
|
mobile drawer changes (it reuses SidebarNav).
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.31",
|
"version": "2.4.35",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.31",
|
"version": "2.4.35",
|
||||||
"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.31",
|
"version": "2.4.35",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `audit_logs` MODIFY `created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0);
|
||||||
|
|
||||||
|
|
||||||
|
-- Stock invariants enforced by the database itself: a future cumulative-
|
||||||
|
-- decrement bug (the C2 class — duplicate issue lines driving a batch
|
||||||
|
-- negative and silently vanishing from stock totals) becomes a loud error
|
||||||
|
-- instead of hidden corruption. All databases verified clean of negative
|
||||||
|
-- rows before this migration (2026-06-12).
|
||||||
|
ALTER TABLE `sklad_batches`
|
||||||
|
ADD CONSTRAINT `chk_sklad_batches_quantity_nonneg` CHECK (`quantity` >= 0);
|
||||||
|
|
||||||
|
ALTER TABLE `sklad_item_locations`
|
||||||
|
ADD CONSTRAINT `chk_sklad_item_locations_quantity_nonneg` CHECK (`quantity` >= 0);
|
||||||
@@ -65,7 +65,9 @@ model audit_logs {
|
|||||||
new_values String? @db.LongText
|
new_values String? @db.LongText
|
||||||
user_agent String? @db.Text
|
user_agent String? @db.Text
|
||||||
session_id String? @db.VarChar(128)
|
session_id String? @db.VarChar(128)
|
||||||
created_at DateTime? @default(now()) @db.Timestamp(0)
|
// DATETIME, not TIMESTAMP — TIMESTAMP dies in 2038 and converts through
|
||||||
|
// the session timezone (2026-06-12 hardening migration).
|
||||||
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
|
|
||||||
@@index([action], map: "idx_audit_logs_action")
|
@@index([action], map: "idx_audit_logs_action")
|
||||||
@@index([created_at], map: "idx_audit_logs_created")
|
@@index([created_at], map: "idx_audit_logs_created")
|
||||||
|
|||||||
@@ -391,12 +391,12 @@ async function main() {
|
|||||||
console.log(` Admin role now has all ${allPerms.length} permissions`);
|
console.log(` Admin role now has all ${allPerms.length} permissions`);
|
||||||
|
|
||||||
// 4. Ensure admin user exists
|
// 4. Ensure admin user exists
|
||||||
let adminUser = await prisma.users.findFirst({
|
const adminUser = await prisma.users.findFirst({
|
||||||
where: { username: "admin" },
|
where: { username: "admin" },
|
||||||
});
|
});
|
||||||
if (!adminUser) {
|
if (!adminUser) {
|
||||||
const hash = bcrypt.hashSync("admin", 10);
|
const hash = bcrypt.hashSync("admin", 10);
|
||||||
adminUser = await prisma.users.create({
|
await prisma.users.create({
|
||||||
data: {
|
data: {
|
||||||
username: "admin",
|
username: "admin",
|
||||||
email: "admin@boha.local",
|
email: "admin@boha.local",
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ async function main() {
|
|||||||
// fails we KEEP file_data and report the row as failed.
|
// fails we KEEP file_data and report the row as failed.
|
||||||
const expectedSize = rec.file_data.length;
|
const expectedSize = rec.file_data.length;
|
||||||
const readBack = nasFinancialsManager.readReceivedInvoice(result.filePath);
|
const readBack = nasFinancialsManager.readReceivedInvoice(result.filePath);
|
||||||
let writtenSize = -1;
|
let writtenSize: number;
|
||||||
if (readBack) {
|
if (readBack) {
|
||||||
writtenSize = readBack.data.length;
|
writtenSize = readBack.data.length;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
136
src/__tests__/db-constraints.test.ts
Normal file
136
src/__tests__/db-constraints.test.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pins the 2026-06-12 DB hardening migration:
|
||||||
|
* 1. CHECK (quantity >= 0) on sklad_batches + sklad_item_locations — the DB
|
||||||
|
* itself rejects negative stock, so a future C2-class bug (cumulative
|
||||||
|
* decrements driving a batch negative) becomes a loud error instead of
|
||||||
|
* silently hidden corruption.
|
||||||
|
* 2. audit_logs.created_at is DATETIME, not TIMESTAMP — no 2038 cap.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const N = "db_constraints_";
|
||||||
|
|
||||||
|
let itemId: number;
|
||||||
|
let locationId: number;
|
||||||
|
let receiptId: number;
|
||||||
|
let lineAId: number;
|
||||||
|
let lineBId: number;
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
await prisma.sklad_batches.deleteMany({
|
||||||
|
where: { item: { name: { contains: N } } },
|
||||||
|
});
|
||||||
|
await prisma.sklad_item_locations.deleteMany({
|
||||||
|
where: { item: { name: { contains: N } } },
|
||||||
|
});
|
||||||
|
await prisma.sklad_receipt_lines.deleteMany({
|
||||||
|
where: { item: { name: { contains: N } } },
|
||||||
|
});
|
||||||
|
await prisma.sklad_receipts.deleteMany({ where: { notes: { contains: N } } });
|
||||||
|
await prisma.sklad_items.deleteMany({ where: { name: { contains: N } } });
|
||||||
|
await prisma.sklad_locations.deleteMany({
|
||||||
|
where: { name: { contains: N } },
|
||||||
|
});
|
||||||
|
await prisma.audit_logs.deleteMany({
|
||||||
|
where: { description: { contains: N } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
const item = await prisma.sklad_items.create({
|
||||||
|
data: { name: `${N}item`, unit: "ks" },
|
||||||
|
});
|
||||||
|
itemId = item.id;
|
||||||
|
const location = await prisma.sklad_locations.create({
|
||||||
|
data: { code: `DBC${Date.now() % 100000}`, name: `${N}loc` },
|
||||||
|
});
|
||||||
|
locationId = location.id;
|
||||||
|
const receipt = await prisma.sklad_receipts.create({
|
||||||
|
data: { notes: `${N}receipt`, status: "CONFIRMED" },
|
||||||
|
});
|
||||||
|
receiptId = receipt.id;
|
||||||
|
const lineA = await prisma.sklad_receipt_lines.create({
|
||||||
|
data: {
|
||||||
|
receipt_id: receiptId,
|
||||||
|
item_id: itemId,
|
||||||
|
quantity: 5,
|
||||||
|
unit_price: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
lineAId = lineA.id;
|
||||||
|
const lineB = await prisma.sklad_receipt_lines.create({
|
||||||
|
data: {
|
||||||
|
receipt_id: receiptId,
|
||||||
|
item_id: itemId,
|
||||||
|
quantity: 5,
|
||||||
|
unit_price: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
lineBId = lineB.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stock CHECK constraints", () => {
|
||||||
|
it("rejects inserting a negative batch quantity", async () => {
|
||||||
|
await expect(
|
||||||
|
prisma.sklad_batches.create({
|
||||||
|
data: {
|
||||||
|
item_id: itemId,
|
||||||
|
receipt_line_id: lineAId,
|
||||||
|
quantity: -1,
|
||||||
|
original_qty: 5,
|
||||||
|
unit_price: 1,
|
||||||
|
is_consumed: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects updating a batch quantity below zero", async () => {
|
||||||
|
const batch = await prisma.sklad_batches.create({
|
||||||
|
data: {
|
||||||
|
item_id: itemId,
|
||||||
|
receipt_line_id: lineBId,
|
||||||
|
quantity: 5,
|
||||||
|
original_qty: 5,
|
||||||
|
unit_price: 1,
|
||||||
|
is_consumed: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
prisma.$executeRaw`UPDATE sklad_batches SET quantity = -2 WHERE id = ${batch.id}`,
|
||||||
|
).rejects.toThrow();
|
||||||
|
const fresh = await prisma.sklad_batches.findUnique({
|
||||||
|
where: { id: batch.id },
|
||||||
|
});
|
||||||
|
expect(Number(fresh?.quantity)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a negative item_location quantity", async () => {
|
||||||
|
await expect(
|
||||||
|
prisma.sklad_item_locations.create({
|
||||||
|
data: { item_id: itemId, location_id: locationId, quantity: -1 },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("audit_logs.created_at has no 2038 cap", () => {
|
||||||
|
it("accepts a far-future timestamp (DATETIME, not TIMESTAMP)", async () => {
|
||||||
|
const row = await prisma.audit_logs.create({
|
||||||
|
data: {
|
||||||
|
action: "test",
|
||||||
|
description: `${N}future`,
|
||||||
|
created_at: new Date(Date.UTC(2098, 0, 15, 12, 0, 0)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(row.created_at?.getUTCFullYear()).toBe(2098);
|
||||||
|
});
|
||||||
|
});
|
||||||
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
51
src/__tests__/navdata-icons.test.ts
Normal file
51
src/__tests__/navdata-icons.test.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { isValidElement, type ReactElement } from "react";
|
||||||
|
import { menuSections, type MenuItem } from "../admin/ui/navData";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pins the sidebar icon-refresh contract (2026-06-12 design): every menu
|
||||||
|
* item has a glyph, and no two items share one — duplicated glyphs were the
|
||||||
|
* whole problem the refresh fixed (3× "people", 3× "sliders", …). Odin's
|
||||||
|
* rawIcon (OdinMark, MUI-styled + animated) is exempt from rendering here
|
||||||
|
* but still must exist and stay the ONLY rawIcon.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const allItems: Array<MenuItem & { section: string }> = menuSections.flatMap(
|
||||||
|
(s) => s.items.map((item) => ({ section: s.label, ...item })),
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("sidebar nav icons", () => {
|
||||||
|
it("every item has an icon and a section hue", () => {
|
||||||
|
for (const section of menuSections) {
|
||||||
|
expect(section.hue, `section ${section.label}`).toBeTruthy();
|
||||||
|
}
|
||||||
|
for (const item of allItems) {
|
||||||
|
expect(isValidElement(item.icon), `${item.section}/${item.label}`).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Odin is the only rawIcon (its own tile + animation stay unique)", () => {
|
||||||
|
const raw = allItems.filter((i) => i.rawIcon);
|
||||||
|
expect(raw.map((i) => i.label)).toEqual(["Odin"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no two items share a glyph", () => {
|
||||||
|
const rendered = allItems
|
||||||
|
.filter((i) => !i.rawIcon)
|
||||||
|
.map((item) => ({
|
||||||
|
key: `${item.section}/${item.label}`,
|
||||||
|
markup: renderToStaticMarkup(item.icon as ReactElement),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const seen = new Map<string, string>();
|
||||||
|
for (const { key, markup } of rendered) {
|
||||||
|
const dupe = seen.get(markup);
|
||||||
|
expect(dupe, `${key} shares its glyph with ${dupe}`).toBeUndefined();
|
||||||
|
seen.set(markup, key);
|
||||||
|
}
|
||||||
|
expect(seen.size).toBe(rendered.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1090,26 +1090,6 @@ async function authPost(path: string, token: string, body: unknown) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function authPatch(path: string, token: string, body: unknown) {
|
|
||||||
return app.inject({
|
|
||||||
method: "PATCH",
|
|
||||||
url: path,
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
payload: body as object,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function authDelete(path: string, token: string) {
|
|
||||||
return app.inject({
|
|
||||||
method: "DELETE",
|
|
||||||
url: path,
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await buildApp();
|
app = await buildApp();
|
||||||
|
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ const TrashIcon = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
export default function PlanCellModal(props: Props) {
|
export default function PlanCellModal(props: Props) {
|
||||||
const { open, mode, onClose } = props;
|
const { open, mode } = props;
|
||||||
if (!open || mode.kind === "closed") return null;
|
if (!open || mode.kind === "closed") return null;
|
||||||
if (mode.kind === "view") return <ViewModal {...props} />;
|
if (mode.kind === "view") return <ViewModal {...props} />;
|
||||||
if (mode.kind === "day-in-range")
|
if (mode.kind === "day-in-range")
|
||||||
@@ -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 ----------
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
@@ -234,7 +234,6 @@ function computeUserTotals(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const holidayDates = holidaysInMonth(yr, mo + 1);
|
const holidayDates = holidaysInMonth(yr, mo + 1);
|
||||||
const holidayCount = holidayDates.length;
|
|
||||||
|
|
||||||
for (const uid of Object.keys(totals)) {
|
for (const uid of Object.keys(totals)) {
|
||||||
const t = totals[uid];
|
const t = totals[uid];
|
||||||
@@ -345,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,
|
||||||
@@ -375,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);
|
||||||
}
|
}
|
||||||
@@ -392,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++) {
|
||||||
@@ -476,14 +486,12 @@ function buildUserSectionHtml(
|
|||||||
// Sum of vacation/sick/unpaid hours (in hours, not minutes). The
|
// Sum of vacation/sick/unpaid hours (in hours, not minutes). The
|
||||||
// "holiday" leave_type is auto-computed from Czech holidays further down
|
// "holiday" leave_type is auto-computed from Czech holidays further down
|
||||||
// and no longer selectable in the UI, so it's deliberately omitted here.
|
// and no longer selectable in the UI, so it's deliberately omitted here.
|
||||||
let vacationHours = 0,
|
let vacationHours = 0;
|
||||||
sickHours = 0;
|
|
||||||
for (const r of userRecords) {
|
for (const r of userRecords) {
|
||||||
const lt = r.leave_type || "work";
|
const lt = r.leave_type || "work";
|
||||||
if (lt === "work") continue;
|
if (lt === "work") continue;
|
||||||
const h = Number(r.leave_hours) || 8;
|
const h = Number(r.leave_hours) || 8;
|
||||||
if (lt === "vacation") vacationHours += h;
|
if (lt === "vacation") vacationHours += h;
|
||||||
else if (lt === "sick") sickHours += h;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Odpracováno: floor(worked / 8) × 8.
|
// Odpracováno: floor(worked / 8) × 8.
|
||||||
@@ -492,7 +500,6 @@ function buildUserSectionHtml(
|
|||||||
|
|
||||||
// Free Svátek: 8h per holiday date the user did not work.
|
// Free Svátek: 8h per holiday date the user did not work.
|
||||||
const holidayDates = holidaysInMonth(yr, mo);
|
const holidayDates = holidaysInMonth(yr, mo);
|
||||||
const holidayCount = holidayDates.length;
|
|
||||||
const workedDates = new Set(
|
const workedDates = new Set(
|
||||||
userRecords
|
userRecords
|
||||||
.filter((r) => (r.leave_type || "work") === "work")
|
.filter((r) => (r.leave_type || "work") === "work")
|
||||||
@@ -592,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,
|
||||||
@@ -1334,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)) {
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ async function performMutation<TIn, TOut>(opts: {
|
|||||||
return result.data as TOut;
|
return result.data as TOut;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiMutationOptions<TIn, TOut> {
|
export interface ApiMutationOptions<TIn> {
|
||||||
url: string | ((input: TIn) => string);
|
url: string | ((input: TIn) => string);
|
||||||
method: HttpMethod | ((input: TIn) => HttpMethod);
|
method: HttpMethod | ((input: TIn) => HttpMethod);
|
||||||
/** Query-key prefixes to invalidate on success. Broad per CLAUDE.md. */
|
/** Query-key prefixes to invalidate on success. Broad per CLAUDE.md. */
|
||||||
@@ -122,7 +122,7 @@ export interface ApiMutationOptions<TIn, TOut> {
|
|||||||
* as `ApiError` (with `.status` attached) so callers can branch on HTTP code.
|
* as `ApiError` (with `.status` attached) so callers can branch on HTTP code.
|
||||||
*/
|
*/
|
||||||
export function useApiMutation<TIn, TOut>(
|
export function useApiMutation<TIn, TOut>(
|
||||||
opts: ApiMutationOptions<TIn, TOut> &
|
opts: ApiMutationOptions<TIn> &
|
||||||
Omit<UseMutationOptions<TOut, ApiError, TIn>, "mutationFn">,
|
Omit<UseMutationOptions<TOut, ApiError, TIn>, "mutationFn">,
|
||||||
) {
|
) {
|
||||||
const { url, method, invalidate, envelope, onSuccess, ...rest } = opts;
|
const { url, method, invalidate, envelope, onSuccess, ...rest } = opts;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { queryOptions } from "@tanstack/react-query";
|
import { queryOptions } from "@tanstack/react-query";
|
||||||
import apiFetch from "../../utils/api";
|
|
||||||
import { jsonQuery } from "../apiAdapter";
|
import { jsonQuery } from "../apiAdapter";
|
||||||
|
|
||||||
export interface PlanUser {
|
export interface PlanUser {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useMemo, useRef } from "react";
|
import { useState, useMemo, useRef } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
@@ -9,7 +9,6 @@ import { iconBadgeSx } from "../theme";
|
|||||||
import {
|
import {
|
||||||
attendanceHistoryOptions,
|
attendanceHistoryOptions,
|
||||||
type AttendanceRecord,
|
type AttendanceRecord,
|
||||||
type ProjectLogEntry,
|
|
||||||
} from "../lib/queries/attendance";
|
} from "../lib/queries/attendance";
|
||||||
import {
|
import {
|
||||||
formatDate,
|
formatDate,
|
||||||
@@ -201,7 +200,6 @@ const renderProjectCell = (record: AttendanceRecord) => {
|
|||||||
|
|
||||||
export default function AttendanceHistory() {
|
export default function AttendanceHistory() {
|
||||||
const { user, hasPermission } = useAuth();
|
const { user, hasPermission } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { data: companySettings } = useQuery(companySettingsOptions());
|
const { data: companySettings } = useQuery(companySettingsOptions());
|
||||||
const companyName = companySettings?.company_name || "";
|
const companyName = companySettings?.company_name || "";
|
||||||
const printRef = useRef<HTMLDivElement>(null);
|
const printRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -212,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("-");
|
||||||
|
|||||||
@@ -26,10 +26,7 @@ const LocationMap = styled("div")(({ theme }) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { formatDate, formatTime } from "../utils/attendanceHelpers";
|
import { formatDate, formatTime } from "../utils/attendanceHelpers";
|
||||||
import {
|
import { attendanceLocationOptions } from "../lib/queries/attendance";
|
||||||
attendanceLocationOptions,
|
|
||||||
type LocationRecord,
|
|
||||||
} from "../lib/queries/attendance";
|
|
||||||
import { Button, Card, LoadingState, PageEnter, PageHeader } from "../ui";
|
import { Button, Card, LoadingState, PageEnter, PageHeader } from "../ui";
|
||||||
|
|
||||||
const BackIcon = (
|
const BackIcon = (
|
||||||
|
|||||||
@@ -3,10 +3,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import {
|
import { companySettingsOptions } from "../lib/queries/settings";
|
||||||
companySettingsOptions,
|
|
||||||
type CompanySettingsData,
|
|
||||||
} from "../lib/queries/settings";
|
|
||||||
import { bankAccountsOptions, type BankAccount } from "../lib/queries/common";
|
import { bankAccountsOptions, type BankAccount } from "../lib/queries/common";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
@@ -23,7 +20,6 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
Select,
|
Select,
|
||||||
CheckboxField,
|
CheckboxField,
|
||||||
StatusChip,
|
|
||||||
FileUpload,
|
FileUpload,
|
||||||
LoadingState,
|
LoadingState,
|
||||||
type DataColumn,
|
type DataColumn,
|
||||||
|
|||||||
@@ -621,7 +621,10 @@ 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;
|
||||||
@@ -656,8 +659,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);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
import { useState, useEffect, useMemo, useRef } from "react";
|
import { useState, useEffect, useMemo, useRef } from "react";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import { orderDetailOptions, type OrderItem } from "../lib/queries/orders";
|
||||||
orderDetailOptions,
|
|
||||||
type OrderData,
|
|
||||||
type OrderItem,
|
|
||||||
type OrderSection,
|
|
||||||
type OrderInvoice,
|
|
||||||
type OrderProject,
|
|
||||||
} from "../lib/queries/orders";
|
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
@@ -128,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);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -389,7 +383,17 @@ export default function OrderDetail() {
|
|||||||
mb: 3,
|
mb: 3,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
{/* flexWrap: the title must drop below the Zpět button on phones
|
||||||
|
instead of overflowing the viewport (same as Offer/Invoice
|
||||||
|
detail headers). */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 2,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
component={RouterLink}
|
component={RouterLink}
|
||||||
to="/orders"
|
to="/orders"
|
||||||
@@ -399,9 +403,22 @@ export default function OrderDetail() {
|
|||||||
>
|
>
|
||||||
Zpět
|
Zpět
|
||||||
</Button>
|
</Button>
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 1.5,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Typography variant="h4">
|
<Typography variant="h4">
|
||||||
Objednávka {order.order_number}
|
Objednávka
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{ color: "text.secondary", ml: 1, fontWeight: 400 }}
|
||||||
|
>
|
||||||
|
{order.order_number}
|
||||||
|
</Box>
|
||||||
</Typography>
|
</Typography>
|
||||||
<StatusChip
|
<StatusChip
|
||||||
label={statusLabel(ORDER_STATUS, order.status)}
|
label={statusLabel(ORDER_STATUS, order.status)}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import MenuItem from "@mui/material/MenuItem";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
projectDetailOptions,
|
projectDetailOptions,
|
||||||
type ProjectData,
|
|
||||||
type ProjectNote,
|
type ProjectNote,
|
||||||
} from "../lib/queries/projects";
|
} from "../lib/queries/projects";
|
||||||
import { userListOptions, type User as ApiUser } from "../lib/queries/users";
|
import { userListOptions, type User as ApiUser } from "../lib/queries/users";
|
||||||
@@ -298,7 +297,17 @@ export default function ProjectDetail() {
|
|||||||
mb: 3,
|
mb: 3,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
{/* flexWrap: the title must drop below the Zpět button on phones
|
||||||
|
instead of being squeezed beside it (mirrors Offer/Invoice
|
||||||
|
detail headers). */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 2,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
component={RouterLink}
|
component={RouterLink}
|
||||||
to="/projects"
|
to="/projects"
|
||||||
@@ -308,9 +317,22 @@ export default function ProjectDetail() {
|
|||||||
>
|
>
|
||||||
Zpět
|
Zpět
|
||||||
</Button>
|
</Button>
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 1.5,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Typography variant="h4">
|
<Typography variant="h4">
|
||||||
Projekt {project.project_number}
|
Projekt
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{ color: "text.secondary", ml: 1, fontWeight: 400 }}
|
||||||
|
>
|
||||||
|
{project.project_number}
|
||||||
|
</Box>
|
||||||
</Typography>
|
</Typography>
|
||||||
<StatusChip
|
<StatusChip
|
||||||
label={STATUS_LABELS[project.status] || project.status}
|
label={STATUS_LABELS[project.status] || project.status}
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ export default function Trips() {
|
|||||||
TripForm,
|
TripForm,
|
||||||
{ message?: string; error?: string }
|
{ message?: string; error?: string }
|
||||||
>({
|
>({
|
||||||
url: (formData) =>
|
url: () =>
|
||||||
editingTrip ? `${API_BASE}/trips/${editingTrip.id}` : `${API_BASE}/trips`,
|
editingTrip ? `${API_BASE}/trips/${editingTrip.id}` : `${API_BASE}/trips`,
|
||||||
method: () => (editingTrip ? "PUT" : "POST"),
|
method: () => (editingTrip ? "PUT" : "POST"),
|
||||||
invalidate: ["trips", "vehicles"],
|
invalidate: ["trips", "vehicles"],
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ interface InventoryItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseDecimal(raw: string): number {
|
function parseDecimal(raw: string): number {
|
||||||
const cleaned = raw.replace(/[eE+\-]/g, "");
|
const cleaned = raw.replace(/[eE+-]/g, "");
|
||||||
const n = Number(cleaned);
|
const n = Number(cleaned);
|
||||||
return Number.isFinite(n) ? n : 0;
|
return Number.isFinite(n) ? n : 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -65,7 +65,7 @@ interface BatchesResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseDecimal(raw: string): number {
|
function parseDecimal(raw: string): number {
|
||||||
const cleaned = raw.replace(/[eE+\-]/g, "");
|
const cleaned = raw.replace(/[eE+-]/g, "");
|
||||||
const n = Number(cleaned);
|
const n = Number(cleaned);
|
||||||
return Number.isFinite(n) ? n : 0;
|
return Number.isFinite(n) ? n : 0;
|
||||||
}
|
}
|
||||||
@@ -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>,
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ interface MovementItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseDecimal(raw: string): number {
|
function parseDecimal(raw: string): number {
|
||||||
const cleaned = raw.replace(/[eE+\-]/g, "");
|
const cleaned = raw.replace(/[eE+-]/g, "");
|
||||||
const n = Number(cleaned);
|
const n = Number(cleaned);
|
||||||
return Number.isFinite(n) ? n : 0;
|
return Number.isFinite(n) ? n : 0;
|
||||||
}
|
}
|
||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { createTheme, type Theme } from "@mui/material/styles";
|
import {
|
||||||
|
createTheme,
|
||||||
|
type Theme,
|
||||||
|
type PaletteColor,
|
||||||
|
type PaletteColorChannel,
|
||||||
|
} from "@mui/material/styles";
|
||||||
|
|
||||||
const FONT_BODY = "'Plus Jakarta Sans', system-ui, sans-serif";
|
const FONT_BODY = "'Plus Jakarta Sans', system-ui, sans-serif";
|
||||||
const FONT_HEADING = "'Urbanist', sans-serif";
|
const FONT_HEADING = "'Urbanist', sans-serif";
|
||||||
@@ -33,6 +38,26 @@ export const theme = createTheme({
|
|||||||
warning: { main: "#b45309", contrastText: "#fff" },
|
warning: { main: "#b45309", contrastText: "#fff" },
|
||||||
error: { main: "#b91c1c", contrastText: "#fff" },
|
error: { main: "#b91c1c", contrastText: "#fff" },
|
||||||
info: { main: "#1d4ed8", contrastText: "#fff" },
|
info: { main: "#1d4ed8", contrastText: "#fff" },
|
||||||
|
// Sidebar section hues (full objects so cssVariables emits *Channel
|
||||||
|
// tokens — the nav tiles use channel-alpha washes).
|
||||||
|
teal: {
|
||||||
|
main: "#0e8a7c",
|
||||||
|
light: "#3aa99c",
|
||||||
|
dark: "#0a675d",
|
||||||
|
contrastText: "#fff",
|
||||||
|
},
|
||||||
|
violet: {
|
||||||
|
main: "#6a4cb4",
|
||||||
|
light: "#8a6fd0",
|
||||||
|
dark: "#54399a",
|
||||||
|
contrastText: "#fff",
|
||||||
|
},
|
||||||
|
slate: {
|
||||||
|
main: "#5c6470",
|
||||||
|
light: "#7d8694",
|
||||||
|
dark: "#454c56",
|
||||||
|
contrastText: "#fff",
|
||||||
|
},
|
||||||
background: { default: "#f4f3f1", paper: "#ffffff" },
|
background: { default: "#f4f3f1", paper: "#ffffff" },
|
||||||
text: { primary: "#1a1a1a", secondary: "#555555" },
|
text: { primary: "#1a1a1a", secondary: "#555555" },
|
||||||
divider: "rgba(0,0,0,0.1)",
|
divider: "rgba(0,0,0,0.1)",
|
||||||
@@ -46,6 +71,25 @@ export const theme = createTheme({
|
|||||||
warning: { main: "#f59e0b", contrastText: "#1a1a1a" },
|
warning: { main: "#f59e0b", contrastText: "#1a1a1a" },
|
||||||
error: { main: "#ef4444", contrastText: "#1a1a1a" },
|
error: { main: "#ef4444", contrastText: "#1a1a1a" },
|
||||||
info: { main: "#3b82f6", contrastText: "#1a1a1a" },
|
info: { main: "#3b82f6", contrastText: "#1a1a1a" },
|
||||||
|
// Sidebar section hues — brighter mains so the glyphs read on dark.
|
||||||
|
teal: {
|
||||||
|
main: "#2dd4bf",
|
||||||
|
light: "#5eead4",
|
||||||
|
dark: "#14b8a6",
|
||||||
|
contrastText: "#1a1a1a",
|
||||||
|
},
|
||||||
|
violet: {
|
||||||
|
main: "#a78bfa",
|
||||||
|
light: "#c4b5fd",
|
||||||
|
dark: "#8b5cf6",
|
||||||
|
contrastText: "#1a1a1a",
|
||||||
|
},
|
||||||
|
slate: {
|
||||||
|
main: "#94a3b8",
|
||||||
|
light: "#cbd5e1",
|
||||||
|
dark: "#64748b",
|
||||||
|
contrastText: "#1a1a1a",
|
||||||
|
},
|
||||||
background: { default: "#0f0f0f", paper: "#1a1a1a" },
|
background: { default: "#0f0f0f", paper: "#1a1a1a" },
|
||||||
text: { primary: "#ffffff", secondary: "#a0a0a0" },
|
text: { primary: "#ffffff", secondary: "#a0a0a0" },
|
||||||
divider: "rgba(255,255,255,0.08)",
|
divider: "rgba(255,255,255,0.08)",
|
||||||
@@ -239,6 +283,24 @@ export const fonts = {
|
|||||||
mono: FONT_MONO,
|
mono: FONT_MONO,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Custom sidebar-section palette entries (teal/violet/slate above). The
|
||||||
|
// channel intersection matters: theme.vars.palette.<hue>.mainChannel is how
|
||||||
|
// the nav tiles build their alpha washes.
|
||||||
|
type NavPaletteColor = PaletteColor & PaletteColorChannel;
|
||||||
|
|
||||||
|
declare module "@mui/material/styles" {
|
||||||
|
interface Palette {
|
||||||
|
teal: NavPaletteColor;
|
||||||
|
violet: NavPaletteColor;
|
||||||
|
slate: NavPaletteColor;
|
||||||
|
}
|
||||||
|
interface PaletteOptions {
|
||||||
|
teal?: PaletteOptions["primary"];
|
||||||
|
violet?: PaletteOptions["primary"];
|
||||||
|
slate?: PaletteOptions["primary"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type IconBadgeColor =
|
export type IconBadgeColor =
|
||||||
| "default"
|
| "default"
|
||||||
| "primary"
|
| "primary"
|
||||||
|
|||||||
@@ -88,9 +88,8 @@ export default function SidebarNav({ onNavigate, onLogout }: SidebarNavProps) {
|
|||||||
sx={{
|
sx={{
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
mb: 0.25,
|
mb: 0.25,
|
||||||
py: 0.6,
|
py: 0.45,
|
||||||
color: "text.secondary",
|
color: "text.secondary",
|
||||||
"& svg": { width: 18, height: 18 },
|
|
||||||
"&.Mui-selected, &.Mui-selected:hover": {
|
"&.Mui-selected, &.Mui-selected:hover": {
|
||||||
bgcolor: "background.paper",
|
bgcolor: "background.paper",
|
||||||
color: "primary.main",
|
color: "primary.main",
|
||||||
@@ -99,8 +98,29 @@ export default function SidebarNav({ onNavigate, onLogout }: SidebarNavProps) {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ListItemIcon sx={{ minWidth: 32, color: "inherit" }}>
|
<ListItemIcon sx={{ minWidth: 36, color: "inherit" }}>
|
||||||
|
{item.rawIcon ? (
|
||||||
|
item.icon
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={(t) => ({
|
||||||
|
width: 26,
|
||||||
|
height: 26,
|
||||||
|
// Echoes OdinMark's rounded-square tile; only Odin
|
||||||
|
// is solid + animated, these are quiet washes.
|
||||||
|
borderRadius: "32%",
|
||||||
|
flexShrink: 0,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor: `rgba(${t.vars!.palette[section.hue].mainChannel} / 0.12)`,
|
||||||
|
color: t.vars!.palette[section.hue].main,
|
||||||
|
"& svg": { width: 16, height: 16 },
|
||||||
|
})}
|
||||||
|
>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary={item.label}
|
primary={item.label}
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { type ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
import OdinMark from "../components/odin/OdinMark";
|
import OdinMark from "../components/odin/OdinMark";
|
||||||
|
|
||||||
|
/** Palette key driving a section's tile wash + glyph color (see theme.ts). */
|
||||||
|
export type NavHue =
|
||||||
|
| "primary"
|
||||||
|
| "info"
|
||||||
|
| "teal"
|
||||||
|
| "warning"
|
||||||
|
| "violet"
|
||||||
|
| "slate";
|
||||||
|
|
||||||
export interface MenuItem {
|
export interface MenuItem {
|
||||||
path: string;
|
path: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -10,33 +19,51 @@ export interface MenuItem {
|
|||||||
matchAlso?: string[];
|
matchAlso?: string[];
|
||||||
matchExclude?: string[];
|
matchExclude?: string[];
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
|
/** Icon ships its own container (OdinMark) — SidebarNav skips the tile. */
|
||||||
|
rawIcon?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MenuSection {
|
export interface MenuSection {
|
||||||
label: string;
|
label: string;
|
||||||
|
hue: NavHue;
|
||||||
items: MenuItem[];
|
items: MenuItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const menuSections: MenuSection[] = [
|
/**
|
||||||
{
|
* One stroke glyph per item, every glyph unique within the set (pinned by
|
||||||
label: "Přehled",
|
* navdata-icons.test.ts). 24-viewBox, stroke 2, round caps — rendered at
|
||||||
items: [
|
* 16px inside SidebarNav's 26px section-hue tile.
|
||||||
{
|
*/
|
||||||
path: "/",
|
function glyph(children: ReactNode) {
|
||||||
label: "Přehled",
|
return (
|
||||||
end: true,
|
|
||||||
icon: (
|
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
>
|
>
|
||||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
{children}
|
||||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
|
||||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
|
||||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
|
||||||
</svg>
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const menuSections: MenuSection[] = [
|
||||||
|
{
|
||||||
|
label: "Přehled",
|
||||||
|
hue: "primary",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
label: "Přehled",
|
||||||
|
end: true,
|
||||||
|
// tachometr — dashboard
|
||||||
|
icon: glyph(
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="13" r="8.5" />
|
||||||
|
<path d="M12 13l4-4.5" />
|
||||||
|
</>,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -44,78 +71,66 @@ export const menuSections: MenuSection[] = [
|
|||||||
label: "Odin",
|
label: "Odin",
|
||||||
permission: "ai.use",
|
permission: "ai.use",
|
||||||
end: true,
|
end: true,
|
||||||
icon: <OdinMark size={22} />,
|
icon: <OdinMark size={26} />,
|
||||||
|
rawIcon: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Docházka",
|
label: "Docházka",
|
||||||
|
hue: "info",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
path: "/attendance",
|
path: "/attendance",
|
||||||
label: "Záznam",
|
label: "Záznam",
|
||||||
permission: "attendance.record",
|
permission: "attendance.record",
|
||||||
end: true,
|
end: true,
|
||||||
icon: (
|
// píchačky — hodiny
|
||||||
<svg
|
icon: glyph(
|
||||||
viewBox="0 0 24 24"
|
<>
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="9" />
|
<circle cx="12" cy="12" r="9" />
|
||||||
<polyline points="12 7 12 12 15 15" />
|
<polyline points="12 7 12 12 15 15" />
|
||||||
</svg>
|
</>,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/attendance/history",
|
path: "/attendance/history",
|
||||||
label: "Moje historie",
|
label: "Moje historie",
|
||||||
permission: "attendance.history",
|
permission: "attendance.history",
|
||||||
icon: (
|
// výkaz s hodinami — timesheet
|
||||||
<svg
|
icon: glyph(
|
||||||
viewBox="0 0 24 24"
|
<>
|
||||||
fill="none"
|
<path d="M14 3H5a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h6" />
|
||||||
stroke="currentColor"
|
<line x1="7" y1="8" x2="13" y2="8" />
|
||||||
strokeWidth="2"
|
<line x1="7" y1="12" x2="11" y2="12" />
|
||||||
>
|
<circle cx="16.5" cy="16" r="4.5" />
|
||||||
<polyline points="12 8 12 12 14 14" />
|
<polyline points="16.5 14 16.5 16 18 17" />
|
||||||
<path d="M3.05 11a9 9 0 1 1 .5 4m-.5 5v-5h5" />
|
</>,
|
||||||
</svg>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/attendance/requests",
|
path: "/attendance/requests",
|
||||||
label: "Žádosti",
|
label: "Žádosti",
|
||||||
permission: "attendance.record",
|
permission: "attendance.record",
|
||||||
icon: (
|
// odeslaná žádost — vlaštovka
|
||||||
<svg
|
icon: glyph(
|
||||||
viewBox="0 0 24 24"
|
<>
|
||||||
fill="none"
|
<path d="M22 2L11 13" />
|
||||||
stroke="currentColor"
|
<path d="M22 2l-7 20-4-9-9-4z" />
|
||||||
strokeWidth="2"
|
</>,
|
||||||
>
|
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
||||||
<polyline points="14 2 14 8 20 8" />
|
|
||||||
<line x1="12" y1="18" x2="12" y2="12" />
|
|
||||||
<line x1="9" y1="15" x2="15" y2="15" />
|
|
||||||
</svg>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/attendance/approval",
|
path: "/attendance/approval",
|
||||||
label: "Schvalování",
|
label: "Schvalování",
|
||||||
permission: "attendance.approve",
|
permission: "attendance.approve",
|
||||||
icon: (
|
// razítko
|
||||||
<svg
|
icon: glyph(
|
||||||
viewBox="0 0 24 24"
|
<>
|
||||||
fill="none"
|
<path d="M9.5 12V9.8C9.5 8.4 8 7.8 8 6a4 4 0 0 1 8 0c0 1.8-1.5 2.4-1.5 3.8V12" />
|
||||||
stroke="currentColor"
|
<path d="M5 16a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v2H5z" />
|
||||||
strokeWidth="2"
|
<line x1="5" y1="21" x2="19" y2="21" />
|
||||||
>
|
</>,
|
||||||
<path d="M9 12l2 2 4-4" />
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
</svg>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -124,147 +139,108 @@ export const menuSections: MenuSection[] = [
|
|||||||
permission: "attendance.manage",
|
permission: "attendance.manage",
|
||||||
matchPrefix: "/attendance/admin",
|
matchPrefix: "/attendance/admin",
|
||||||
matchAlso: ["/attendance/create", "/attendance/location"],
|
matchAlso: ["/attendance/create", "/attendance/location"],
|
||||||
icon: (
|
// osoba + fajfka
|
||||||
<svg
|
icon: glyph(
|
||||||
viewBox="0 0 24 24"
|
<>
|
||||||
fill="none"
|
<circle cx="9" cy="8" r="3.5" />
|
||||||
stroke="currentColor"
|
<path d="M3 20a6 6 0 0 1 12 0" />
|
||||||
strokeWidth="2"
|
<path d="M15.5 11.5l2 2 4-4" />
|
||||||
>
|
</>,
|
||||||
<line x1="4" y1="21" x2="4" y2="14" />
|
|
||||||
<line x1="4" y1="10" x2="4" y2="3" />
|
|
||||||
<line x1="12" y1="21" x2="12" y2="12" />
|
|
||||||
<line x1="12" y1="8" x2="12" y2="3" />
|
|
||||||
<line x1="20" y1="21" x2="20" y2="16" />
|
|
||||||
<line x1="20" y1="12" x2="20" y2="3" />
|
|
||||||
<line x1="1" y1="14" x2="7" y2="14" />
|
|
||||||
<line x1="9" y1="8" x2="15" y2="8" />
|
|
||||||
<line x1="17" y1="16" x2="23" y2="16" />
|
|
||||||
</svg>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/attendance/balances",
|
path: "/attendance/balances",
|
||||||
label: "Správa bilancí",
|
label: "Správa bilancí",
|
||||||
permission: "attendance.balances",
|
permission: "attendance.balances",
|
||||||
icon: (
|
// kniha plus/mínus — ruční korekce bilancí
|
||||||
<svg
|
icon: glyph(
|
||||||
viewBox="0 0 24 24"
|
<>
|
||||||
fill="none"
|
<rect x="4" y="3" width="16" height="18" rx="2" />
|
||||||
stroke="currentColor"
|
<line x1="9" y1="8.5" x2="15" y2="8.5" />
|
||||||
strokeWidth="2"
|
<line x1="12" y1="5.5" x2="12" y2="11.5" />
|
||||||
>
|
<line x1="9" y1="16" x2="15" y2="16" />
|
||||||
<line x1="18" y1="20" x2="18" y2="10" />
|
</>,
|
||||||
<line x1="12" y1="20" x2="12" y2="4" />
|
|
||||||
<line x1="6" y1="20" x2="6" y2="14" />
|
|
||||||
</svg>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/plan-work",
|
path: "/plan-work",
|
||||||
label: "Plán prací",
|
label: "Plán prací",
|
||||||
permission: ["attendance.manage", "attendance.record"],
|
permission: ["attendance.manage", "attendance.record"],
|
||||||
icon: (
|
// kalendář s plánovacím blokem
|
||||||
<svg
|
icon: glyph(
|
||||||
viewBox="0 0 24 24"
|
<>
|
||||||
fill="none"
|
<rect x="3" y="4" width="18" height="17" rx="2" />
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
|
||||||
<line x1="16" y1="2" x2="16" y2="6" />
|
<line x1="16" y1="2" x2="16" y2="6" />
|
||||||
<line x1="8" y1="2" x2="8" y2="6" />
|
<line x1="8" y1="2" x2="8" y2="6" />
|
||||||
<line x1="3" y1="10" x2="21" y2="10" />
|
<line x1="3" y1="9.5" x2="21" y2="9.5" />
|
||||||
</svg>
|
<path d="M8 14h3v3H8z" />
|
||||||
|
</>,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Kniha jízd",
|
label: "Kniha jízd",
|
||||||
|
hue: "teal",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
path: "/trips",
|
path: "/trips",
|
||||||
label: "Záznam",
|
label: "Záznam",
|
||||||
permission: "trips.record",
|
permission: "trips.record",
|
||||||
end: true,
|
end: true,
|
||||||
icon: (
|
// trasa A→B
|
||||||
<svg
|
icon: glyph(
|
||||||
viewBox="0 0 24 24"
|
<>
|
||||||
fill="none"
|
<circle cx="5" cy="19" r="2" />
|
||||||
stroke="currentColor"
|
<circle cx="19" cy="5" r="2" />
|
||||||
strokeWidth="2"
|
<path d="M5 17C5 11 19 13 19 7" />
|
||||||
>
|
</>,
|
||||||
<circle cx="5" cy="18" r="3" />
|
|
||||||
<circle cx="19" cy="18" r="3" />
|
|
||||||
<path d="M5 18V12L8 5h8l3 7v6" />
|
|
||||||
<path d="M10 18h4" />
|
|
||||||
</svg>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/trips/history",
|
path: "/trips/history",
|
||||||
label: "Moje historie",
|
label: "Moje historie",
|
||||||
permission: "trips.history",
|
permission: "trips.history",
|
||||||
icon: (
|
// navštívená místa — pin
|
||||||
<svg
|
icon: glyph(
|
||||||
viewBox="0 0 24 24"
|
<>
|
||||||
fill="none"
|
<path d="M12 21s-6-5.2-6-10a6 6 0 0 1 12 0c0 4.8-6 10-6 10z" />
|
||||||
stroke="currentColor"
|
<circle cx="12" cy="11" r="2.5" />
|
||||||
strokeWidth="2"
|
</>,
|
||||||
>
|
|
||||||
<polyline points="12 8 12 12 14 14" />
|
|
||||||
<path d="M3.05 11a9 9 0 1 1 .5 4m-.5 5v-5h5" />
|
|
||||||
</svg>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/trips/admin",
|
path: "/trips/admin",
|
||||||
label: "Správa",
|
label: "Správa",
|
||||||
permission: "trips.manage",
|
permission: "trips.manage",
|
||||||
icon: (
|
// schránka s fajfkou — kniha jízd
|
||||||
<svg
|
icon: glyph(
|
||||||
viewBox="0 0 24 24"
|
<>
|
||||||
fill="none"
|
<rect x="5" y="4" width="14" height="17" rx="2" />
|
||||||
stroke="currentColor"
|
<path d="M9 2h6v4H9z" />
|
||||||
strokeWidth="2"
|
<path d="M9 13l2 2 4-4" />
|
||||||
>
|
</>,
|
||||||
<line x1="4" y1="21" x2="4" y2="14" />
|
|
||||||
<line x1="4" y1="10" x2="4" y2="3" />
|
|
||||||
<line x1="12" y1="21" x2="12" y2="12" />
|
|
||||||
<line x1="12" y1="8" x2="12" y2="3" />
|
|
||||||
<line x1="20" y1="21" x2="20" y2="16" />
|
|
||||||
<line x1="20" y1="12" x2="20" y2="3" />
|
|
||||||
<line x1="1" y1="14" x2="7" y2="14" />
|
|
||||||
<line x1="9" y1="8" x2="15" y2="8" />
|
|
||||||
<line x1="17" y1="16" x2="23" y2="16" />
|
|
||||||
</svg>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/vehicles",
|
path: "/vehicles",
|
||||||
label: "Vozidla",
|
label: "Vozidla",
|
||||||
permission: "vehicles.manage",
|
permission: "vehicles.manage",
|
||||||
icon: (
|
// auto
|
||||||
<svg
|
icon: glyph(
|
||||||
viewBox="0 0 24 24"
|
<>
|
||||||
fill="none"
|
<path d="M5 17H3v-4l2-5h11l3 5h2v4h-2" />
|
||||||
stroke="currentColor"
|
<circle cx="7.5" cy="17" r="2" />
|
||||||
strokeWidth="2"
|
<circle cx="16.5" cy="17" r="2" />
|
||||||
>
|
<line x1="9.5" y1="17" x2="14.5" y2="17" />
|
||||||
<rect x="1" y="3" width="15" height="13" rx="2" />
|
</>,
|
||||||
<path d="M16 8h4l3 3v5h-7V8z" />
|
|
||||||
<circle cx="5.5" cy="18.5" r="2.5" />
|
|
||||||
<circle cx="18.5" cy="18.5" r="2.5" />
|
|
||||||
</svg>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Administrativa",
|
label: "Administrativa",
|
||||||
|
hue: "warning",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
path: "/offers",
|
path: "/offers",
|
||||||
@@ -272,16 +248,12 @@ export const menuSections: MenuSection[] = [
|
|||||||
permission: "offers.view",
|
permission: "offers.view",
|
||||||
matchPrefix: "/offers",
|
matchPrefix: "/offers",
|
||||||
matchExclude: ["/offers/customers", "/offers/templates"],
|
matchExclude: ["/offers/customers", "/offers/templates"],
|
||||||
icon: (
|
// cenovka
|
||||||
<svg
|
icon: glyph(
|
||||||
viewBox="0 0 24 24"
|
<>
|
||||||
fill="none"
|
<path d="M20.6 13.4L11 3H4v7l9.6 10.4a2 2 0 0 0 2.8 0l4.2-4.2a2 2 0 0 0 0-2.8z" />
|
||||||
stroke="currentColor"
|
<circle cx="8" cy="8" r="1.5" />
|
||||||
strokeWidth="2"
|
</>,
|
||||||
>
|
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
||||||
<polyline points="14 2 14 8 20 8" />
|
|
||||||
</svg>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -289,17 +261,13 @@ export const menuSections: MenuSection[] = [
|
|||||||
label: "Objednávky",
|
label: "Objednávky",
|
||||||
permission: "orders.view",
|
permission: "orders.view",
|
||||||
matchPrefix: "/orders",
|
matchPrefix: "/orders",
|
||||||
icon: (
|
// košík
|
||||||
<svg
|
icon: glyph(
|
||||||
viewBox="0 0 24 24"
|
<>
|
||||||
fill="none"
|
<circle cx="9" cy="20" r="1.6" />
|
||||||
stroke="currentColor"
|
<circle cx="17" cy="20" r="1.6" />
|
||||||
strokeWidth="2"
|
<path d="M2 3h3l2.7 12h10.6l2.7-9H6" />
|
||||||
>
|
</>,
|
||||||
<path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z" />
|
|
||||||
<line x1="3" y1="6" x2="21" y2="6" />
|
|
||||||
<path d="M16 10a4 4 0 0 1-8 0" />
|
|
||||||
</svg>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -307,16 +275,13 @@ export const menuSections: MenuSection[] = [
|
|||||||
label: "Faktury",
|
label: "Faktury",
|
||||||
permission: "invoices.view",
|
permission: "invoices.view",
|
||||||
matchPrefix: "/invoices",
|
matchPrefix: "/invoices",
|
||||||
icon: (
|
// účtenka
|
||||||
<svg
|
icon: glyph(
|
||||||
viewBox="0 0 24 24"
|
<>
|
||||||
fill="none"
|
<path d="M6 2h12v20l-3-2-3 2-3-2-3 2z" />
|
||||||
stroke="currentColor"
|
<line x1="9" y1="7" x2="15" y2="7" />
|
||||||
strokeWidth="2"
|
<line x1="9" y1="11" x2="15" y2="11" />
|
||||||
>
|
</>,
|
||||||
<line x1="12" y1="1" x2="12" y2="23" />
|
|
||||||
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
|
||||||
</svg>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -324,60 +289,44 @@ export const menuSections: MenuSection[] = [
|
|||||||
label: "Projekty",
|
label: "Projekty",
|
||||||
permission: "projects.view",
|
permission: "projects.view",
|
||||||
matchPrefix: "/projects",
|
matchPrefix: "/projects",
|
||||||
icon: (
|
// složka (NAS projektové složky)
|
||||||
<svg
|
icon: glyph(
|
||||||
viewBox="0 0 24 24"
|
<path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V18a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />,
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<rect x="2" y="3" width="20" height="14" rx="2" />
|
|
||||||
<line x1="8" y1="21" x2="16" y2="21" />
|
|
||||||
<line x1="12" y1="17" x2="12" y2="21" />
|
|
||||||
</svg>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/offers/customers",
|
path: "/offers/customers",
|
||||||
label: "Zákazníci",
|
label: "Zákazníci",
|
||||||
permission: "customers.view",
|
permission: "customers.view",
|
||||||
icon: (
|
// vizitka
|
||||||
<svg
|
icon: glyph(
|
||||||
viewBox="0 0 24 24"
|
<>
|
||||||
fill="none"
|
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||||
stroke="currentColor"
|
<circle cx="8.5" cy="10.5" r="2.2" />
|
||||||
strokeWidth="2"
|
<path d="M5 16.5a3.5 3.5 0 0 1 7 0" />
|
||||||
>
|
<line x1="14.5" y1="9.5" x2="19" y2="9.5" />
|
||||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
<line x1="14.5" y1="13.5" x2="19" y2="13.5" />
|
||||||
<circle cx="9" cy="7" r="4" />
|
</>,
|
||||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
|
||||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
|
||||||
</svg>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Sklad",
|
label: "Sklad",
|
||||||
|
hue: "violet",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
path: "/warehouse",
|
path: "/warehouse",
|
||||||
label: "Přehled",
|
label: "Přehled",
|
||||||
permission: "warehouse.view",
|
permission: "warehouse.view",
|
||||||
end: true,
|
end: true,
|
||||||
icon: (
|
// skladová hala
|
||||||
<svg
|
icon: glyph(
|
||||||
viewBox="0 0 24 24"
|
<>
|
||||||
fill="none"
|
<path d="M3 21V9l9-5 9 5v12" />
|
||||||
stroke="currentColor"
|
<path d="M7 21v-7h10v7" />
|
||||||
strokeWidth="2"
|
<line x1="7" y1="17.5" x2="17" y2="17.5" />
|
||||||
strokeLinecap="round"
|
</>,
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
|
||||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
|
||||||
<line x1="12" y1="22.08" x2="12" y2="12" />
|
|
||||||
</svg>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -385,17 +334,13 @@ export const menuSections: MenuSection[] = [
|
|||||||
label: "Položky",
|
label: "Položky",
|
||||||
permission: "warehouse.view",
|
permission: "warehouse.view",
|
||||||
matchPrefix: "/warehouse/items",
|
matchPrefix: "/warehouse/items",
|
||||||
icon: (
|
// krabice
|
||||||
<svg
|
icon: glyph(
|
||||||
viewBox="0 0 24 24"
|
<>
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
||||||
<line x1="12" y1="22.08" x2="12" y2="12" />
|
<line x1="12" y1="22.08" x2="12" y2="12" />
|
||||||
</svg>
|
</>,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -403,17 +348,13 @@ export const menuSections: MenuSection[] = [
|
|||||||
label: "Příjmy",
|
label: "Příjmy",
|
||||||
permission: "warehouse.operate",
|
permission: "warehouse.operate",
|
||||||
matchPrefix: "/warehouse/receipts",
|
matchPrefix: "/warehouse/receipts",
|
||||||
icon: (
|
// šipka do krabice
|
||||||
<svg
|
icon: glyph(
|
||||||
viewBox="0 0 24 24"
|
<>
|
||||||
fill="none"
|
<rect x="4" y="12" width="16" height="9" rx="1" />
|
||||||
stroke="currentColor"
|
<polyline points="8 6 12 10 16 6" />
|
||||||
strokeWidth="2"
|
<line x1="12" y1="2" x2="12" y2="10" />
|
||||||
>
|
</>,
|
||||||
<polyline points="17 11 12 6 7 11" />
|
|
||||||
<line x1="12" y1="6" x2="12" y2="18" />
|
|
||||||
<path d="M5 19h14" />
|
|
||||||
</svg>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -421,17 +362,13 @@ export const menuSections: MenuSection[] = [
|
|||||||
label: "Výdeje",
|
label: "Výdeje",
|
||||||
permission: "warehouse.operate",
|
permission: "warehouse.operate",
|
||||||
matchPrefix: "/warehouse/issues",
|
matchPrefix: "/warehouse/issues",
|
||||||
icon: (
|
// šipka z krabice ven
|
||||||
<svg
|
icon: glyph(
|
||||||
viewBox="0 0 24 24"
|
<>
|
||||||
fill="none"
|
<rect x="4" y="13" width="16" height="8" rx="1" />
|
||||||
stroke="currentColor"
|
<polyline points="8 6 12 2 16 6" />
|
||||||
strokeWidth="2"
|
<line x1="12" y1="2" x2="12" y2="11" />
|
||||||
>
|
</>,
|
||||||
<polyline points="7 13 12 18 17 13" />
|
|
||||||
<line x1="12" y1="18" x2="12" y2="6" />
|
|
||||||
<path d="M5 5h14" />
|
|
||||||
</svg>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -439,18 +376,12 @@ export const menuSections: MenuSection[] = [
|
|||||||
label: "Rezervace",
|
label: "Rezervace",
|
||||||
permission: "warehouse.operate",
|
permission: "warehouse.operate",
|
||||||
matchPrefix: "/warehouse/reservations",
|
matchPrefix: "/warehouse/reservations",
|
||||||
icon: (
|
// krabice se záložkou
|
||||||
<svg
|
icon: glyph(
|
||||||
viewBox="0 0 24 24"
|
<>
|
||||||
fill="none"
|
<rect x="3" y="8" width="13" height="12" rx="1" />
|
||||||
stroke="currentColor"
|
<path d="M16 3h5v9l-2.5-2L16 12z" />
|
||||||
strokeWidth="2"
|
</>,
|
||||||
>
|
|
||||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
|
||||||
<circle cx="9" cy="7" r="4" />
|
|
||||||
<line x1="19" y1="8" x2="19" y2="14" />
|
|
||||||
<line x1="22" y1="11" x2="16" y2="11" />
|
|
||||||
</svg>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -458,16 +389,15 @@ export const menuSections: MenuSection[] = [
|
|||||||
label: "Inventura",
|
label: "Inventura",
|
||||||
permission: "warehouse.inventory",
|
permission: "warehouse.inventory",
|
||||||
matchPrefix: "/warehouse/inventory",
|
matchPrefix: "/warehouse/inventory",
|
||||||
icon: (
|
// čárový kód — fyzický sken zásob
|
||||||
<svg
|
icon: glyph(
|
||||||
viewBox="0 0 24 24"
|
<>
|
||||||
fill="none"
|
<line x1="4" y1="6" x2="4" y2="18" />
|
||||||
stroke="currentColor"
|
<line x1="8" y1="6" x2="8" y2="18" />
|
||||||
strokeWidth="2"
|
<line x1="12" y1="6" x2="12" y2="18" />
|
||||||
>
|
<line x1="17" y1="6" x2="17" y2="18" />
|
||||||
<path d="M9 11l3 3L22 4" />
|
<line x1="20" y1="6" x2="20" y2="18" />
|
||||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
</>,
|
||||||
</svg>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -475,17 +405,12 @@ export const menuSections: MenuSection[] = [
|
|||||||
label: "Reporty",
|
label: "Reporty",
|
||||||
permission: "warehouse.view",
|
permission: "warehouse.view",
|
||||||
end: true,
|
end: true,
|
||||||
icon: (
|
// graf v rámu
|
||||||
<svg
|
icon: glyph(
|
||||||
viewBox="0 0 24 24"
|
<>
|
||||||
fill="none"
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
stroke="currentColor"
|
<polyline points="7 14 11 10 14 13 17 8" />
|
||||||
strokeWidth="2"
|
</>,
|
||||||
>
|
|
||||||
<line x1="18" y1="20" x2="18" y2="10" />
|
|
||||||
<line x1="12" y1="20" x2="12" y2="4" />
|
|
||||||
<line x1="6" y1="20" x2="6" y2="14" />
|
|
||||||
</svg>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -493,23 +418,14 @@ export const menuSections: MenuSection[] = [
|
|||||||
label: "Kategorie",
|
label: "Kategorie",
|
||||||
permission: "warehouse.manage",
|
permission: "warehouse.manage",
|
||||||
matchPrefix: "/warehouse/categories",
|
matchPrefix: "/warehouse/categories",
|
||||||
icon: (
|
// tvary — třídění do kategorií
|
||||||
<svg
|
icon: glyph(
|
||||||
viewBox="0 0 24 24"
|
<>
|
||||||
fill="none"
|
<circle cx="7.5" cy="7.5" r="3.5" />
|
||||||
stroke="currentColor"
|
<rect x="13.5" y="4" width="7" height="7" rx="1" />
|
||||||
strokeWidth="2"
|
<path d="M7.5 13.5l3.5 7h-7z" />
|
||||||
>
|
<rect x="13.5" y="13.5" width="7" height="7" rx="3.5" />
|
||||||
<line x1="4" y1="21" x2="4" y2="14" />
|
</>,
|
||||||
<line x1="4" y1="10" x2="4" y2="3" />
|
|
||||||
<line x1="12" y1="21" x2="12" y2="12" />
|
|
||||||
<line x1="12" y1="8" x2="12" y2="3" />
|
|
||||||
<line x1="20" y1="21" x2="20" y2="16" />
|
|
||||||
<line x1="20" y1="12" x2="20" y2="3" />
|
|
||||||
<line x1="1" y1="14" x2="7" y2="14" />
|
|
||||||
<line x1="9" y1="8" x2="15" y2="8" />
|
|
||||||
<line x1="17" y1="16" x2="23" y2="16" />
|
|
||||||
</svg>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -517,18 +433,16 @@ export const menuSections: MenuSection[] = [
|
|||||||
label: "Lokace",
|
label: "Lokace",
|
||||||
permission: "warehouse.manage",
|
permission: "warehouse.manage",
|
||||||
matchPrefix: "/warehouse/locations",
|
matchPrefix: "/warehouse/locations",
|
||||||
icon: (
|
// regál s krabicemi
|
||||||
<svg
|
icon: glyph(
|
||||||
viewBox="0 0 24 24"
|
<>
|
||||||
fill="none"
|
<path d="M3 21V4" />
|
||||||
stroke="currentColor"
|
<path d="M21 21V4" />
|
||||||
strokeWidth="2"
|
<line x1="3" y1="12" x2="21" y2="12" />
|
||||||
>
|
<line x1="3" y1="21" x2="21" y2="21" />
|
||||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
<rect x="7" y="8" width="4" height="4" />
|
||||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
<rect x="13" y="17" width="4" height="4" />
|
||||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
</>,
|
||||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
|
||||||
</svg>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -536,41 +450,34 @@ export const menuSections: MenuSection[] = [
|
|||||||
label: "Dodavatelé",
|
label: "Dodavatelé",
|
||||||
permission: "warehouse.manage",
|
permission: "warehouse.manage",
|
||||||
matchPrefix: "/warehouse/suppliers",
|
matchPrefix: "/warehouse/suppliers",
|
||||||
icon: (
|
// kamion
|
||||||
<svg
|
icon: glyph(
|
||||||
viewBox="0 0 24 24"
|
<>
|
||||||
fill="none"
|
<rect x="1" y="6" width="13" height="10" rx="1" />
|
||||||
stroke="currentColor"
|
<path d="M14 9h4l3 4v3h-7" />
|
||||||
strokeWidth="2"
|
<circle cx="6" cy="18.5" r="2" />
|
||||||
>
|
<circle cx="17.5" cy="18.5" r="2" />
|
||||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
</>,
|
||||||
<circle cx="9" cy="7" r="4" />
|
|
||||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
|
||||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
|
||||||
</svg>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Systém",
|
label: "Systém",
|
||||||
|
hue: "slate",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
path: "/users",
|
path: "/users",
|
||||||
label: "Uživatelé",
|
label: "Uživatelé",
|
||||||
permission: "users.view",
|
permission: "users.view",
|
||||||
icon: (
|
// lidé — uživatelské účty
|
||||||
<svg
|
icon: glyph(
|
||||||
viewBox="0 0 24 24"
|
<>
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||||
<circle cx="9" cy="7" r="4" />
|
<circle cx="9" cy="7" r="4" />
|
||||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||||
</svg>
|
</>,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -583,35 +490,27 @@ export const menuSections: MenuSection[] = [
|
|||||||
"settings.system",
|
"settings.system",
|
||||||
"settings.templates",
|
"settings.templates",
|
||||||
],
|
],
|
||||||
icon: (
|
// ozubené kolo
|
||||||
<svg
|
icon: glyph(
|
||||||
viewBox="0 0 24 24"
|
<>
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="3" />
|
<circle cx="12" cy="12" r="3" />
|
||||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||||
</svg>
|
</>,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/audit-log",
|
path: "/audit-log",
|
||||||
label: "Audit log",
|
label: "Audit log",
|
||||||
permission: "settings.audit",
|
permission: "settings.audit",
|
||||||
icon: (
|
// lupa nad záznamy
|
||||||
<svg
|
icon: glyph(
|
||||||
viewBox="0 0 24 24"
|
<>
|
||||||
fill="none"
|
<path d="M4 5h12" />
|
||||||
stroke="currentColor"
|
<path d="M4 10h6" />
|
||||||
strokeWidth="2"
|
<path d="M4 15h5" />
|
||||||
>
|
<circle cx="15" cy="14" r="4.5" />
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
<line x1="18.2" y1="17.2" x2="21.5" y2="20.5" />
|
||||||
<polyline points="14 2 14 8 20 8" />
|
</>,
|
||||||
<line x1="16" y1="13" x2="8" y2="13" />
|
|
||||||
<line x1="16" y1="17" x2="8" y2="17" />
|
|
||||||
<polyline points="10 9 9 9 8 9" />
|
|
||||||
</svg>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { FastifyRequest, FastifyReply } from "fastify";
|
import { FastifyRequest, FastifyReply } from "fastify";
|
||||||
import { verifyAccessToken } from "../services/auth";
|
import { verifyAccessToken } from "../services/auth";
|
||||||
import { error } from "../utils/response";
|
import { error } from "../utils/response";
|
||||||
import { AuthData } from "../types";
|
|
||||||
|
|
||||||
export async function requireAuth(
|
export async function requireAuth(
|
||||||
request: FastifyRequest,
|
request: FastifyRequest,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
import prisma from "../../config/database";
|
import prisma from "../../config/database";
|
||||||
import { requireAuth, requirePermission } from "../../middleware/auth";
|
import { requirePermission } from "../../middleware/auth";
|
||||||
import { logAudit } from "../../services/audit";
|
import { logAudit } from "../../services/audit";
|
||||||
import { success, error, parseId, paginated } from "../../utils/response";
|
import { success, error, parseId, paginated } from "../../utils/response";
|
||||||
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
markOverdueInvoices,
|
markOverdueInvoices,
|
||||||
listInvoices,
|
listInvoices,
|
||||||
getInvoiceListTotals,
|
getInvoiceListTotals,
|
||||||
getNextInvoiceNumberFormatted,
|
|
||||||
getNextInvoiceNumberPreview,
|
getNextInvoiceNumberPreview,
|
||||||
getInvoiceStats,
|
getInvoiceStats,
|
||||||
getOrderDataForInvoice,
|
getOrderDataForInvoice,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ export default async function projectFilesRoutes(
|
|||||||
|
|
||||||
const subPath = parsedQuery.data.path || "";
|
const subPath = parsedQuery.data.path || "";
|
||||||
const rawFileName = file.filename;
|
const rawFileName = file.filename;
|
||||||
const fileName = rawFileName.replace(/[\/\\:*?"<>|]/g, "_");
|
const fileName = rawFileName.replace(/[/\\:*?"<>|]/g, "_");
|
||||||
|
|
||||||
const buffer = await file.toBuffer();
|
const buffer = await file.toBuffer();
|
||||||
const err = await fm.uploadFile(
|
const err = await fm.uploadFile(
|
||||||
|
|||||||
@@ -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 } });
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { FastifyInstance } from "fastify";
|
|||||||
import multipart from "@fastify/multipart";
|
import multipart from "@fastify/multipart";
|
||||||
import prisma from "../../config/database";
|
import prisma from "../../config/database";
|
||||||
import { config } from "../../config/env";
|
import { config } from "../../config/env";
|
||||||
import { requireAuth, requirePermission } from "../../middleware/auth";
|
import { requirePermission } from "../../middleware/auth";
|
||||||
import { logAudit } from "../../services/audit";
|
import { logAudit } from "../../services/audit";
|
||||||
import { success, error, parseId, paginated } from "../../utils/response";
|
import { success, error, parseId, paginated } from "../../utils/response";
|
||||||
import { contentDisposition } from "../../utils/content-disposition";
|
import { contentDisposition } from "../../utils/content-disposition";
|
||||||
@@ -2209,7 +2209,7 @@ export default async function warehouseRoutes(
|
|||||||
// Build items with auto-filled system_qty and calculated difference
|
// Build items with auto-filled system_qty and calculated difference
|
||||||
const itemsData = await Promise.all(
|
const itemsData = await Promise.all(
|
||||||
body.items.map(async (item) => {
|
body.items.map(async (item) => {
|
||||||
let systemQty = 0;
|
let systemQty: number;
|
||||||
|
|
||||||
if (item.location_id != null) {
|
if (item.location_id != null) {
|
||||||
// Get stock at specific location
|
// Get stock at specific location
|
||||||
@@ -2318,7 +2318,7 @@ export default async function warehouseRoutes(
|
|||||||
if (body.items) {
|
if (body.items) {
|
||||||
itemsData = await Promise.all(
|
itemsData = await Promise.all(
|
||||||
body.items.map(async (item) => {
|
body.items.map(async (item) => {
|
||||||
let systemQty = 0;
|
let systemQty: number;
|
||||||
|
|
||||||
if (item.location_id != null) {
|
if (item.location_id != null) {
|
||||||
const loc = await prisma.sklad_item_locations.findUnique({
|
const loc = await prisma.sklad_item_locations.findUnique({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -791,7 +791,7 @@ export async function getProjectReport(year: number) {
|
|||||||
|
|
||||||
// Use detailed project logs (started_at/ended_at or hours/minutes)
|
// Use detailed project logs (started_at/ended_at or hours/minutes)
|
||||||
for (const log of rec.attendance_project_logs) {
|
for (const log of rec.attendance_project_logs) {
|
||||||
let hours = 0;
|
let hours: number;
|
||||||
if (log.hours != null || log.minutes != null) {
|
if (log.hours != null || log.minutes != null) {
|
||||||
hours = (Number(log.hours) || 0) + (Number(log.minutes) || 0) / 60;
|
hours = (Number(log.hours) || 0) + (Number(log.minutes) || 0) / 60;
|
||||||
} else if (log.started_at && log.ended_at) {
|
} else if (log.started_at && log.ended_at) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import jwt from "jsonwebtoken";
|
import jwt from "jsonwebtoken";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { FastifyRequest, FastifyReply } from "fastify";
|
import { FastifyRequest } from "fastify";
|
||||||
import prisma from "../config/database";
|
import prisma from "../config/database";
|
||||||
import { config } from "../config/env";
|
import { config } from "../config/env";
|
||||||
import { AuthData, JwtPayload } from "../types";
|
import { AuthData, JwtPayload } from "../types";
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,12 +115,29 @@ 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;
|
||||||
|
};
|
||||||
|
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 + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
|
||||||
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 {
|
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";
|
||||||
@@ -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 } });
|
const existing = await prisma.quotations.findUnique({ where: { id } });
|
||||||
if (!existing) return { error: "not_found" as const };
|
if (!existing) return { error: "not_found" as const };
|
||||||
|
|
||||||
|
|||||||
@@ -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 } },
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PaginationQuery, PaginationMeta } from "../types";
|
import { PaginationMeta } from "../types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse common pagination/sort/search query params.
|
* Parse common pagination/sort/search query params.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"lib": ["ES2022"],
|
"lib": ["ES2022"],
|
||||||
"types": ["node", "vitest/globals"],
|
"types": ["node", "vitest/globals"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user