Compare commits

..

3 Commits

Author SHA1 Message Date
BOHA
907ee574e6 chore(lint): remove all 34 dead-code warnings (verified site by site)
unused-vars (25): leftover imports/types across pages, routes and services;
write-only sickHours accumulator; unused holidayCount pair; test helpers
authPatch/authDelete; TOut dropped from ApiMutationOptions (the hook keeps
its own generic — no call-site changes); requireAuth no longer importable
in customers/warehouse routes (matches the no-bare-auth-reads convention).

useless-assignment (5): initializers proven overwritten on every path
(systemQty x2, hours, writtenSize -> let x: number) and the seed's dead
adminUser reassignment (create kept, assignment dropped).

useless-escape (4): [eE+\-] -> [eE+-] and [\/...] -> [/...] — identical
semantics.

Behavior-neutral; lint 102 -> 68 warnings (0 errors), suite 641 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 00:13:52 +02:00
BOHA
b51ea2505e feat(db): stock CHECK constraints + audit_logs DATETIME (2038 fix)
Migration 20260612234500: CHECK (quantity >= 0) on sklad_batches and
sklad_item_locations — the DB itself now rejects negative stock, so a
future C2-class bug (cumulative decrements driving a batch negative and
silently vanishing from totals) becomes a loud error instead of hidden
corruption. All three databases verified clean of negative rows before
the migration.

audit_logs.created_at TIMESTAMP(0) -> DATETIME(0): TIMESTAMP dies in 2038
and converts through the session timezone; existing wall times preserved
by the MODIFY.

Pinned by db-constraints.test.ts (4 tests, each watched fail pre-migration:
negative insert/update/location all succeeded before, 2098 audit timestamp
was rejected before).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:56:56 +02:00
BOHA
fd2e613aa5 feat(ui): sidebar icon refresh + unified detail-page headers
Sidebar (user-approved design, docs/superpowers/specs/2026-06-12-sidebar-
icons-design.md): all 31 menu items get unique contextual stroke glyphs in
rounded-square tiles tinted per section (Docházka blue, Kniha jízd teal,
Administrativa amber, Sklad violet, Systém slate; new teal/violet/slate
palette entries with channel tokens in both schemes). Replaces the old set
where 13 items shared a glyph (3x people, 3x sliders, ...) and Faktury was a
dollar sign. Odin stays the only solid + animated tile. Uniqueness pinned by
navdata-icons.test.ts (tsconfig.test.json gains jsx for the .tsx import).

Headers: ProjectDetail and OrderDetail now match the Offer/Invoice shell —
number rendered in secondary color at regular weight next to the bold
entity word, and flexWrap on both left header boxes so the title drops
below Zpět on phones instead of overflowing the viewport (the reported
mobile bug on received orders).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:37:44 +02:00
35 changed files with 665 additions and 426 deletions

View 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
View File

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

View File

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

View File

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

View File

@@ -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")

View File

@@ -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",

View File

@@ -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 {

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

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

View File

@@ -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();

View File

@@ -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")

View File

@@ -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];
@@ -476,14 +475,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 +489,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")

View File

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

View File

@@ -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 {

View File

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

View File

@@ -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 = (

View File

@@ -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,

View File

@@ -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";
@@ -389,7 +382,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 +402,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)}

View File

@@ -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}

View File

@@ -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"],

View File

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

View File

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

View File

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

View File

@@ -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"

View File

@@ -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.icon} {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}
</Box>
)}
</ListItemIcon> </ListItemIcon>
<ListItemText <ListItemText
primary={item.label} primary={item.label}

View File

@@ -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[];
} }
/**
* One stroke glyph per item, every glyph unique within the set (pinned by
* navdata-icons.test.ts). 24-viewBox, stroke 2, round caps — rendered at
* 16px inside SidebarNav's 26px section-hue tile.
*/
function glyph(children: ReactNode) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
{children}
</svg>
);
}
export const menuSections: MenuSection[] = [ export const menuSections: MenuSection[] = [
{ {
label: "Přehled", label: "Přehled",
hue: "primary",
items: [ items: [
{ {
path: "/", path: "/",
label: "Přehled", label: "Přehled",
end: true, end: true,
icon: ( // tachometr — dashboard
<svg icon: glyph(
viewBox="0 0 24 24" <>
fill="none" <circle cx="12" cy="13" r="8.5" />
stroke="currentColor" <path d="M12 13l4-4.5" />
strokeWidth="2" </>,
>
<rect x="3" y="3" width="7" height="7" rx="1" />
<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>
), ),
}, },
{ {
@@ -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>
), ),
}, },
], ],

View File

@@ -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,

View File

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

View File

@@ -13,7 +13,6 @@ import {
markOverdueInvoices, markOverdueInvoices,
listInvoices, listInvoices,
getInvoiceListTotals, getInvoiceListTotals,
getNextInvoiceNumberFormatted,
getNextInvoiceNumberPreview, getNextInvoiceNumberPreview,
getInvoiceStats, getInvoiceStats,
getOrderDataForInvoice, getOrderDataForInvoice,

View File

@@ -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(

View File

@@ -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({

View File

@@ -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) {

View File

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

View File

@@ -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.

View File

@@ -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,