Compare commits

..

4 Commits

Author SHA1 Message Date
BOHA
cd6d3daf43 v1.6.5: fix attendance unique constraint, schema sync, seed script
- Change attendance idx_attendance_user_date from unique to index (allow multiple shifts per day)
- Reset migrations to single baseline init migration
- Add seed script with admin user (admin/admin)
- Update CLAUDE.md with migration workflow documentation
- Various frontend fixes (queries, pages, hooks)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 16:21:34 +02:00
BOHA
1db5060c42 v1.6.4: fix RichEditor auto-scroll when opening offers with multiple sections
Replace Quill editor.format() with direct DOM style manipulation to
avoid getSelection(true) calls that steal focus and scroll the page
to the sections area on mount.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 08:07:18 +02:00
BOHA
4cabba395d v1.6.3: fix project file upload, filter responsible user by permission
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 20:26:32 +02:00
BOHA
59b478f262 v1.6.2: fix RichEditor auto-scroll and PDF offers multi-page header
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 20:23:36 +02:00
81 changed files with 1824 additions and 1256 deletions

View File

@@ -75,10 +75,14 @@ npm test # vitest run (single pass)
npm run test:watch # vitest watch
# Database
npx prisma migrate dev # Apply migrations (dev)
npx prisma migrate deploy # Apply migrations (prod)
npx prisma generate # Regenerate Prisma client after schema changes
npx prisma studio # DB browser GUI
npx prisma migrate dev # Create migration from schema changes + apply to dev
npx prisma migrate dev --name <descriptive_name> # Named migration
npx prisma migrate deploy # Apply pending migrations to production
npx prisma generate # Regenerate Prisma client after schema changes
npx prisma studio # DB browser GUI
npx prisma db seed # (Re)seed the dev database
npx prisma migrate diff --from-url <url> --to-schema-datamodel prisma/schema.prisma --script # Preview SQL before prod deploy
npx prisma migrate resolve --applied <migration> # Mark migration as applied without running SQL
```
**Do not start the dev server.** The user manages it separately.
@@ -248,6 +252,24 @@ When adding new features, add tests in `src/__tests__/`. Name test files `<featu
- Custom hooks: `useApiCall`, `useListData`, `useTableSort`, `useDebounce`, `useModalLock`.
- Styling: CSS files in `src/admin/` — no CSS-in-JS, no Tailwind. Use CSS variables.
### Query Invalidation Convention
Mutations must invalidate the **full domain** of any entity they touch. Prefer broad invalidation:
- `["users"]` over `["users", "list"]`
- `["trips"]` over `["trips", "vehicles"]`
Reason: React Query's `invalidateQueries` uses prefix matching, so `["trips"]` matches all
`["trips", ...]` sub-queries. This means new queries are automatically invalidated without
updating every mutation handler. Inactive queries are only marked stale, not refetched, so
the performance cost is minimal. Optimize to targeted keys only when profiling shows a problem.
When entity A embeds/references entity B, A's mutation handlers invalidate B's domain:
- User CRUD invalidates `["trips"]` and `["attendance"]` (both embed user data)
- Vehicle CRUD invalidates `["trips"]` (trips reference vehicles)
- Invoice CRUD invalidates `["orders"]` (orders reference invoices)
---
## Database Conventions
@@ -260,6 +282,69 @@ When adding new features, add tests in `src/__tests__/`. Name test files `<featu
---
## Database Migrations (Critical Workflow)
**Golden rule: NEVER use `prisma db push`.** It bypasses migration history and causes drift
between the schema and migration tracking. Always use `prisma migrate dev` for every schema change.
### Making schema changes
```
1. Edit prisma/schema.prisma
2. npx prisma migrate dev --name descriptive_name
→ This creates a migration in prisma/migrations/ AND applies it to dev DB
3. npx prisma generate
4. Commit BOTH schema.prisma AND the new migration folder
git add prisma/schema.prisma prisma/migrations/
```
### Verifying before production deploy
```bash
# Preview what SQL will run on production (no changes applied)
npx prisma migrate diff \
--from-url "mysql://user:pass@prod:3306/app" \
--to-schema-datamodel prisma/schema.prisma \
--script
# Empty output = no diff. If it shows SQL, review it before deploying.
```
### Deploying migrations to production
The release process runs `prisma migrate deploy` on production.
This applies only pending migrations — safe, idempotent.
### If migrations get out of sync (drift)
```bash
# Diff production DB against local schema to find drift
npx prisma migrate diff \
--from-url "mysql://prod" \
--to-schema-datamodel prisma/schema.prisma \
--script
# If drift is safe (CREATE only, no DROPs): apply with db push ONCE, then baseline
# If drift includes DROPs: investigate before touching production
```
### Baselinining a database that has no migrations
If production was synced with `db push` and has no `_prisma_migrations` table:
```bash
# 1. Create initial migration locally
npx prisma migrate dev --name init
# 2. Copy to production and mark as applied (no SQL runs)
scp -r prisma/migrations user@prod:/var/www/app-ts/prisma/
ssh user@prod
cd /var/www/app-ts
npx prisma migrate resolve --applied <migration_name>
```
---
## Known Issues & Gotchas
1. **Date.prototype.toJSON override** — global monkey-patch in `src/config/env.ts`. Side-effects on third-party libraries that serialize dates. Do not remove without migrating all date serialization.
@@ -276,7 +361,7 @@ When adding new features, add tests in `src/__tests__/`. Name test files `<featu
7. **NAS_PATH file access** — Project file uploads write to a network share path. In dev, this path may not be mounted. Features using `NAS_PATH` will fail gracefully (or not) if the path is unavailable.
8. **Prisma client regeneration** — After any schema change, run `npx prisma generate`. The generated client is not committed to git.
8. **Prisma client regeneration** — After any schema change and migration, run `npx prisma generate`. The generated client is not committed to git.
9. **No CSRF tokens** — CSRF protection relies on `SameSite=Strict` cookies + CORS. Do not weaken CORS configuration.

View File

@@ -1,6 +1,6 @@
{
"name": "app-ts",
"version": "1.6.1",
"version": "1.6.5",
"description": "",
"main": "dist/server.js",
"scripts": {
@@ -18,7 +18,11 @@
"db:studio": "prisma studio",
"test": "vitest run",
"test:watch": "vitest",
"bones": "boneyard-js build http://localhost:3000"
"bones": "boneyard-js build http://localhost:3000",
"seed": "tsx prisma/seed.ts"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"keywords": [],
"author": "",

View File

@@ -1,2 +0,0 @@
-- Add billing_text column to invoices table
ALTER TABLE `invoices` ADD COLUMN `billing_text` VARCHAR(500) NULL AFTER `issued_by`;

View File

@@ -1,3 +0,0 @@
-- Add lock fields to quotations table for pessimistic locking
ALTER TABLE `quotations` ADD COLUMN `locked_by` INT NULL AFTER `scope_description`;
ALTER TABLE `quotations` ADD COLUMN `locked_at` DATETIME NULL AFTER `locked_by`;

View File

@@ -1,9 +0,0 @@
CREATE TABLE `invoice_alert_log` (
`id` INT NOT NULL AUTO_INCREMENT,
`invoice_type` VARCHAR(20) NOT NULL,
`invoice_id` INT NOT NULL,
`alert_type` VARCHAR(20) NOT NULL,
`sent_at` DATETIME(0) NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `invoice_type_invoice_id_alert_type` (`invoice_type`, `invoice_id`, `alert_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -1 +0,0 @@
ALTER TABLE `invoices` ADD COLUMN `language` VARCHAR(5) DEFAULT 'cs' AFTER `billing_text`;

View File

@@ -1,2 +0,0 @@
-- Add file_path column for NAS storage of received invoice files
ALTER TABLE `received_invoices` ADD COLUMN `file_path` VARCHAR(500) NULL AFTER `file_data`;

View File

@@ -1,4 +0,0 @@
-- Remove file_data (BLOB) and file_path columns — files are now stored on NAS
-- Path is derived from year, month, and file_name
ALTER TABLE `received_invoices` DROP COLUMN `file_data`;
ALTER TABLE `received_invoices` DROP COLUMN `file_path`;

View File

@@ -1 +0,0 @@
ALTER TABLE `company_settings` ADD COLUMN `logo_data_dark` MEDIUMBLOB NULL AFTER `logo_data`;

View File

@@ -1,4 +0,0 @@
ALTER TABLE `company_settings`
ADD COLUMN `offer_number_pattern` VARCHAR(100) NULL,
ADD COLUMN `order_number_pattern` VARCHAR(100) NULL,
ADD COLUMN `invoice_number_pattern` VARCHAR(100) NULL;

View File

@@ -1,3 +0,0 @@
ALTER TABLE `company_settings`
ADD COLUMN `smtp_from` VARCHAR(255) NULL,
ADD COLUMN `smtp_from_name` VARCHAR(255) NULL;

View File

@@ -1,13 +0,0 @@
-- System settings columns on company_settings
ALTER TABLE `company_settings`
ADD COLUMN `break_threshold_hours` DECIMAL(4,2) DEFAULT 6,
ADD COLUMN `break_duration_short` INT DEFAULT 15,
ADD COLUMN `break_duration_long` INT DEFAULT 30,
ADD COLUMN `clock_rounding_minutes` INT DEFAULT 15,
ADD COLUMN `invoice_alert_email` VARCHAR(255) NULL,
ADD COLUMN `leave_notify_email` VARCHAR(255) NULL,
ADD COLUMN `max_login_attempts` INT DEFAULT 5,
ADD COLUMN `lockout_minutes` INT DEFAULT 15,
ADD COLUMN `max_requests_per_minute` INT DEFAULT 300,
ADD COLUMN `available_vat_rates` LONGTEXT NULL,
ADD COLUMN `available_currencies` LONGTEXT NULL;

View File

@@ -1,18 +0,0 @@
-- Create new unified permission
INSERT INTO permissions (name, display_name, description, module)
VALUES ('settings.manage', 'Správa nastavení', 'Správa všech nastavení systému', 'settings')
ON DUPLICATE KEY UPDATE display_name = VALUES(display_name);
-- Grant to all roles that had any of the old 3
INSERT IGNORE INTO role_permissions (role_id, permission_id)
SELECT DISTINCT rp.role_id, (SELECT id FROM permissions WHERE name = 'settings.manage')
FROM role_permissions rp
JOIN permissions p ON p.id = rp.permission_id
WHERE p.name IN ('offers.settings', 'settings.roles', 'settings.security');
-- Clean up old role_permissions
DELETE FROM role_permissions
WHERE permission_id IN (SELECT id FROM permissions WHERE name IN ('offers.settings', 'settings.roles', 'settings.security'));
-- Remove old permissions
DELETE FROM permissions WHERE name IN ('offers.settings', 'settings.roles', 'settings.security');

View File

@@ -1,2 +0,0 @@
-- Add unique constraint on number_sequences(type, year) for atomic numbering
ALTER TABLE number_sequences ADD UNIQUE INDEX idx_number_sequences_type_year (`type`, `year`);

View File

@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE `users` ADD COLUMN `totp_last_used_counter` INTEGER NULL;

View File

@@ -98,6 +98,7 @@ CREATE TABLE `company_settings` (
`vat_id` VARCHAR(50) NULL,
`custom_fields` LONGTEXT NULL,
`logo_data` LONGBLOB NULL,
`logo_data_dark` MEDIUMBLOB NULL,
`quotation_prefix` VARCHAR(20) NULL,
`default_currency` VARCHAR(10) NULL DEFAULT 'CZK',
`default_vat_rate` DECIMAL(5, 2) NULL DEFAULT 21.00,
@@ -108,7 +109,24 @@ CREATE TABLE `company_settings` (
`order_type_code` VARCHAR(10) NULL,
`invoice_type_code` VARCHAR(10) NULL,
`require_2fa` BOOLEAN NOT NULL DEFAULT false,
`break_threshold_hours` DECIMAL(4, 2) NULL DEFAULT 6.00,
`break_duration_short` INTEGER NULL DEFAULT 15,
`break_duration_long` INTEGER NULL DEFAULT 30,
`clock_rounding_minutes` INTEGER NULL DEFAULT 15,
`invoice_alert_email` VARCHAR(255) NULL,
`leave_notify_email` VARCHAR(255) NULL,
`max_login_attempts` INTEGER NULL DEFAULT 5,
`lockout_minutes` INTEGER NULL DEFAULT 15,
`max_requests_per_minute` INTEGER NULL DEFAULT 300,
`available_vat_rates` LONGTEXT NULL,
`available_currencies` LONGTEXT NULL,
`smtp_from` VARCHAR(255) NULL,
`smtp_from_name` VARCHAR(255) NULL,
`offer_number_pattern` VARCHAR(100) NULL,
`order_number_pattern` VARCHAR(100) NULL,
`invoice_number_pattern` VARCHAR(100) NULL,
UNIQUE INDEX `company_settings_uuid_key`(`uuid`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -167,14 +185,18 @@ CREATE TABLE `invoices` (
`tax_date` DATE NULL,
`paid_date` DATE NULL,
`issued_by` VARCHAR(255) NULL,
`billing_text` VARCHAR(500) NULL,
`language` VARCHAR(5) NULL DEFAULT 'cs',
`notes` TEXT NULL,
`internal_notes` TEXT NULL,
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
UNIQUE INDEX `idx_invoices_number_unique`(`invoice_number`),
INDEX `customer_id`(`customer_id`),
INDEX `idx_invoices_due_date`(`due_date`),
INDEX `idx_invoices_status_issue`(`status`, `issue_date`),
INDEX `idx_invoices_status_due`(`status`, `due_date`),
INDEX `order_id`(`order_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -239,6 +261,7 @@ CREATE TABLE `number_sequences` (
`year` INTEGER NULL,
`last_number` INTEGER NULL DEFAULT 0,
UNIQUE INDEX `idx_number_sequences_type_year`(`type`, `year`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -294,6 +317,7 @@ CREATE TABLE `orders` (
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
UNIQUE INDEX `idx_orders_number_unique`(`order_number`),
INDEX `customer_id`(`customer_id`),
INDEX `quotation_id`(`quotation_id`),
PRIMARY KEY (`id`)
@@ -341,6 +365,7 @@ CREATE TABLE `projects` (
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
UNIQUE INDEX `projects_project_number_key`(`project_number`),
INDEX `customer_id`(`customer_id`),
INDEX `fk_projects_responsible_user`(`responsible_user_id`),
INDEX `order_id`(`order_id`),
@@ -386,10 +411,13 @@ CREATE TABLE `quotations` (
`status` VARCHAR(20) NOT NULL DEFAULT 'active',
`scope_title` VARCHAR(500) NULL,
`scope_description` TEXT NULL,
`locked_by` INTEGER NULL,
`locked_at` DATETIME(0) NULL,
`uuid` VARCHAR(36) NULL,
`modified_at` DATETIME(0) NULL,
`sync_version` INTEGER NULL DEFAULT 0,
UNIQUE INDEX `quotations_quotation_number_key`(`quotation_number`),
INDEX `customer_id`(`customer_id`),
INDEX `idx_quotations_number`(`quotation_number`),
PRIMARY KEY (`id`)
@@ -411,7 +439,6 @@ CREATE TABLE `received_invoices` (
`due_date` DATE NULL,
`paid_date` DATE NULL,
`status` ENUM('unpaid', 'paid') NOT NULL DEFAULT 'unpaid',
`file_data` MEDIUMBLOB NULL,
`file_name` VARCHAR(255) NULL,
`file_mime` VARCHAR(100) NULL,
`file_size` INTEGER UNSIGNED NULL,
@@ -572,6 +599,7 @@ CREATE TABLE `users` (
`totp_secret` VARCHAR(255) NULL,
`totp_enabled` BOOLEAN NOT NULL DEFAULT false,
`totp_backup_codes` TEXT NULL,
`totp_last_used_counter` INTEGER NULL,
UNIQUE INDEX `username`(`username`),
UNIQUE INDEX `email`(`email`),
@@ -597,12 +625,28 @@ CREATE TABLE `vehicles` (
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `invoice_alert_log` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`invoice_type` VARCHAR(20) NOT NULL,
`invoice_id` INTEGER NOT NULL,
`alert_type` VARCHAR(20) NOT NULL,
`sent_at` DATETIME(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
`created_at` DATETIME(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
UNIQUE INDEX `invoice_type_invoice_id_alert_type`(`invoice_type`, `invoice_id`, `alert_type`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `attendance` ADD CONSTRAINT `attendance_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `attendance_project_logs` ADD CONSTRAINT `attendance_project_logs_attendance_id_fkey` FOREIGN KEY (`attendance_id`) REFERENCES `attendance`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `attendance_project_logs` ADD CONSTRAINT `attendance_project_logs_project_id_fkey` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `invoice_items` ADD CONSTRAINT `invoice_items_ibfk_1` FOREIGN KEY (`invoice_id`) REFERENCES `invoices`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
@@ -674,4 +718,3 @@ ALTER TABLE `trips` ADD CONSTRAINT `trips_vehicle_fk` FOREIGN KEY (`vehicle_id`)
-- AddForeignKey
ALTER TABLE `users` ADD CONSTRAINT `users_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE SET NULL ON UPDATE NO ACTION;

View File

@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
# It should be added in your version-control system (e.g., Git)
provider = "mysql"

View File

@@ -32,7 +32,7 @@ model attendance {
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "attendance_ibfk_1")
attendance_project_logs attendance_project_logs[]
@@unique([user_id, shift_date], map: "idx_attendance_user_date")
@@index([user_id, shift_date], map: "idx_attendance_user_date")
@@index([user_id, departure_time], map: "idx_attendance_user_departure")
@@index([project_id], map: "idx_project_id")
}
@@ -101,7 +101,7 @@ model company_settings {
vat_id String? @db.VarChar(50)
custom_fields String? @db.LongText
logo_data Bytes?
logo_data_dark Bytes?
logo_data_dark Bytes? @db.MediumBlob
quotation_prefix String? @db.VarChar(20)
default_currency String? @default("CZK") @db.VarChar(10)
default_vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
@@ -112,7 +112,7 @@ model company_settings {
order_type_code String? @db.VarChar(10)
invoice_type_code String? @db.VarChar(10)
require_2fa Boolean @default(false)
break_threshold_hours Decimal? @default(6) @db.Decimal(4, 2)
break_threshold_hours Decimal? @default(6.00) @db.Decimal(4, 2)
break_duration_short Int? @default(15)
break_duration_long Int? @default(30)
clock_rounding_minutes Int? @default(15)
@@ -389,7 +389,7 @@ model quotation_items {
model quotations {
id Int @id @default(autoincrement())
quotation_number String? @unique @db.VarChar(50)
project_code String? @db.VarChar(50)
project_code String? @db.VarChar(100)
customer_id Int?
created_at DateTime? @default(now()) @db.DateTime(0)
valid_until DateTime? @db.Date
@@ -437,7 +437,7 @@ model received_invoices {
file_mime String? @db.VarChar(100)
file_size Int? @db.UnsignedInt
notes String? @db.Text
uploaded_by Int?
uploaded_by Int? @db.UnsignedInt
created_at DateTime @default(now()) @db.DateTime(0)
modified_at DateTime @default(now()) @db.DateTime(0)
@@ -449,7 +449,7 @@ model received_invoices {
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model refresh_tokens {
id Int @id @default(autoincrement()) @db.UnsignedInt
user_id Int
user_id Int @db.UnsignedInt
token_hash String @unique(map: "token_hash") @db.VarChar(64)
expires_at DateTime @db.DateTime(0)
replaced_at DateTime? @db.DateTime(0)
@@ -632,7 +632,7 @@ model invoice_alert_log {
sent_at DateTime @default(now()) @db.DateTime(0)
created_at DateTime @default(now()) @db.DateTime(0)
@@unique([invoice_type, invoice_id, alert_type])
@@unique([invoice_type, invoice_id, alert_type], map: "invoice_type_invoice_id_alert_type")
}
enum received_invoices_status {

430
prisma/seed.ts Normal file
View File

@@ -0,0 +1,430 @@
import { PrismaClient } from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
const PERMISSIONS: {
name: string;
display_name: string;
module: string;
description: string;
}[] = [
// Docházka
{
name: "attendance.record",
display_name: "Záznam docházky",
module: "attendance",
description: "Zapisovat příchody/odchody",
},
{
name: "attendance.history",
display_name: "Historie docházky",
module: "attendance",
description: "Prohlížet vlastní historii docházky",
},
{
name: "attendance.approve",
display_name: "Schvalování docházky",
module: "attendance",
description: "Schvalovat žádosti o dovolenou/nemoc",
},
{
name: "attendance.manage",
display_name: "Správa docházky",
module: "attendance",
description: "Spravovat všechny záznamy, vytvářet/editovat/mazat",
},
{
name: "attendance.balances",
display_name: "Správa bilancí",
module: "attendance",
description: "Spravovat zůstatky dovolené a sick days",
},
// Kniha jízd
{
name: "trips.record",
display_name: "Záznam jízd",
module: "trips",
description: "Zapisovat jízdy",
},
{
name: "trips.history",
display_name: "Historie jízd",
module: "trips",
description: "Prohlížet vlastní historii jízd",
},
{
name: "trips.manage",
display_name: "Správa jízd",
module: "trips",
description: "Spravovat všechny jízdy",
},
// Vozidla
{
name: "vehicles.manage",
display_name: "Správa vozidel",
module: "vehicles",
description: "Spravovat vozový park",
},
// Nabídky
{
name: "offers.view",
display_name: "Zobrazit nabídky",
module: "offers",
description: "Prohlížet seznam nabídek",
},
{
name: "offers.create",
display_name: "Vytvořit nabídku",
module: "offers",
description: "Vytvářet nové nabídky",
},
{
name: "offers.edit",
display_name: "Upravit nabídku",
module: "offers",
description: "Upravovat existující nabídky",
},
{
name: "offers.delete",
display_name: "Smazat nabídku",
module: "offers",
description: "Mazat nabídky",
},
{
name: "offers.export",
display_name: "Exportovat nabídku",
module: "offers",
description: "Exportovat nabídky do PDF",
},
// Objednávky
{
name: "orders.view",
display_name: "Zobrazit objednávky",
module: "orders",
description: "Prohlížet seznam objednávek",
},
{
name: "orders.create",
display_name: "Vytvořit objednávku",
module: "orders",
description: "Vytvářet nové objednávky",
},
{
name: "orders.edit",
display_name: "Upravit objednávku",
module: "orders",
description: "Upravovat existující objednávky",
},
{
name: "orders.delete",
display_name: "Smazat objednávku",
module: "orders",
description: "Mazat objednávky",
},
{
name: "orders.export",
display_name: "Exportovat objednávku",
module: "orders",
description: "Exportovat objednávky do PDF",
},
// Faktury
{
name: "invoices.view",
display_name: "Zobrazit faktury",
module: "invoices",
description: "Prohlížet seznam faktur",
},
{
name: "invoices.create",
display_name: "Vytvořit fakturu",
module: "invoices",
description: "Vytvářet nové faktury",
},
{
name: "invoices.edit",
display_name: "Upravit fakturu",
module: "invoices",
description: "Upravovat existující faktury",
},
{
name: "invoices.delete",
display_name: "Smazat fakturu",
module: "invoices",
description: "Mazat faktury",
},
{
name: "invoices.export",
display_name: "Exportovat fakturu",
module: "invoices",
description: "Exportovat faktury do PDF",
},
// Projekty
{
name: "projects.view",
display_name: "Zobrazit projekty",
module: "projects",
description: "Prohlížet seznam projektů",
},
{
name: "projects.edit",
display_name: "Upravit projekt",
module: "projects",
description: "Upravovat existující projekty",
},
{
name: "projects.delete",
display_name: "Smazat projekt",
module: "projects",
description: "Mazat projekty",
},
{
name: "projects.files",
display_name: "Soubory projektu",
module: "projects",
description: "Spravovat soubory a složky projektu",
},
// Zákazníci
{
name: "customers.view",
display_name: "Zobrazit zákazníky",
module: "customers",
description: "Prohlížet seznam zákazníků",
},
{
name: "customers.create",
display_name: "Vytvořit zákazníka",
module: "customers",
description: "Vytvářet nové zákazníky",
},
{
name: "customers.edit",
display_name: "Upravit zákazníka",
module: "customers",
description: "Upravovat existující zákazníky",
},
{
name: "customers.delete",
display_name: "Smazat zákazníka",
module: "customers",
description: "Mazat zákazníky",
},
// Uživatelé
{
name: "users.view",
display_name: "Zobrazit uživatele",
module: "users",
description: "Prohlížet seznam uživatelů",
},
{
name: "users.create",
display_name: "Vytvořit uživatele",
module: "users",
description: "Vytvářet nové uživatele",
},
{
name: "users.edit",
display_name: "Upravit uživatele",
module: "users",
description: "Upravovat existující uživatele",
},
{
name: "users.delete",
display_name: "Smazat uživatele",
module: "users",
description: "Mazat uživatele",
},
// Nastavení
{
name: "settings.company",
display_name: "Nastavení společnosti",
module: "settings",
description: "Upravovat údaje o společnosti, logo, 2FA",
},
{
name: "settings.banking",
display_name: "Bankovní účty",
module: "settings",
description: "Spravovat bankovní účty společnosti",
},
{
name: "settings.roles",
display_name: "Role a oprávnění",
module: "settings",
description: "Spravovat role a přiřazovat oprávnění",
},
{
name: "settings.templates",
display_name: "Šablony",
module: "settings",
description: "Spravovat šablony nabídek a položek",
},
{
name: "settings.audit",
display_name: "Audit log",
module: "settings",
description: "Prohlížet auditní logy",
},
];
async function main() {
console.log("Seeding permissions...");
// 1. Clear existing permissions and re-insert current set
await prisma.role_permissions.deleteMany({});
await prisma.permissions.deleteMany({});
console.log(" Cleared old permissions");
for (const perm of PERMISSIONS) {
await prisma.permissions.create({ data: perm });
}
console.log(` Inserted ${PERMISSIONS.length} permissions`);
// 2. Ensure admin role exists
let adminRole = await prisma.roles.findFirst({ where: { name: "admin" } });
if (!adminRole) {
adminRole = await prisma.roles.create({
data: {
name: "admin",
display_name: "Administrátor",
description: "Hlavní administrátor s plným přístupem",
},
});
console.log(" Created admin role");
} else {
console.log(" Admin role already exists (id=" + adminRole.id + ")");
}
// 3. Assign all permissions to admin role
const allPerms = await prisma.permissions.findMany();
const existingAssignments = await prisma.role_permissions.findMany({
where: { role_id: adminRole.id },
});
// Delete stale assignments (permissions no longer in the list)
const validIds = new Set(allPerms.map((p) => p.id));
const staleIds = existingAssignments.filter(
(a) => !validIds.has(a.permission_id),
);
if (staleIds.length > 0) {
for (const s of staleIds) {
await prisma.role_permissions.delete({
where: {
role_id_permission_id: {
role_id: adminRole.id,
permission_id: s.permission_id,
},
},
});
}
console.log(` Removed ${staleIds.length} stale role_permissions`);
}
// Add missing assignments
const existingIds = new Set(existingAssignments.map((a) => a.permission_id));
let added = 0;
for (const perm of allPerms) {
if (!existingIds.has(perm.id)) {
await prisma.role_permissions.create({
data: { role_id: adminRole.id, permission_id: perm.id },
});
added++;
}
}
if (added > 0) console.log(` Added ${added} role_permission assignments`);
console.log(` Admin role now has all ${allPerms.length} permissions`);
// 4. Ensure admin user exists
let adminUser = await prisma.users.findFirst({
where: { username: "admin" },
});
if (!adminUser) {
const hash = bcrypt.hashSync("admin", 10);
adminUser = await prisma.users.create({
data: {
username: "admin",
email: "admin@boha.local",
password_hash: hash,
first_name: "Admin",
last_name: "BOHA",
role_id: adminRole.id,
is_active: true,
},
});
console.log(" Created admin user (admin / admin)");
} else if (adminUser.role_id !== adminRole.id) {
await prisma.users.update({
where: { id: adminUser.id },
data: { role_id: adminRole.id },
});
console.log(" Updated admin user role_id to admin role");
} else {
console.log(" Admin user already exists with correct role");
}
// 5. Create default company_settings if not exists
const existingSettings = await prisma.company_settings.findFirst();
if (!existingSettings) {
await prisma.company_settings.create({
data: {
company_name: "BOHA s.r.o.",
default_currency: "CZK",
default_vat_rate: 21.0,
available_vat_rates: JSON.stringify([0, 10, 15, 21]),
available_currencies: JSON.stringify(["CZK", "EUR"]),
offer_number_pattern: "NAB-{YYYY}-{NNN}",
order_number_pattern: "OBJ-{YYYY}-{NNN}",
invoice_number_pattern: "FV-{YYYY}-{NNN}",
break_threshold_hours: 6.0,
break_duration_short: 15,
break_duration_long: 30,
clock_rounding_minutes: 15,
require_2fa: false,
max_login_attempts: 5,
lockout_minutes: 15,
max_requests_per_minute: 300,
},
});
console.log(" Created default company_settings");
} else {
console.log(" company_settings already exists");
}
// 6. Initialize number_sequences for current year
const year = new Date().getFullYear();
const types = ["quotation", "order", "invoice"];
for (const type of types) {
const existing = await prisma.number_sequences.findFirst({
where: { type, year },
});
if (!existing) {
await prisma.number_sequences.create({
data: { type, year, last_number: 0 },
});
console.log(` Created number_sequence: ${type}/${year}`);
}
}
console.log(" number_sequences ready");
console.log(
"\nSeed complete — " +
PERMISSIONS.length +
" permissions, 1 admin role, company_settings + number_sequences ready.",
);
}
main()
.catch((e) => {
console.error("Seed failed:", e);
process.exit(1);
})
.finally(() => prisma.$disconnect());

View File

@@ -1,4 +1,4 @@
import { useMemo, useRef, useCallback, useEffect } from "react";
import { useMemo, useRef, useCallback, useLayoutEffect } from "react";
import ReactQuill from "react-quill-new";
import "react-quill-new/dist/quill.snow.css";
@@ -96,11 +96,11 @@ export default function RichEditor({
[onChange],
);
useEffect(() => {
useLayoutEffect(() => {
if (!quillRef.current) return;
const editor = quillRef.current.getEditor();
editor.format("font", "tahoma");
editor.format("size", "14px");
editor.root.style.fontFamily = "Tahoma, sans-serif";
editor.root.style.fontSize = "14px";
}, []);
return (

View File

@@ -116,7 +116,7 @@ const menuSections: MenuSection[] = [
{
path: "/attendance/admin",
label: "Správa",
permission: "attendance.admin",
permission: "attendance.manage",
matchPrefix: "/attendance/admin",
matchAlso: ["/attendance/create", "/attendance/location"],
icon: (
@@ -198,7 +198,7 @@ const menuSections: MenuSection[] = [
{
path: "/trips/admin",
label: "Správa",
permission: "trips.admin",
permission: "trips.manage",
icon: (
<svg
viewBox="0 0 24 24"
@@ -221,7 +221,7 @@ const menuSections: MenuSection[] = [
{
path: "/vehicles",
label: "Vozidla",
permission: "trips.vehicles",
permission: "vehicles.manage",
icon: (
<svg
viewBox="0 0 24 24"
@@ -315,7 +315,7 @@ const menuSections: MenuSection[] = [
{
path: "/offers/customers",
label: "Zákazníci",
permission: "offers.view",
permission: "customers.view",
icon: (
<svg
viewBox="0 0 24 24"
@@ -356,7 +356,12 @@ const menuSections: MenuSection[] = [
{
path: "/settings",
label: "Nastavení",
permission: "settings.manage",
permission: [
"settings.company",
"settings.banking",
"settings.roles",
"settings.templates",
],
icon: (
<svg
viewBox="0 0 24 24"

View File

@@ -89,6 +89,21 @@ export default function DashProfile({
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
const dataToSave = { ...formData };
if (dataToSave.new_password && !dataToSave.current_password) {
alert.error("Pro změnu hesla zadejte aktuální heslo");
return;
}
if (dataToSave.current_password && !dataToSave.new_password) {
alert.error("Pro změnu hesla zadejte nové heslo");
return;
}
// Strip empty password fields so Zod doesn't reject ""
if (!dataToSave.current_password)
delete (dataToSave as any).current_password;
if (!dataToSave.new_password) delete (dataToSave as any).new_password;
try {
const response = await apiFetch(`${API_BASE}/profile`, {
method: "PUT",
@@ -329,9 +344,24 @@ export default function DashProfile({
className="admin-form-input"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Aktuální heslo</label>
<input
type="password"
value={formData.current_password}
onChange={(e) =>
setFormData({
...formData,
current_password: e.target.value,
})
}
className="admin-form-input"
placeholder="Pro změnu hesla, zadejte aktuální heslo"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
Nové heslo (ponechte prázdné pro zachování stávajícího)
Nové heslo
</label>
<input
type="password"
@@ -343,27 +373,9 @@ export default function DashProfile({
})
}
className="admin-form-input"
placeholder="Pro změnu hesla, zadejte nové heslo"
/>
</div>
{formData.new_password && (
<div className="admin-form-group">
<label className="admin-form-label required">
Aktuální heslo
</label>
<input
type="password"
value={formData.current_password}
onChange={(e) =>
setFormData({
...formData,
current_password: e.target.value,
})
}
className="admin-form-input"
placeholder="Zadejte aktuální heslo pro potvrzení"
/>
</div>
)}
</div>
</div>
<div className="admin-modal-footer">

View File

@@ -1,49 +0,0 @@
import { useCallback, useRef } from "react";
import { useAlert } from "../context/AlertContext";
import apiFetch from "../utils/api";
interface ApiCallResult<T> {
data: T | null;
ok: boolean;
response: Response | null;
}
export default function useApiCall() {
const alert = useAlert();
const abortRef = useRef<AbortController | null>(null);
const call = useCallback(
async <T = unknown>(
url: string,
options: RequestInit = {},
errorMsg = "Chyba při načítání dat",
): Promise<ApiCallResult<T>> => {
if (abortRef.current) abortRef.current.abort();
const controller = new AbortController();
abortRef.current = controller;
try {
const { signal: _, ...restOptions } = options;
const response = await apiFetch(url, {
...restOptions,
signal: controller.signal,
});
const data = await response.json();
if (!response.ok || !data.success) {
alert.error(data.error || errorMsg);
return { data: null, ok: false, response };
}
return { data: data.data as T, ok: true, response };
} catch (err: unknown) {
if (err instanceof Error && err.name === "AbortError") {
return { data: null, ok: false, response: null };
}
alert.error(errorMsg);
return { data: null, ok: false, response: null };
}
},
[alert],
);
return { call };
}

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import apiFetch from "../utils/api";
import {
calcProjectMinutesTotal,
@@ -461,6 +462,7 @@ function buildPrintHtml(
// ---------------------------------------------------------------------------
export default function useAttendanceAdmin({ alert }: AlertContext) {
const queryClient = useQueryClient();
// ---- Core state ----
const [loading, setLoading] = useState(true);
const [month, setMonth] = useState(() => {
@@ -771,6 +773,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
if (result.success) {
setShowCreateModal(false);
queryClient.invalidateQueries({ queryKey: ["attendance"] });
await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
@@ -842,6 +845,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
if (result.success) {
setShowBulkModal(false);
queryClient.invalidateQueries({ queryKey: ["attendance"] });
await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
@@ -976,6 +980,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
if (result.success) {
setShowEditModal(false);
queryClient.invalidateQueries({ queryKey: ["attendance"] });
await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
@@ -1005,6 +1010,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
if (result.success) {
setDeleteConfirm({ show: false, record: null });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
await fetchData(false);
alert.success(
result.message || result.data?.message || "Záznam smazán",

View File

@@ -1,139 +0,0 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useAlert } from "../context/AlertContext";
import apiFetch from "../utils/api";
import useDebounce from "./useDebounce";
const API_BASE = "/api/admin";
interface PaginationData {
total: number;
page: number;
per_page: number;
total_pages: number;
}
interface UseListDataOptions {
dataKey?: string;
search?: string;
sort?: string;
order?: string;
page?: number;
perPage?: number;
extraParams?: Record<string, string>;
errorMsg?: string;
}
export default function useListData<T = unknown>(
endpoint: string,
options: UseListDataOptions = {},
) {
const {
dataKey,
search = "",
sort,
order,
page = 1,
perPage = 25,
extraParams = {},
errorMsg = "Nepodařilo se načíst data",
} = options;
const alert = useAlert();
const [items, setItems] = useState<T[]>([]);
const [loading, setLoading] = useState(true);
const [initialLoad, setInitialLoad] = useState(true);
const [pagination, setPagination] = useState<PaginationData | null>(null);
const abortRef = useRef<AbortController | null>(null);
const mountedRef = useRef(true);
const debouncedSearch = useDebounce(search, 300);
const extraParamsKey = Object.entries(extraParams)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([k, v]) => `${k}=${v}`)
.join("&");
const fetchData = useCallback(async () => {
if (abortRef.current) abortRef.current.abort();
const controller = new AbortController();
abortRef.current = controller;
try {
const params = new URLSearchParams({
page: String(page),
per_page: String(perPage),
});
if (debouncedSearch) params.set("search", debouncedSearch);
if (sort) params.set("sort", sort);
if (order) params.set("order", order);
Object.entries(extraParams).forEach(([k, v]) => {
if (v) params.set(k, v);
});
const url = endpoint.startsWith("/")
? `${endpoint}?${params}`
: `${API_BASE}/${endpoint}?${params}`;
const response = await apiFetch(url, { signal: controller.signal });
if (response.status === 401) {
window.location.href = "/login";
return;
}
const result = await response.json();
if (result.success) {
const data = dataKey
? result.data[dataKey]
: Array.isArray(result.data)
? result.data
: result.data?.items || [];
setItems(data || []);
const pag =
result.pagination ||
(!Array.isArray(result.data) && result.data?.pagination) ||
null;
setPagination(
pag || {
total: data?.length ?? 0,
page,
per_page: perPage,
total_pages: 1,
},
);
} else {
alert.error(result.error || errorMsg);
}
} catch (err: unknown) {
if (err instanceof Error && err.name === "AbortError") return;
if (!mountedRef.current) return;
alert.error(errorMsg);
} finally {
if (!mountedRef.current) return;
setLoading(false);
setInitialLoad(false);
}
}, [
endpoint,
debouncedSearch,
sort,
order,
page,
perPage,
dataKey,
extraParamsKey,
]);
useEffect(() => {
mountedRef.current = true;
fetchData();
return () => {
mountedRef.current = false;
if (abortRef.current) abortRef.current.abort();
};
}, [fetchData]);
return {
items,
setItems,
loading,
initialLoad,
pagination,
refetch: fetchData,
};
}

View File

@@ -33,6 +33,94 @@ export interface LocationRecord {
departure_address?: string | null;
}
export interface ProjectLogEntry {
id?: number;
project_id?: number;
project_name?: string;
started_at?: string;
ended_at?: string | null;
hours?: string | number | null;
minutes?: string | number | null;
}
export interface AttendanceRecord {
id: number;
shift_date: string;
leave_type?: string;
leave_hours?: number;
arrival_time?: string | null;
departure_time?: string | null;
break_start?: string | null;
break_end?: string | null;
notes?: string;
project_name?: string;
project_logs?: ProjectLogEntry[];
}
export interface BalanceEntry {
name: string;
vacation_total: number;
vacation_used: number;
vacation_remaining: number;
sick_used: number;
}
export interface UserShort {
id: number | string;
name: string;
}
export interface BalancesData {
users: UserShort[];
balances: Record<string, BalanceEntry>;
}
export interface FundUserData {
name: string;
worked: number;
covered: number;
overtime: number;
missing: number;
}
export interface MonthFundData {
month_name: string;
fund: number;
fund_to_date: number;
business_days: number;
users?: Record<string, FundUserData>;
}
export interface FundData {
months: Record<string, MonthFundData>;
holidays: unknown[];
users: UserShort[];
balances: Record<string, unknown>;
}
export interface ProjectUser {
user_id: number;
user_name: string;
hours: number;
}
export interface ProjectEntry {
project_id: number | null;
project_number?: string;
project_name?: string;
hours: number;
users: ProjectUser[];
}
export interface MonthProjectData {
month_name: string;
projects: ProjectEntry[];
}
export interface ProjectData {
months: Record<string, MonthProjectData>;
}
export const attendanceHistoryOptions = (filters: {
month?: string;
userId?: number;
@@ -44,7 +132,7 @@ export const attendanceHistoryOptions = (filters: {
params.set("limit", "1000");
if (filters.month) params.set("month", filters.month);
if (filters.userId) params.set("user_id", String(filters.userId));
return jsonQuery<Record<string, unknown>[]>(
return jsonQuery<AttendanceRecord[]>(
`/api/admin/attendance?${params.toString()}`,
);
},
@@ -54,7 +142,7 @@ export const attendanceBalancesOptions = (year: number) =>
queryOptions({
queryKey: ["attendance", "balances", year],
queryFn: () =>
jsonQuery<Record<string, unknown>>(
jsonQuery<BalancesData>(
`/api/admin/attendance?action=balances&year=${year}`,
),
});
@@ -63,16 +151,14 @@ export const attendanceWorkFundOptions = (year: number) =>
queryOptions({
queryKey: ["attendance", "workfund", year],
queryFn: () =>
jsonQuery<Record<string, unknown>>(
`/api/admin/attendance?action=workfund&year=${year}`,
),
jsonQuery<FundData>(`/api/admin/attendance?action=workfund&year=${year}`),
});
export const attendanceProjectReportOptions = (year: number) =>
queryOptions({
queryKey: ["attendance", "project-report", year],
queryFn: () =>
jsonQuery<Record<string, unknown>>(
jsonQuery<ProjectData>(
`/api/admin/attendance?action=project_report&year=${year}`,
),
});

View File

@@ -1,11 +1,21 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery } from "../apiAdapter";
export interface BankAccount {
id: number;
account_name: string;
bank_name: string;
account_number: string;
iban: string;
bic: string;
currency: string;
is_default: boolean;
}
export const bankAccountsOptions = () =>
queryOptions({
queryKey: ["bank-accounts"],
queryFn: () =>
jsonQuery<Record<string, unknown>[]>("/api/admin/bank-accounts"),
queryFn: () => jsonQuery<BankAccount[]>("/api/admin/bank-accounts"),
staleTime: 2 * 60_000,
});

View File

@@ -31,6 +31,89 @@ export interface InvoiceStats {
vat_month_czk: number;
}
export interface InvoiceItem {
id?: number;
description: string;
item_description?: string;
quantity: number;
unit: string;
unit_price: number;
is_included_in_total: boolean;
position?: number;
}
export interface InvoiceDetail {
id: number;
invoice_number: string;
customer_id: number | null;
customer_name: string;
customer?: {
company_id?: string;
vat_id?: string;
};
status: string;
issue_date: string;
due_date: string;
taxable_date?: string;
tax_date?: string;
currency: string;
language: string;
vat_rate: number;
apply_vat: boolean;
exchange_rate: string;
notes?: string;
payment_method?: string;
variable_symbol?: string;
constant_symbol?: string;
issued_by?: string | null;
paid_date?: string;
billing_text?: string;
bank_name?: string;
bank_swift?: string;
bank_iban?: string;
bank_account?: string;
bank_account_id?: number | null;
items?: InvoiceItem[];
subtotal: number;
vat_amount: number;
total: number;
order?: {
id: number;
order_number: string;
status?: string;
} | null;
order_id?: number;
order_number?: string;
has_pdf?: boolean;
valid_transitions?: string[];
}
export interface ReceivedInvoice {
id: number;
supplier_name: string;
invoice_number: string;
amount: number;
currency: string;
vat_rate: number;
issue_date: string;
due_date: string;
notes: string;
status: string;
file_name?: string;
created_at: string;
}
export interface ReceivedStats {
total_month: CurrencyAmount[];
total_month_czk: number | null;
vat_month: CurrencyAmount[];
vat_month_czk: number | null;
unpaid: CurrencyAmount[];
unpaid_czk: number | null;
unpaid_count: number;
month_count: number;
}
export const invoiceListOptions = (filters: {
search?: string;
sort?: string;
@@ -93,7 +176,7 @@ export const receivedInvoiceListOptions = (filters: {
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
const qs = params.toString();
return paginatedJsonQuery(
return paginatedJsonQuery<ReceivedInvoice>(
`/api/admin/received-invoices${qs ? `?${qs}` : ""}`,
);
},
@@ -112,7 +195,7 @@ export const receivedInvoiceStatsOptions = (month: number, year: number) =>
queryOptions({
queryKey: ["invoices", "received", "stats", month, year],
queryFn: () =>
jsonQuery<Record<string, unknown>>(
jsonQuery<ReceivedStats>(
`/api/admin/received-invoices/stats?month=${month}&year=${year}`,
),
});
@@ -120,7 +203,6 @@ export const receivedInvoiceStatsOptions = (month: number, year: number) =>
export const invoiceDetailOptions = (id: string | undefined) =>
queryOptions({
queryKey: ["invoices", id],
queryFn: () =>
jsonQuery<Record<string, unknown>>(`/api/admin/invoices/${id}`),
queryFn: () => jsonQuery<InvoiceDetail>(`/api/admin/invoices/${id}`),
enabled: !!id,
});

View File

@@ -1,11 +1,24 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery } from "../apiAdapter";
export interface LeaveRequest {
id: number;
leave_type: string;
date_from: string;
date_to: string;
total_days: number;
total_hours: number;
status: string;
notes?: string;
reviewer_note?: string;
created_at: string;
}
export const leaveRequestsOptions = (mine = true) =>
queryOptions({
queryKey: ["leave-requests", mine ? "mine" : "all"],
queryFn: () =>
jsonQuery<Record<string, unknown>>(
jsonQuery<LeaveRequest[]>(
`/api/admin/leave-requests${mine ? "?mine=1" : ""}`,
),
});

View File

@@ -1,22 +1,67 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
export interface ItemTemplate {
id: number;
name: string;
description: string;
default_price: number;
category: string;
}
export interface ScopeSection {
_key: string;
title: string;
title_cz: string;
content: string;
}
export interface ScopeTemplate {
id: number;
name: string;
description?: string;
scope_template_sections?: ScopeSection[];
}
export interface CustomField {
name: string;
value: string;
showLabel: boolean;
_key?: string;
}
export interface Customer {
id: number;
name: string;
street?: string;
city?: string;
postal_code?: string;
country?: string;
company_id?: string;
vat_id?: string;
quotation_count: number;
custom_fields?: CustomField[];
customer_field_order?: string[];
}
export const offerCustomersOptions = () =>
queryOptions({
queryKey: ["offer-customers"],
queryFn: () => jsonQuery<Record<string, unknown>>("/api/admin/customers"),
queryFn: () => jsonQuery<Customer[]>("/api/admin/customers"),
staleTime: 2 * 60_000,
});
export const offerTemplatesOptions = (action?: string) =>
export const itemTemplatesOptions = () =>
queryOptions({
queryKey: ["offer-templates", action ?? "all"],
queryFn: () => {
const url = action
? `/api/admin/offers-templates?action=${action}`
: "/api/admin/offers-templates";
return jsonQuery<Record<string, unknown>[]>(url);
},
queryKey: ["offer-templates", "items"],
queryFn: () =>
jsonQuery<ItemTemplate[]>("/api/admin/offers-templates?action=items"),
});
export const scopeTemplatesOptions = () =>
queryOptions({
queryKey: ["offer-templates", "scopes"],
queryFn: () => jsonQuery<ScopeTemplate[]>("/api/admin/offers-templates"),
});
export const offerListOptions = (filters: {
@@ -40,11 +85,62 @@ export const offerListOptions = (filters: {
},
});
export interface OfferItemData {
id?: number;
description: string;
item_description: string;
quantity: number;
unit: string;
unit_price: number;
is_included_in_total: boolean;
position?: number;
}
export interface OfferSectionData {
title: string;
title_cz: string;
content: string;
position?: number;
}
export interface OfferLockInfo {
user_id: number;
username: string;
full_name: string;
}
export interface OfferOrderInfo {
id: number;
order_number: string;
status?: string;
}
export interface OfferDetailData {
id: number;
quotation_number: string;
project_code: string;
customer_id: number | null;
customer_name: string;
created_at: string;
valid_until: string;
currency: string;
language: string;
vat_rate: number;
apply_vat: boolean;
exchange_rate: string;
scope_title: string;
scope_description: string;
items?: OfferItemData[];
sections?: OfferSectionData[];
status: string;
order: OfferOrderInfo | null;
locked_by: OfferLockInfo | null;
}
export const offerDetailOptions = (id: string | undefined) =>
queryOptions({
queryKey: ["offers", id],
queryFn: () =>
jsonQuery<Record<string, unknown>>(`/api/admin/offers/${id}`),
queryFn: () => jsonQuery<OfferDetailData>(`/api/admin/offers/${id}`),
enabled: !!id,
});

View File

@@ -1,6 +1,60 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
export interface OrderItem {
id?: number;
description: string;
item_description?: string;
quantity: number;
unit: string;
unit_price: number;
is_included_in_total: number | boolean;
}
export interface OrderSection {
id?: number;
title: string;
title_cz?: string;
content: string;
}
export interface OrderInvoice {
id: number;
invoice_number: string;
}
export interface OrderProject {
id: number;
project_number: string;
name: string;
has_nas_folder?: boolean;
}
export interface OrderData {
id: number;
order_number: string;
quotation_id: number;
quotation_number: string;
project_code?: string;
customer_name: string;
customer_order_number: string;
currency: string;
created_at: string;
status: string;
notes: string;
attachment_name?: string;
apply_vat: number | boolean;
vat_rate: number;
language?: string;
items: OrderItem[];
sections: OrderSection[];
scope_title?: string;
scope_description?: string;
valid_transitions?: string[];
invoice?: OrderInvoice;
project?: OrderProject;
}
export const orderListOptions = (filters: {
search?: string;
sort?: string;
@@ -25,7 +79,6 @@ export const orderListOptions = (filters: {
export const orderDetailOptions = (id: string | undefined) =>
queryOptions({
queryKey: ["orders", id],
queryFn: () =>
jsonQuery<Record<string, unknown>>(`/api/admin/orders/${id}`),
queryFn: () => jsonQuery<OrderData>(`/api/admin/orders/${id}`),
enabled: !!id,
});

View File

@@ -2,6 +2,32 @@ import { queryOptions } from "@tanstack/react-query";
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
import apiFetch from "../../utils/api";
export interface ProjectNote {
id: number;
content: string;
user_name: string;
created_at: string;
}
export interface ProjectData {
id: number;
project_number: string;
name: string;
status: string;
start_date: string;
end_date: string;
customer_name: string;
responsible_user_id: string;
notes?: string;
order_id?: number;
order_number?: string;
order_status?: string;
quotation_id?: number;
quotation_number?: string;
has_nas_folder?: boolean;
project_notes?: ProjectNote[];
}
export const projectListOptions = (filters: {
search?: string;
sort?: string;
@@ -26,8 +52,7 @@ export const projectListOptions = (filters: {
export const projectDetailOptions = (id: string | undefined) =>
queryOptions({
queryKey: ["projects", id],
queryFn: () =>
jsonQuery<Record<string, unknown>>(`/api/admin/projects/${id}`),
queryFn: () => jsonQuery<ProjectData>(`/api/admin/projects/${id}`),
enabled: !!id,
});

View File

@@ -1,11 +1,63 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery } from "../apiAdapter";
export interface CompanySettingsCustomField {
name: string;
value: string;
showLabel: boolean;
}
export interface CompanySettingsData {
// Company info
company_name?: string;
street?: string;
city?: string;
postal_code?: string;
country?: string;
company_id?: string;
vat_id?: string;
ico?: string;
dic?: string;
has_logo?: boolean;
has_logo_dark?: boolean;
custom_fields?: CompanySettingsCustomField[];
supplier_field_order?: string[];
// System settings
require_2fa?: boolean;
default_currency?: string;
default_vat_rate?: number;
available_currencies?: string[];
available_vat_rates?: number[];
break_threshold_hours?: number;
break_duration_short?: number;
break_duration_long?: number;
clock_rounding_minutes?: number;
invoice_alert_email?: string;
leave_notify_email?: string;
smtp_from?: string;
smtp_from_name?: string;
max_login_attempts?: number;
lockout_minutes?: number;
max_requests_per_minute?: number;
// Numbering
quotation_prefix?: string;
order_type_code?: string;
invoice_type_code?: string;
offer_number_pattern?: string;
order_number_pattern?: string;
invoice_number_pattern?: string;
// Misc
app_version?: string;
}
export const companySettingsOptions = () =>
queryOptions({
queryKey: ["company-settings"],
queryFn: () =>
jsonQuery<Record<string, unknown>>("/api/admin/company-settings"),
jsonQuery<CompanySettingsData>("/api/admin/company-settings"),
staleTime: 5 * 60_000,
});

View File

@@ -1,6 +1,33 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery } from "../apiAdapter";
export interface TripVehicle {
id: number;
spz: string;
name: string;
}
export interface TripUser {
id: number;
name: string;
}
export interface BackendTrip {
id: number;
vehicle_id: number;
user_id: number;
trip_date: string;
start_km: number;
end_km: number;
distance: number | null;
route_from: string;
route_to: string;
is_business: boolean;
notes: string | null;
users: { id: number; first_name: string; last_name: string };
vehicles: { id: number; name: string; spz: string };
}
export const tripListOptions = (filters: {
month?: number;
year?: number;
@@ -32,24 +59,21 @@ export const tripListOptions = (filters: {
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
const qs = params.toString();
return jsonQuery<Record<string, unknown>[]>(
`/api/admin/trips${qs ? `?${qs}` : ""}`,
);
return jsonQuery<BackendTrip[]>(`/api/admin/trips${qs ? `?${qs}` : ""}`);
},
});
export const tripVehiclesOptions = () =>
queryOptions({
queryKey: ["trips", "vehicles"],
queryFn: () => jsonQuery<Record<string, unknown>[]>("/api/admin/vehicles"),
queryFn: () => jsonQuery<TripVehicle[]>("/api/admin/vehicles"),
staleTime: 2 * 60_000,
});
export const tripUsersOptions = () =>
queryOptions({
queryKey: ["trips", "users"],
queryFn: () =>
jsonQuery<Record<string, unknown>[]>("/api/admin/trips/users"),
queryFn: () => jsonQuery<TripUser[]>("/api/admin/trips/users"),
staleTime: 2 * 60_000,
});
@@ -75,8 +99,6 @@ export const tripHistoryOptions = (filters: {
params.set("vehicle_id", String(filters.vehicleId));
if (filters.userId) params.set("user_id", String(filters.userId));
const qs = params.toString();
return jsonQuery<Record<string, unknown>[]>(
`/api/admin/trips${qs ? `?${qs}` : ""}`,
);
return jsonQuery<BackendTrip[]>(`/api/admin/trips${qs ? `?${qs}` : ""}`);
},
});

View File

@@ -1,18 +1,37 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery } from "../apiAdapter";
export const userListOptions = () =>
export interface User {
id: number;
username: string;
email: string;
first_name: string;
last_name: string;
role_id: number;
roles?: { id: number; name: string; display_name: string } | null;
is_active: boolean;
}
export interface Role {
id: number;
name: string;
display_name: string;
}
export const userListOptions = (permission?: string) =>
queryOptions({
queryKey: ["users"],
queryKey: ["users", { permission }],
queryFn: () => {
// The users endpoint returns { success, data: { items, ... } } or { success, data: [...] }
return jsonQuery<Record<string, unknown>>("/api/admin/users");
const url = permission
? `/api/admin/users?permission=${encodeURIComponent(permission)}&limit=100`
: "/api/admin/users?limit=100";
return jsonQuery<User[]>(url);
},
});
export const roleListOptions = () =>
queryOptions({
queryKey: ["roles"],
queryFn: () => jsonQuery<Record<string, unknown>[]>("/api/admin/roles"),
queryFn: () => jsonQuery<Role[]>("/api/admin/roles"),
staleTime: 2 * 60_000,
});

View File

@@ -601,6 +601,14 @@
color: var(--text-muted) !important;
}
/* Completed offer */
.offers-completed-row {
background: var(
--row-completed,
color-mix(in srgb, var(--success) 8%, transparent)
);
}
/* Read-only form (invalidated offer detail) */
.offers-readonly input[readonly],
.offers-readonly select:disabled {

View File

@@ -244,7 +244,7 @@ export default function Attendance() {
if (result.success) {
await queryClient.invalidateQueries({
queryKey: ["attendance", "status"],
queryKey: ["attendance"],
});
punchTimeoutRef.current = setTimeout(() => {
alert.success(result.data?.message || result.message || "Uloženo");
@@ -271,7 +271,7 @@ export default function Attendance() {
const result = await response.json();
if (result.success) {
await queryClient.invalidateQueries({
queryKey: ["attendance", "status"],
queryKey: ["attendance"],
});
alert.success(
result.data?.message || result.message || "Přestávka zaznamenána",
@@ -297,6 +297,7 @@ export default function Attendance() {
const result = await response.json();
if (result.success) {
await queryClient.invalidateQueries({ queryKey: ["attendance"] });
alert.success("Poznámka byla uložena");
} else {
alert.error(result.error);
@@ -319,7 +320,7 @@ export default function Attendance() {
const result = await response.json();
if (result.success) {
await queryClient.invalidateQueries({
queryKey: ["attendance", "status"],
queryKey: ["attendance"],
});
alert.success(
result.data?.message || result.message || "Projekt přepnut",
@@ -363,7 +364,13 @@ export default function Attendance() {
if (result.success) {
setIsLeaveModalOpen(false);
await queryClient.invalidateQueries({
queryKey: ["attendance", "status"],
queryKey: ["attendance"],
});
await queryClient.invalidateQueries({
queryKey: ["leave-requests"],
});
await queryClient.invalidateQueries({
queryKey: ["leave"],
});
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
@@ -910,7 +917,7 @@ export default function Attendance() {
<path d="M9 18l6-6-6-6" />
</svg>
</Link>
{hasPermission("attendance.admin") && (
{hasPermission("attendance.manage") && (
<Link to="/attendance/admin" className="attendance-quick-link">
<svg
width="20"

View File

@@ -87,7 +87,7 @@ export default function AttendanceAdmin() {
useModalLock(showEditModal);
useModalLock(showCreateModal);
if (!hasPermission("attendance.admin")) return <Forbidden />;
if (!hasPermission("attendance.manage")) return <Forbidden />;
// Show skeleton only on initial load (no data yet), not on filter changes
const isInitialLoad =

View File

@@ -11,6 +11,13 @@ import {
attendanceBalancesOptions,
attendanceWorkFundOptions,
attendanceProjectReportOptions,
type BalanceEntry,
type BalancesData,
type FundData,
type FundUserData,
type MonthFundData,
type ProjectData,
type UserShort,
} from "../lib/queries/attendance";
import apiFetch from "../utils/api";
@@ -18,70 +25,6 @@ import { Skeleton } from "boneyard-js/react";
import AttendanceBalancesFixture from "../fixtures/AttendanceBalancesFixture";
const API_BASE = "/api/admin";
interface BalanceEntry {
name: string;
vacation_total: number;
vacation_used: number;
vacation_remaining: number;
sick_used: number;
}
interface UserShort {
id: number | string;
name: string;
}
interface FundUserData {
name: string;
worked: number;
covered: number;
overtime: number;
missing: number;
}
interface MonthFundData {
month_name: string;
fund: number;
fund_to_date: number;
business_days: number;
users?: Record<string, FundUserData>;
}
interface ProjectUser {
user_id: number;
user_name: string;
hours: number;
}
interface ProjectEntry {
project_id: number | null;
project_number?: string;
project_name?: string;
hours: number;
users: ProjectUser[];
}
interface MonthProjectData {
month_name: string;
projects: ProjectEntry[];
}
interface BalancesData {
users: UserShort[];
balances: Record<string, BalanceEntry>;
}
interface FundData {
months: Record<string, MonthFundData>;
holidays: unknown[];
users: UserShort[];
balances: Record<string, unknown>;
}
interface ProjectData {
months: Record<string, MonthProjectData>;
}
const getVacationClass = (remaining: number): string => {
if (remaining <= 0) return "text-danger";
if (remaining < 20) return "text-warning";
@@ -144,18 +87,15 @@ export default function AttendanceBalances() {
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const [year, setYear] = useState(new Date().getFullYear());
const { data: balancesRaw, isPending: balancesPending } = useQuery(
const { data: balancesData, isPending: balancesPending } = useQuery(
attendanceBalancesOptions(year),
);
const { data: fundRaw, isPending: fundPending } = useQuery(
const { data: fundData, isPending: fundPending } = useQuery(
attendanceWorkFundOptions(year),
);
const { data: projectRaw, isPending: projectPending } = useQuery(
const { data: projectData, isPending: projectPending } = useQuery(
attendanceProjectReportOptions(year),
);
const balancesData = balancesRaw as BalancesData | undefined;
const fundData = fundRaw as FundData | undefined;
const projectData = projectRaw as ProjectData | undefined;
const [showEditModal, setShowEditModal] = useState(false);
const [editingUser, setEditingUser] = useState<{

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { userListOptions } from "../lib/queries/users";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
@@ -14,11 +14,6 @@ import { Skeleton } from "boneyard-js/react";
import AttendanceCreateFixture from "../fixtures/AttendanceCreateFixture";
const API_BASE = "/api/admin";
interface User {
id: number | string;
name: string;
}
interface CreateForm {
user_id: string;
shift_date: string;
@@ -39,8 +34,12 @@ export default function AttendanceCreate() {
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { data: usersData, isPending: loading } = useQuery(userListOptions());
const users = (usersData as unknown as User[] | undefined) ?? [];
const users = (usersData ?? []).map((u) => ({
id: u.id,
name: `${u.first_name} ${u.last_name}`.trim() || u.username,
}));
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState<CreateForm>(() => {
@@ -83,6 +82,7 @@ export default function AttendanceCreate() {
if (result.success) {
alert.success(result.message);
await queryClient.invalidateQueries({ queryKey: ["attendance"] });
navigate(`/attendance/admin?month=${form.shift_date.substring(0, 7)}`);
} else {
alert.error(result.error);
@@ -107,7 +107,7 @@ export default function AttendanceCreate() {
const isWorkType = form.leave_type === "work";
if (!hasPermission("attendance.admin")) return <Forbidden />;
if (!hasPermission("attendance.manage")) return <Forbidden />;
return (
<Skeleton

View File

@@ -5,7 +5,11 @@ import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import AdminDatePicker from "../components/AdminDatePicker";
import { companySettingsOptions } from "../lib/queries/settings";
import { attendanceHistoryOptions } from "../lib/queries/attendance";
import {
attendanceHistoryOptions,
type AttendanceRecord,
type ProjectLogEntry,
} from "../lib/queries/attendance";
import {
formatDate,
formatDatetime,
@@ -20,29 +24,6 @@ import {
import FormField from "../components/FormField";
import { Skeleton } from "boneyard-js/react";
import AttendanceHistoryFixture from "../fixtures/AttendanceHistoryFixture";
interface ProjectLog {
id?: number;
project_id?: number;
project_name?: string;
started_at?: string;
ended_at?: string | null;
hours?: string | number | null;
minutes?: string | number | null;
}
interface AttendanceRecord {
id: number;
shift_date: string;
leave_type?: string;
leave_hours?: number;
arrival_time?: string | null;
departure_time?: string | null;
break_start?: string | null;
break_end?: string | null;
notes?: string;
project_name?: string;
project_logs?: ProjectLog[];
}
const MONTH_NAMES = [
"Leden",
@@ -196,9 +177,7 @@ export default function AttendanceHistory() {
const { user, hasPermission } = useAuth();
const queryClient = useQueryClient();
const { data: companySettings } = useQuery(companySettingsOptions());
const companyName =
((companySettings as Record<string, unknown> | undefined)
?.company_name as string) || "";
const companyName = companySettings?.company_name || "";
const printRef = useRef<HTMLDivElement>(null);
const [month, setMonth] = useState(() => {
const now = new Date();
@@ -207,7 +186,7 @@ export default function AttendanceHistory() {
const { data, isPending } = useQuery(
attendanceHistoryOptions({ month, userId: user?.id }),
);
const records = (data as AttendanceRecord[] | undefined) ?? [];
const records = data ?? [];
const computed = useMemo(() => {
const [yearStr, monthStr] = month.split("-");

View File

@@ -154,7 +154,7 @@ export default function AttendanceLocation() {
return `${d.getDate()}.${d.getMonth() + 1}.${d.getFullYear()} ${formatTime(datetime)}`;
};
if (!hasPermission("attendance.admin")) return <Forbidden />;
if (!hasPermission("attendance.manage")) return <Forbidden />;
if (!record) {
return null;

View File

@@ -5,8 +5,11 @@ import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import FormField from "../components/FormField";
import ConfirmModal from "../components/ConfirmModal";
import { companySettingsOptions } from "../lib/queries/settings";
import { bankAccountsOptions } from "../lib/queries/common";
import {
companySettingsOptions,
type CompanySettingsData,
} from "../lib/queries/settings";
import { bankAccountsOptions, type BankAccount } from "../lib/queries/common";
import { motion } from "framer-motion";
import apiFetch from "../utils/api";
@@ -47,17 +50,6 @@ interface CompanyForm {
vat_id: string;
}
interface BankAccount {
id: number;
account_name: string;
bank_name: string;
account_number: string;
iban: string;
bic: string;
currency: string;
is_default: boolean;
}
interface BankForm {
account_name: string;
bank_name: string;
@@ -196,49 +188,49 @@ export default function CompanySettings({
);
const bankAccountsList: BankAccount[] = Array.isArray(bankAccountsData)
? (bankAccountsData as unknown as BankAccount[])
? bankAccountsData
: [];
// Populate form state when settings data arrives
useEffect(() => {
if (!settingsData) return;
const d = settingsData as Record<string, unknown>;
setForm({
company_name: (d.company_name as string) || "",
street: (d.street as string) || "",
city: (d.city as string) || "",
postal_code: (d.postal_code as string) || "",
country: (d.country as string) || "",
company_id: (d.company_id as string) || "",
vat_id: (d.vat_id as string) || "",
company_name: settingsData.company_name || "",
street: settingsData.street || "",
city: settingsData.city || "",
postal_code: settingsData.postal_code || "",
country: settingsData.country || "",
company_id: settingsData.company_id || "",
vat_id: settingsData.vat_id || "",
});
const cf: CustomField[] =
Array.isArray(d.custom_fields) && d.custom_fields.length > 0
? (d.custom_fields as CustomField[]).map((f, i) => ({
Array.isArray(settingsData.custom_fields) &&
settingsData.custom_fields.length > 0
? settingsData.custom_fields.map((f, i) => ({
...f,
showLabel: f.showLabel !== false,
_key: f._key || `cf-${Date.now()}-${i}`,
_key: (f as CustomField)._key || `cf-${Date.now()}-${i}`,
}))
: [];
setCustomFields(cf);
if (
Array.isArray(d.supplier_field_order) &&
d.supplier_field_order.length > 0
Array.isArray(settingsData.supplier_field_order) &&
settingsData.supplier_field_order.length > 0
) {
setFieldOrder(d.supplier_field_order as string[]);
setFieldOrder(settingsData.supplier_field_order);
} else {
setFieldOrder([...DEFAULT_FIELD_ORDER]);
}
if (
Array.isArray(d.available_currencies) &&
d.available_currencies.length > 0
Array.isArray(settingsData.available_currencies) &&
settingsData.available_currencies.length > 0
) {
setAvailableCurrencies(d.available_currencies as string[]);
setAvailableCurrencies(settingsData.available_currencies);
}
if (d.has_logo) {
if (settingsData.has_logo) {
fetchLogo("light");
}
if (d.has_logo_dark) {
if (settingsData.has_logo_dark) {
fetchLogo("dark");
}
}, [settingsData, fetchLogo]);
@@ -277,6 +269,7 @@ export default function CompanySettings({
alert.success(result.message);
resetBankForm();
queryClient.invalidateQueries({ queryKey: ["bank-accounts"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Chyba při ukládání");
}
@@ -305,6 +298,7 @@ export default function CompanySettings({
alert.success(result.message);
if (editingBank === bankDeleteConfirm.id) resetBankForm();
queryClient.invalidateQueries({ queryKey: ["bank-accounts"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Chyba při mazání");
}
@@ -355,6 +349,9 @@ export default function CompanySettings({
if (result.success) {
alert.success(result.message || "Nastavení bylo uloženo");
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Nepodařilo se uložit nastavení");
}
@@ -390,6 +387,9 @@ export default function CompanySettings({
if (result.success) {
alert.success(result.message || "Logo bylo nahráno");
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
fetchLogo(variant);
} else {
alert.error(result.error || "Nepodařilo se nahrát logo");
@@ -406,7 +406,7 @@ export default function CompanySettings({
setForm((prev) => ({ ...prev, [field]: value }));
};
if (!embedded && !hasPermission("settings.manage")) return <Forbidden />;
if (!embedded && !hasPermission("settings.company")) return <Forbidden />;
if (settingsLoading) {
return (

View File

@@ -113,6 +113,7 @@ export default function Dashboard() {
if (result.success) {
alert.success(result.data?.message || "Docházka zaznamenána");
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
} else {
alert.error(result.error || "Chyba při záznamu docházky");
}
@@ -171,7 +172,9 @@ export default function Dashboard() {
});
const data = await response.json();
if (data.success) {
queryClient.invalidateQueries({ queryKey: ["settings", "2fa"] });
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
queryClient.invalidateQueries({ queryKey: ["users"] });
setBackupCodes(data.data?.backup_codes || null);
setTotpSecret(null);
setTotpQrUri(null);
@@ -199,7 +202,9 @@ export default function Dashboard() {
});
const data = await response.json();
if (data.success) {
queryClient.invalidateQueries({ queryKey: ["settings", "2fa"] });
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
queryClient.invalidateQueries({ queryKey: ["users"] });
setShow2FADisable(false);
setDisableCode("");
updateUser({ totpEnabled: false });
@@ -343,7 +348,7 @@ export default function Dashboard() {
<DashActivityFeed activities={dashData?.recent_activity ?? null} />
)}
{hasPermission("attendance.admin") && (
{hasPermission("attendance.manage") && (
<DashAttendanceToday attendance={dashData?.attendance ?? null} />
)}

View File

@@ -38,10 +38,16 @@ import {
} from "@dnd-kit/modifiers";
import { CSS } from "@dnd-kit/utilities";
import apiFetch from "../utils/api";
import { companySettingsOptions } from "../lib/queries/settings";
import { invoiceDetailOptions } from "../lib/queries/invoices";
import { offerCustomersOptions } from "../lib/queries/offers";
import { bankAccountsOptions } from "../lib/queries/common";
import {
companySettingsOptions,
type CompanySettingsData,
} from "../lib/queries/settings";
import {
invoiceDetailOptions,
type InvoiceDetail,
} from "../lib/queries/invoices";
import { offerCustomersOptions, type Customer } from "../lib/queries/offers";
import { bankAccountsOptions, type BankAccount } from "../lib/queries/common";
import { jsonQuery } from "../lib/apiAdapter";
import { formatCurrency, formatDate } from "../utils/formatters";
@@ -74,23 +80,6 @@ interface InvoiceItem {
vat_rate: number;
}
interface Customer {
id: number;
name: string;
company_id?: string;
city?: string;
}
interface BankAccount {
id: number;
account_name: string;
account_number?: string;
bank_name?: string;
bic?: string;
iban?: string;
is_default?: boolean;
}
interface InvoiceForm {
customer_id: number | null;
customer_name: string;
@@ -114,42 +103,6 @@ interface InvoiceForm {
bank_account: string;
}
interface InvoiceCustomer {
company_id?: string;
vat_id?: string;
}
interface Invoice {
id: number;
invoice_number: string;
customer_id?: number | null;
customer_name: string | null;
customer?: InvoiceCustomer;
order_id?: number;
order_number?: string;
currency: string;
status: string;
issue_date: string;
due_date: string;
tax_date: string;
payment_method: string;
constant_symbol?: string;
issued_by: string | null;
paid_date?: string;
notes: string;
language: string;
apply_vat: number | string;
vat_rate?: number;
billing_text?: string;
bank_name?: string;
bank_swift?: string;
bank_iban?: string;
bank_account?: string;
bank_account_id?: number | null;
items: Omit<InvoiceItem, "_key">[];
valid_transitions?: string[];
}
// Sortable row for create mode
function SortableInvoiceRow({
item,
@@ -226,7 +179,7 @@ function SortableInvoiceRow({
<input
type="number"
value={item.quantity}
onChange={(e) => onUpdate(index, "quantity", e.target.value)}
onChange={(e) => onUpdate(index, "quantity", Number(e.target.value))}
className="admin-form-input"
min="0"
step="any"
@@ -255,7 +208,9 @@ function SortableInvoiceRow({
<input
type="number"
value={item.unit_price}
onChange={(e) => onUpdate(index, "unit_price", e.target.value)}
onChange={(e) =>
onUpdate(index, "unit_price", Number(e.target.value))
}
className="admin-form-input"
step="any"
style={{
@@ -395,14 +350,7 @@ export default function InvoiceDetail() {
const [customerSearch, setCustomerSearch] = useState("");
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
const companySettings = useQuery(companySettingsOptions()).data as unknown as
| {
default_currency: string;
default_vat_rate: number;
available_currencies: string[];
available_vat_rates: number[];
}
| undefined;
const companySettings = useQuery(companySettingsOptions()).data;
useEffect(() => {
if (companySettings && !isEdit) {
@@ -441,20 +389,13 @@ export default function InvoiceDetail() {
const queryClient = useQueryClient();
const customersQuery = useQuery(offerCustomersOptions());
const customers = useMemo<Customer[]>(() => {
const data = customersQuery.data;
if (!data) return [];
if (Array.isArray(data)) return data as Customer[];
const obj = data as Record<string, unknown>;
if (Array.isArray(obj.customers)) return obj.customers as Customer[];
return [];
}, [customersQuery.data]);
const customers = customersQuery.data ?? [];
const bankAccountsQuery = useQuery(bankAccountsOptions());
const bankAccounts = (bankAccountsQuery.data ?? []) as BankAccount[];
const bankAccounts = bankAccountsQuery.data ?? [];
const invoiceQuery = useQuery(invoiceDetailOptions(id));
const invoice = (invoiceQuery.data as Invoice | undefined) ?? null;
const invoice = invoiceQuery.data ?? null;
const nextNumberQuery = useQuery({
queryKey: ["invoices", "next-number"],
@@ -505,56 +446,54 @@ export default function InvoiceDetail() {
return;
if (!invoiceQuery.data) return;
const inv = invoiceQuery.data as Record<string, unknown>;
const inv = invoiceQuery.data;
// Match bank account from invoice's bank details
let matchedBankId: number | string = "";
const bankData = bankAccountsQuery.data ?? [];
if (Array.isArray(bankData)) {
const match = bankData.find(
(b: BankAccount) =>
(inv.bank_iban && b.iban === inv.bank_iban) ||
(inv.bank_account && b.account_number === inv.bank_account),
);
if (match) matchedBankId = match.id;
}
const match = bankData.find(
(b) =>
(inv.bank_iban && b.iban === inv.bank_iban) ||
(inv.bank_account && b.account_number === inv.bank_account),
);
if (match) matchedBankId = match.id;
const formData = {
customer_id: (inv.customer_id as number) || null,
customer_name: (inv.customer_name as string) || "",
order_id: (inv.order_id as number) || null,
const formData: InvoiceForm = {
customer_id: inv.customer_id || null,
customer_name: inv.customer_name || "",
order_id: inv.order_id || null,
issue_date: inv.issue_date
? new Date(inv.issue_date as string).toISOString().split("T")[0]
? new Date(inv.issue_date).toISOString().split("T")[0]
: "",
due_date: inv.due_date
? new Date(inv.due_date as string).toISOString().split("T")[0]
? new Date(inv.due_date).toISOString().split("T")[0]
: "",
tax_date: inv.tax_date
? new Date(inv.tax_date as string).toISOString().split("T")[0]
? new Date(inv.tax_date).toISOString().split("T")[0]
: "",
currency: (inv.currency as string) || "CZK",
currency: inv.currency || "CZK",
apply_vat: Number(inv.apply_vat) ? 1 : 0,
vat_rate: Number(inv.vat_rate) || 21,
payment_method: (inv.payment_method as string) || "Příkazem",
constant_symbol: (inv.constant_symbol as string) || "0308",
issued_by: (inv.issued_by as string) || "",
billing_text: (inv.billing_text as string) || "",
notes: (inv.notes as string) || "",
language: (inv.language as string) || "cs",
payment_method: inv.payment_method || "Příkazem",
constant_symbol: inv.constant_symbol || "0308",
issued_by: inv.issued_by || "",
billing_text: inv.billing_text || "",
notes: inv.notes || "",
language: inv.language || "cs",
bank_account_id: matchedBankId,
bank_name: (inv.bank_name as string) || "",
bank_swift: (inv.bank_swift as string) || "",
bank_iban: (inv.bank_iban as string) || "",
bank_account: (inv.bank_account as string) || "",
bank_name: inv.bank_name || "",
bank_swift: inv.bank_swift || "",
bank_iban: inv.bank_iban || "",
bank_account: inv.bank_account || "",
};
setForm(formData);
setNotes((inv.notes as string) || "");
setInvoiceNumber((inv.invoice_number as string) || "");
setNotes(inv.notes || "");
setInvoiceNumber(inv.invoice_number || "");
// Calculate dueDays from existing dates
if (inv.issue_date && inv.due_date) {
const issue = new Date(inv.issue_date as string);
const due = new Date(inv.due_date as string);
const issue = new Date(inv.issue_date);
const due = new Date(inv.due_date);
const diffDays = Math.round(
(due.getTime() - issue.getTime()) / (1000 * 60 * 60 * 24),
);
@@ -562,17 +501,17 @@ export default function InvoiceDetail() {
}
// Populate items from existing invoice
const invItems = inv.items as Record<string, unknown>[] | undefined;
const invItems = inv.items;
const mappedItems =
invItems && invItems.length > 0
? invItems.map((item) => ({
_key: `inv-${++keyCounterRef.current}`,
id: item.id as number | undefined,
description: (item.description as string) || "",
id: item.id,
description: item.description || "",
quantity: Number(item.quantity) || 1,
unit: (item.unit as string) || "",
unit: item.unit || "",
unit_price: Number(item.unit_price) || 0,
vat_rate: Number(item.vat_rate) || Number(inv.vat_rate) || 21,
vat_rate: Number(inv.vat_rate) || 21,
}))
: [];
if (mappedItems.length > 0) {
@@ -613,7 +552,7 @@ export default function InvoiceDetail() {
}
// Set default bank account
const defaultAcc = bankAccounts.find((a: BankAccount) => a.is_default);
const defaultAcc = bankAccounts.find((a) => a.is_default);
if (defaultAcc) {
setForm((prev) => ({
...prev,
@@ -859,12 +798,14 @@ export default function InvoiceDetail() {
);
initialSnapshotRef.current = JSON.stringify({ form, items });
if (isEdit) {
queryClient.invalidateQueries({ queryKey: ["invoices", id] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
} else {
navigate(`/invoices/${result.data.invoice_id}`);
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
navigate(`/invoices/${result.data.invoice_id}`);
}
} else {
alert.error(
@@ -895,8 +836,9 @@ export default function InvoiceDetail() {
const result = await response.json();
if (result.success) {
alert.success(result.message || "Stav byl změněn");
queryClient.invalidateQueries({ queryKey: ["invoices", id] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
} else {
alert.error(result.error || "Nepodařilo se změnit stav");
}
@@ -943,6 +885,7 @@ export default function InvoiceDetail() {
alert.success(result.message || "Faktura byla smazána");
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
navigate("/invoices");
} else {
alert.error(result.error || "Nepodařilo se smazat fakturu");

View File

@@ -220,6 +220,7 @@ export default function Invoices() {
alert.success(result.message || "Faktura byla smazána");
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
} else {
alert.error(result.error || "Nepodařilo se smazat fakturu");
}
@@ -243,6 +244,7 @@ export default function Invoices() {
alert.success("Faktura označena jako zaplacená");
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
} else {
alert.error(data.error || "Nepodařilo se změnit stav");
}

View File

@@ -158,6 +158,8 @@ export default function LeaveApproval() {
if (result.success) {
setApproveModal({ open: false, request: null });
await queryClient.invalidateQueries({ queryKey: ["leave"] });
await queryClient.invalidateQueries({ queryKey: ["leave-requests"] });
await queryClient.invalidateQueries({ queryKey: ["attendance"] });
alert.success("Žádost byla schválena");
} else {
alert.error(result.error);
@@ -195,6 +197,8 @@ export default function LeaveApproval() {
setRejectModal({ open: false, request: null });
setRejectNote("");
await queryClient.invalidateQueries({ queryKey: ["leave"] });
await queryClient.invalidateQueries({ queryKey: ["leave-requests"] });
await queryClient.invalidateQueries({ queryKey: ["attendance"] });
alert.success("Žádost byla zamítnuta");
} else {
alert.error(result.error);

View File

@@ -9,7 +9,7 @@ import LeaveRequestsFixture from "../fixtures/LeaveRequestsFixture";
import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
import apiFetch from "../utils/api";
import ConfirmModal from "../components/ConfirmModal";
import { leaveRequestsOptions } from "../lib/queries/leave";
import { leaveRequestsOptions, type LeaveRequest } from "../lib/queries/leave";
const API_BASE = "/api/admin";
@@ -39,26 +39,13 @@ const leaveTypeClasses: Record<string, string> = {
unpaid: "badge-unpaid",
};
interface LeaveRequest {
id: number;
leave_type: string;
date_from: string;
date_to: string;
total_days: number;
total_hours: number;
status: string;
notes?: string;
reviewer_note?: string;
created_at: string;
}
export default function LeaveRequests() {
const alert = useAlert();
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const { data: requests = [], isPending } = useQuery(
leaveRequestsOptions(true),
) as { data: LeaveRequest[]; isPending: boolean };
);
const [cancelModal, setCancelModal] = useState<{
open: boolean;
id: number | null;
@@ -82,6 +69,8 @@ export default function LeaveRequests() {
if (result.success) {
setCancelModal({ open: false, id: null });
queryClient.invalidateQueries({ queryKey: ["leave-requests"] });
queryClient.invalidateQueries({ queryKey: ["leave"] });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
alert.success(result.message);
} else {
alert.error(result.error);

View File

@@ -14,8 +14,14 @@ import { motion, AnimatePresence } from "framer-motion";
import {
offerDetailOptions,
offerCustomersOptions,
offerTemplatesOptions,
scopeTemplatesOptions,
offerNextNumberOptions,
type OfferDetailData,
type OfferItemData,
type OfferSectionData,
type OfferLockInfo,
type OfferOrderInfo,
type ScopeTemplate,
} from "../lib/queries/offers";
import {
@@ -50,7 +56,10 @@ import apiFetch from "../utils/api";
import { formatCurrency } from "../utils/formatters";
import { Skeleton } from "boneyard-js/react";
import OfferDetailFixture from "../fixtures/OfferDetailFixture";
import { companySettingsOptions } from "../lib/queries/settings";
import {
companySettingsOptions,
type CompanySettingsData,
} from "../lib/queries/settings";
const API_BASE = "/api/admin";
const DRAFT_KEY = "boha_offer_draft";
@@ -94,11 +103,6 @@ interface Customer {
city?: string;
}
interface OrderInfo {
id: number;
order_number: string;
}
const emptyForm: OfferForm = {
quotation_number: "",
project_code: "",
@@ -206,7 +210,7 @@ function SortableItemRow({
<input
type="number"
value={item.quantity}
onChange={(e) => onUpdate("quantity", e.target.value)}
onChange={(e) => onUpdate("quantity", parseInt(e.target.value, 10))}
className="admin-form-input"
step="1"
readOnly={readOnly}
@@ -225,7 +229,7 @@ function SortableItemRow({
<input
type="number"
value={item.unit_price}
onChange={(e) => onUpdate("unit_price", e.target.value)}
onChange={(e) => onUpdate("unit_price", parseFloat(e.target.value))}
className="admin-form-input"
step="0.01"
readOnly={readOnly}
@@ -318,34 +322,15 @@ export default function OfferDetail() {
...offerCustomersOptions(),
enabled: !isEdit,
});
const { data: templatesData } = useQuery({
...offerTemplatesOptions(),
enabled: !isEdit,
});
const { data: templatesData } = useQuery(scopeTemplatesOptions());
const { data: nextNumberData } = useQuery({
...offerNextNumberOptions(),
enabled: !isEdit,
});
// Derive typed arrays from query data
const customers = (
Array.isArray(customersData)
? customersData
: (customersData as Record<string, unknown>)?.customers
? ((customersData as Record<string, unknown>).customers as Customer[])
: []
) as Customer[];
const scopeTemplates = (
Array.isArray(templatesData) ? templatesData : []
) as Array<{
id: number;
name: string;
description?: string;
scope_template_sections?: Array<{
title?: string;
title_cz?: string;
content?: string;
}>;
}>;
const customers: Customer[] = Array.isArray(customersData)
? customersData
: [];
const scopeTemplates = templatesData ?? [];
const loading = isEdit && offerQuery.isLoading;
const [saving, setSaving] = useState(false);
@@ -396,7 +381,7 @@ export default function OfferDetail() {
});
const [customerSearch, setCustomerSearch] = useState("");
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
const [orderInfo, setOrderInfo] = useState<OrderInfo | null>(null);
const [orderInfo, setOrderInfo] = useState<OfferOrderInfo | null>(null);
const [offerStatus, setOfferStatus] = useState<string>("");
const [deleteConfirm, setDeleteConfirm] = useState(false);
@@ -409,14 +394,7 @@ export default function OfferDetail() {
const [orderAttachment, setOrderAttachment] = useState<File | null>(null);
const [pdfLoading, setPdfLoading] = useState(false);
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const companySettings = useQuery(companySettingsOptions()).data as unknown as
| {
default_currency: string;
default_vat_rate: number;
available_currencies: string[];
available_vat_rates: number[];
}
| undefined;
const { data: companySettings } = useQuery(companySettingsOptions());
useEffect(() => {
if (companySettings && !isEdit) {
@@ -452,10 +430,13 @@ export default function OfferDetail() {
}, []);
const isInvalidated = offerStatus === "invalidated";
const isCompleted = orderInfo?.status === "dokoncena";
const isLockedByOther = !!lockedBy;
const readOnly = isInvalidated || isLockedByOther || isCompleted;
const isExpiredNotInvalidated =
isEdit &&
!isInvalidated &&
!isCompleted &&
!orderInfo &&
form.valid_until &&
new Date(form.valid_until) < new Date(new Date().toDateString());
@@ -464,28 +445,26 @@ export default function OfferDetail() {
const formInitializedRef = useRef(false);
useEffect(() => {
if (!offerQuery.data || formInitializedRef.current) return;
const d = offerQuery.data as Record<string, unknown>;
const d = offerQuery.data;
const formData: OfferForm = {
quotation_number: (d.quotation_number as string) || "",
project_code: (d.project_code as string) || "",
customer_id: (d.customer_id as number | null) ?? null,
customer_name: (d.customer_name as string) || "",
created_at: d.created_at ? String(d.created_at).substring(0, 10) : "",
valid_until: d.valid_until ? String(d.valid_until).substring(0, 10) : "",
currency:
(d.currency as string) || companySettings?.default_currency || "CZK",
language: (d.language as string) || "EN",
vat_rate:
(d.vat_rate as number) ?? companySettings?.default_vat_rate ?? 21,
quotation_number: d.quotation_number || "",
project_code: d.project_code || "",
customer_id: d.customer_id ?? null,
customer_name: d.customer_name || "",
created_at: d.created_at ? d.created_at.substring(0, 10) : "",
valid_until: d.valid_until ? d.valid_until.substring(0, 10) : "",
currency: d.currency || companySettings?.default_currency || "CZK",
language: d.language || "EN",
vat_rate: d.vat_rate ?? companySettings?.default_vat_rate ?? 21,
apply_vat: !!d.apply_vat,
exchange_rate: (d.exchange_rate as string) || "",
scope_title: (d.scope_title as string) || "",
scope_description: (d.scope_description as string) || "",
exchange_rate: d.exchange_rate || "",
scope_title: d.scope_title || "",
scope_description: d.scope_description || "",
};
setForm(formData);
const mappedItems =
Array.isArray(d.items) && d.items.length
? (d.items as any[]).map((it: any) => ({
? d.items.map((it) => ({
...it,
_key: `item-${++itemKeyCounter.current}`,
}))
@@ -493,7 +472,7 @@ export default function OfferDetail() {
setItems(mappedItems);
const mappedSections =
Array.isArray(d.sections) && d.sections.length
? (d.sections as any[]).map((s: any) => ({
? d.sections.map((s) => ({
title: s.title || "",
title_cz: s.title_cz || "",
content: s.content || "",
@@ -505,14 +484,15 @@ export default function OfferDetail() {
items: mappedItems,
sections: mappedSections,
});
setOfferStatus((d.status as string) || "");
setOrderInfo((d.order as OrderInfo) ?? null);
setLockedBy((d.locked_by as typeof lockedBy) ?? null);
setOfferStatus(d.status || "");
setOrderInfo(d.order ?? null);
setLockedBy(d.locked_by ?? null);
// Try to acquire lock if not locked by someone else and not invalidated
// Try to acquire lock if not locked by someone else, not invalidated, and not completed
if (
!d.locked_by &&
d.status !== "invalidated" &&
d.order?.status !== "dokoncena" &&
hasPermission("offers.edit")
) {
apiFetch(`${API_BASE}/offers/${id}/lock`, { method: "POST" }).catch(
@@ -533,10 +513,7 @@ export default function OfferDetail() {
// Sync next-number data to form (create mode)
useEffect(() => {
if (isEdit || !nextNumberData) return;
const num =
((nextNumberData as Record<string, unknown>).next_number as string) ||
((nextNumberData as Record<string, unknown>).number as string) ||
"";
const num = nextNumberData.next_number || nextNumberData.number || "";
if (num) {
setForm((prev) => ({ ...prev, quotation_number: num }));
}
@@ -544,7 +521,8 @@ export default function OfferDetail() {
// Heartbeat to keep lock alive + cleanup on unmount
useEffect(() => {
if (!isEdit || !id || isLockedByOther || isInvalidated) return;
if (!isEdit || !id || isLockedByOther || isInvalidated || isCompleted)
return;
heartbeatRef.current = setInterval(() => {
apiFetch(`${API_BASE}/offers/${id}/heartbeat`, { method: "POST" }).catch(
@@ -719,6 +697,10 @@ export default function OfferDetail() {
}
}
if (!isEdit && result.data?.id) {
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
navigate(`/offers/${result.data.id}`);
}
if (isEdit) {
@@ -730,6 +712,7 @@ export default function OfferDetail() {
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
}
} else {
alert.error(result.error || "Nepodařilo se uložit nabídku");
@@ -772,9 +755,10 @@ export default function OfferDetail() {
if (result.success) {
setShowOrderModal(false);
alert.success(result.message || "Objednávka byla vytvořena");
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
await queryClient.invalidateQueries({ queryKey: ["offers"] });
await queryClient.invalidateQueries({ queryKey: ["orders"] });
await queryClient.invalidateQueries({ queryKey: ["projects"] });
await queryClient.invalidateQueries({ queryKey: ["invoices"] });
navigate(`/orders/${result.data.order_id}`);
} else {
alert.error(result.error || "Nepodařilo se vytvořit objednávku");
@@ -800,6 +784,7 @@ export default function OfferDetail() {
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Nepodařilo se zneplatnit nabídku");
}
@@ -819,9 +804,10 @@ export default function OfferDetail() {
const result = await response.json();
if (result.success) {
alert.success(result.message || "Nabídka byla smazána");
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
await queryClient.invalidateQueries({ queryKey: ["offers"] });
await queryClient.invalidateQueries({ queryKey: ["orders"] });
await queryClient.invalidateQueries({ queryKey: ["projects"] });
await queryClient.invalidateQueries({ queryKey: ["invoices"] });
navigate("/offers");
} else {
alert.error(result.error || "Nepodařilo se smazat nabídku");
@@ -864,7 +850,7 @@ export default function OfferDetail() {
const getRequiredPerm = () => {
if (!isEdit) return "offers.create";
return isInvalidated ? "offers.view" : "offers.edit";
return isInvalidated || isCompleted ? "offers.view" : "offers.edit";
};
const requiredPerm = getRequiredPerm();
if (!hasPermission(requiredPerm)) return <Forbidden />;
@@ -915,6 +901,17 @@ export default function OfferDetail() {
Zneplatněna
</span>
)}
{isCompleted && (
<span
className="admin-badge admin-badge-success text-xs"
style={{
marginLeft: "0.75rem",
verticalAlign: "middle",
}}
>
Dokončeno
</span>
)}
</h1>
</div>
</div>
@@ -949,7 +946,7 @@ export default function OfferDetail() {
</button>
)}
{isEdit &&
!isInvalidated &&
!readOnly &&
hasPermission("orders.create") &&
!orderInfo && (
<button
@@ -979,7 +976,7 @@ export default function OfferDetail() {
Zneplatnit
</button>
)}
{!isInvalidated && !isLockedByOther && (
{!readOnly && (
<button
onClick={handleSave}
className="admin-btn admin-btn-primary"
@@ -1042,7 +1039,7 @@ export default function OfferDetail() {
{/* Quotation Form */}
<motion.div
className={`admin-editor-section${isInvalidated || isLockedByOther ? " offers-readonly" : ""}`}
className={`admin-editor-section${readOnly ? " offers-readonly" : ""}`}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
@@ -1069,14 +1066,14 @@ export default function OfferDetail() {
onChange={(e) => updateForm("project_code", e.target.value)}
className="admin-form-input"
placeholder="Volitelný kód projektu"
readOnly={isInvalidated || isLockedByOther}
readOnly={readOnly}
/>
</FormField>
<FormField label="Zákazník" error={errors.customer_id} required>
{form.customer_id ? (
<div className="admin-customer-selected">
<span>{form.customer_name}</span>
{!isInvalidated && !isLockedByOther && (
{!readOnly && (
<button
type="button"
onClick={clearCustomer}
@@ -1112,7 +1109,7 @@ export default function OfferDetail() {
onFocus={() => setShowCustomerDropdown(true)}
className="admin-form-input"
placeholder="Hledat zákazníka..."
readOnly={isInvalidated || isLockedByOther}
readOnly={readOnly}
/>
{showCustomerDropdown && !isInvalidated && (
<div className="admin-customer-dropdown">
@@ -1145,7 +1142,7 @@ export default function OfferDetail() {
error={errors.created_at}
required
>
{isInvalidated || isLockedByOther ? (
{readOnly ? (
<input
type="text"
value={form.created_at}
@@ -1168,7 +1165,7 @@ export default function OfferDetail() {
error={errors.valid_until}
required
>
{isInvalidated || isLockedByOther ? (
{readOnly ? (
<input
type="text"
value={form.valid_until}
@@ -1197,7 +1194,7 @@ export default function OfferDetail() {
value={form.currency}
onChange={(e) => updateForm("currency", e.target.value)}
className="admin-form-select"
disabled={isInvalidated || isLockedByOther}
disabled={readOnly}
>
{(
companySettings?.available_currencies || [
@@ -1218,7 +1215,7 @@ export default function OfferDetail() {
value={form.language}
onChange={(e) => updateForm("language", e.target.value)}
className="admin-form-select"
disabled={isInvalidated || isLockedByOther}
disabled={readOnly}
>
<option value="EN">English</option>
<option value="CZ">Čeština</option>
@@ -1235,7 +1232,7 @@ export default function OfferDetail() {
updateForm("vat_rate", parseFloat(e.target.value) || 0)
}
className="admin-form-select flex-1"
disabled={isInvalidated || isLockedByOther}
disabled={readOnly}
>
{(
companySettings?.available_vat_rates || [
@@ -1254,7 +1251,7 @@ export default function OfferDetail() {
onChange={(e) =>
updateForm("apply_vat", e.target.checked)
}
disabled={isInvalidated || isLockedByOther}
disabled={readOnly}
/>
<span>Účtovat DPH</span>
</label>
@@ -1268,7 +1265,7 @@ export default function OfferDetail() {
className="admin-form-input"
placeholder="Volitelný"
step="0.0001"
readOnly={isInvalidated || isLockedByOther}
readOnly={readOnly}
/>
</FormField>
</div>
@@ -1285,7 +1282,7 @@ export default function OfferDetail() {
<div className="admin-card-body">
<div className="admin-card-header flex-between">
<h3 className="admin-card-title">Položky</h3>
{!isInvalidated && !isLockedByOther && (
{!readOnly && (
<button
onClick={addItem}
className="admin-btn admin-btn-secondary admin-btn-sm"
@@ -1327,9 +1324,7 @@ export default function OfferDetail() {
<table className="admin-table">
<thead>
<tr>
{!isInvalidated && !isLockedByOther && (
<th style={{ width: "2rem" }} />
)}
{!readOnly && <th style={{ width: "2rem" }} />}
<th style={{ width: "2.5rem", textAlign: "center" }}>
#
</th>
@@ -1364,7 +1359,7 @@ export default function OfferDetail() {
>
Celkem
</th>
{!isInvalidated && !isLockedByOther && (
{!readOnly && (
<th
className="offers-col-del"
style={{ width: "3rem" }}
@@ -1379,7 +1374,7 @@ export default function OfferDetail() {
item={item}
index={index}
currency={form.currency}
readOnly={isInvalidated || isLockedByOther}
readOnly={readOnly}
canDelete={items.length > 1}
onUpdate={(field, value) =>
updateItem(index, field, value)
@@ -1423,7 +1418,7 @@ export default function OfferDetail() {
<div className="admin-card-body">
<div className="admin-card-header flex-between">
<h3 className="admin-card-title">Rozsah projektu</h3>
{!isInvalidated && !isLockedByOther && (
{!readOnly && (
<div
style={{
display: "flex",
@@ -1444,7 +1439,7 @@ export default function OfferDetail() {
);
if (template?.scope_template_sections?.length) {
const newSections =
template.scope_template_sections.map((s: any) => ({
template.scope_template_sections.map((s) => ({
title: s.title || "",
title_cz: s.title_cz || "",
content: s.content || "",
@@ -1537,7 +1532,7 @@ export default function OfferDetail() {
</span>
)}
</span>
{!isInvalidated && !isLockedByOther && (
{!readOnly && (
<div style={{ display: "flex", gap: "0.25rem" }}>
{idx > 0 && (
<button
@@ -1639,7 +1634,7 @@ export default function OfferDetail() {
}
className="admin-form-input"
placeholder="Název sekce (anglicky)"
readOnly={isInvalidated || isLockedByOther}
readOnly={readOnly}
/>
</FormField>
<FormField
@@ -1666,7 +1661,7 @@ export default function OfferDetail() {
}
className="admin-form-input"
placeholder="Název sekce (česky)"
readOnly={isInvalidated || isLockedByOther}
readOnly={readOnly}
/>
</FormField>
</div>
@@ -1684,7 +1679,7 @@ export default function OfferDetail() {
}
placeholder="Obsah sekce..."
minHeight="120px"
readOnly={isInvalidated || isLockedByOther}
readOnly={readOnly}
/>
</div>
</div>

View File

@@ -33,6 +33,7 @@ interface Quotation {
total: number;
status: string;
order_id?: number;
order_status?: string;
}
interface Draft {
@@ -118,8 +119,13 @@ export default function Offers() {
setDraft(null);
};
const getRowClass = (invalidated: boolean, expired: boolean) => {
const getRowClass = (
invalidated: boolean,
expired: boolean,
completed: boolean,
) => {
if (invalidated) return "offers-invalidated-row";
if (completed) return "offers-completed-row";
if (expired) return "offers-expired-row";
return "";
};
@@ -140,6 +146,9 @@ export default function Offers() {
if (result.success) {
alert.success(result.message || "Nabídka byla duplikována");
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Nepodařilo se duplikovat nabídku");
}
@@ -168,10 +177,11 @@ export default function Offers() {
if (result.success) {
setOrderModal({ show: false, quotation: null });
alert.success(result.message || "Objednávka byla vytvořena");
navigate(`/orders/${result.data.order_id}`);
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
navigate(`/orders/${result.data.order_id}`);
} else {
alert.error(result.error || "Nepodařilo se vytvořit objednávku");
}
@@ -199,6 +209,7 @@ export default function Offers() {
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Nepodařilo se smazat nabídku");
}
@@ -226,6 +237,7 @@ export default function Offers() {
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Nepodařilo se zneplatnit nabídku");
}
@@ -289,7 +301,7 @@ export default function Offers() {
</p>
</div>
<div className="admin-page-actions">
{hasPermission("settings.manage") && (
{hasPermission("settings.templates") && (
<Link
to="/offers/templates"
className="admin-btn admin-btn-secondary"
@@ -520,16 +532,24 @@ export default function Offers() {
)}
{(quotations as Quotation[]).map((q) => {
const isInvalidated = q.status === "invalidated";
const isCompleted =
!isInvalidated && q.order_status === "dokoncena";
const isExpired =
!isInvalidated &&
!isCompleted &&
!q.order_id &&
q.valid_until &&
new Date(q.valid_until) <
new Date(new Date().toDateString());
const readOnly = isInvalidated || isCompleted;
return (
<tr
key={q.id}
className={getRowClass(isInvalidated, !!isExpired)}
className={getRowClass(
isInvalidated,
!!isExpired,
isCompleted,
)}
>
<td>
<Link
@@ -560,12 +580,10 @@ export default function Offers() {
<Link
to={`/offers/${q.id}`}
className="admin-btn-icon"
title={isInvalidated ? "Zobrazit" : "Upravit"}
aria-label={
isInvalidated ? "Zobrazit" : "Upravit"
}
title={readOnly ? "Zobrazit" : "Upravit"}
aria-label={readOnly ? "Zobrazit" : "Upravit"}
>
{isInvalidated ? (
{readOnly ? (
<svg
width="18"
height="18"
@@ -591,35 +609,34 @@ export default function Offers() {
</svg>
)}
</Link>
{!isInvalidated &&
hasPermission("offers.create") && (
<button
onClick={() => handleDuplicate(q)}
className="admin-btn-icon"
title="Duplikovat"
disabled={duplicating === q.id}
{!readOnly && hasPermission("offers.create") && (
<button
onClick={() => handleDuplicate(q)}
className="admin-btn-icon"
title="Duplikovat"
disabled={duplicating === q.id}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect
x="9"
y="9"
width="13"
height="13"
rx="2"
ry="2"
/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
</button>
)}
{!isInvalidated && q.order_id ? (
<rect
x="9"
y="9"
width="13"
height="13"
rx="2"
ry="2"
/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
</button>
)}
{!readOnly && q.order_id ? (
<Link
to={`/orders/${q.order_id}`}
className="admin-btn-icon accent"
@@ -650,7 +667,7 @@ export default function Offers() {
</svg>
</Link>
) : (
!isInvalidated &&
!readOnly &&
hasPermission("orders.create") && (
<button
onClick={() => {

View File

@@ -7,7 +7,11 @@ import ConfirmModal from "../components/ConfirmModal";
import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden";
import useModalLock from "../hooks/useModalLock";
import { offerCustomersOptions } from "../lib/queries/offers";
import {
offerCustomersOptions,
type Customer,
type CustomField,
} from "../lib/queries/offers";
import { Skeleton } from "boneyard-js/react";
import OffersCustomersFixture from "../fixtures/OffersCustomersFixture";
@@ -31,27 +35,6 @@ const CUSTOMER_FIELD_LABELS: Record<string, string> = {
vat_id: "DIČ",
};
interface Customer {
id: number;
name: string;
street?: string;
city?: string;
postal_code?: string;
country?: string;
company_id?: string;
vat_id?: string;
quotation_count: number;
custom_fields?: CustomField[];
customer_field_order?: string[];
}
interface CustomField {
name: string;
value: string;
showLabel: boolean;
_key?: string;
}
interface CustomerForm {
name: string;
street: string;
@@ -66,9 +49,7 @@ export default function OffersCustomers() {
const alert = useAlert();
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const { data: customers = [], isPending } = useQuery(
offerCustomersOptions(),
) as { data: Customer[]; isPending: boolean };
const { data: customers = [], isPending } = useQuery(offerCustomersOptions());
const [search, setSearch] = useState("");
const [showModal, setShowModal] = useState(false);
@@ -232,6 +213,7 @@ export default function OffersCustomers() {
: "Zákazník byl vytvořen"),
);
queryClient.invalidateQueries({ queryKey: ["offer-customers"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
} else {
alert.error(result.error || "Nepodařilo se uložit zákazníka");
}
@@ -260,6 +242,7 @@ export default function OffersCustomers() {
setDeleteConfirm({ show: false, customer: null });
alert.success(result.message || "Zákazník byl smazán");
queryClient.invalidateQueries({ queryKey: ["offer-customers"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
} else {
alert.error(result.error || "Nepodařilo se smazat zákazníka");
}
@@ -270,7 +253,7 @@ export default function OffersCustomers() {
}
};
if (!hasPermission("offers.view")) return <Forbidden />;
if (!hasPermission("customers.view")) return <Forbidden />;
const filteredCustomers = search
? customers.filter(
@@ -300,7 +283,7 @@ export default function OffersCustomers() {
<h1 className="admin-page-title">Zákazníci</h1>
<p className="admin-page-subtitle">Správa zákazníků pro nabídky</p>
</div>
{hasPermission("offers.create") && (
{hasPermission("customers.create") && (
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
@@ -345,7 +328,7 @@ export default function OffersCustomers() {
? "Žádní zákazníci odpovídající hledání."
: "Zatím nejsou žádní zákazníci."}
</p>
{!search && hasPermission("offers.create") && (
{!search && hasPermission("customers.create") && (
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
@@ -398,7 +381,7 @@ export default function OffersCustomers() {
</td>
<td>
<div className="admin-table-actions">
{hasPermission("offers.edit") && (
{hasPermission("customers.edit") && (
<button
onClick={() => openEditModal(customer)}
className="admin-btn-icon"
@@ -418,7 +401,7 @@ export default function OffersCustomers() {
</svg>
</button>
)}
{hasPermission("offers.delete") && (
{hasPermission("customers.delete") && (
<button
onClick={() =>
setDeleteConfirm({ show: true, customer })

View File

@@ -8,7 +8,13 @@ import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden";
import RichEditor from "../components/RichEditor";
import useModalLock from "../hooks/useModalLock";
import { offerTemplatesOptions } from "../lib/queries/offers";
import {
itemTemplatesOptions,
scopeTemplatesOptions,
type ItemTemplate,
type ScopeTemplate,
type ScopeSection,
} from "../lib/queries/offers";
import { Skeleton } from "boneyard-js/react";
import OffersTemplatesFixture from "../fixtures/OffersTemplatesFixture";
@@ -16,27 +22,6 @@ import apiFetch from "../utils/api";
const API_BASE = "/api/admin";
interface ItemTemplate {
id: number;
name: string;
description: string;
default_price: number;
category: string;
}
interface ScopeSection {
_key: string;
title: string;
title_cz: string;
content: string;
}
interface ScopeTemplate {
id: number;
name: string;
sections?: ScopeSection[];
}
interface ItemForm {
name: string;
description: string;
@@ -53,7 +38,7 @@ export default function OffersTemplates() {
const { hasPermission } = useAuth();
const [activeTab, setActiveTab] = useState<"items" | "scopes">("items");
if (!hasPermission("settings.manage")) return <Forbidden />;
if (!hasPermission("settings.templates")) return <Forbidden />;
return (
<div>
@@ -96,9 +81,7 @@ export default function OffersTemplates() {
function ItemTemplatesTab() {
const alert = useAlert();
const queryClient = useQueryClient();
const { data: templates = [], isPending } = useQuery(
offerTemplatesOptions("items"),
) as { data: ItemTemplate[]; isPending: boolean };
const { data: templates = [], isPending } = useQuery(itemTemplatesOptions());
const [showModal, setShowModal] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<ItemTemplate | null>(
null,
@@ -157,6 +140,7 @@ function ItemTemplatesTab() {
await new Promise((r) => setTimeout(r, 300));
alert.success(result.message);
queryClient.invalidateQueries({ queryKey: ["offer-templates"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
} else {
alert.error(result.error);
}
@@ -180,6 +164,7 @@ function ItemTemplatesTab() {
setDeleteConfirm({ show: false, template: null });
alert.success(result.message);
queryClient.invalidateQueries({ queryKey: ["offer-templates"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
} else {
alert.error(result.error);
}
@@ -365,7 +350,7 @@ function ItemTemplatesTab() {
onChange={(e) =>
setForm((p) => ({
...p,
default_price: e.target.value,
default_price: parseFloat(e.target.value),
}))
}
className="admin-form-input"
@@ -435,9 +420,7 @@ function ItemTemplatesTab() {
function ScopeTemplatesTab() {
const alert = useAlert();
const queryClient = useQueryClient();
const { data: templates = [], isPending } = useQuery(
offerTemplatesOptions(),
) as { data: ScopeTemplate[]; isPending: boolean };
const { data: templates = [], isPending } = useQuery(scopeTemplatesOptions());
const [showModal, setShowModal] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<ScopeTemplate | null>(
null,
@@ -574,6 +557,7 @@ function ScopeTemplatesTab() {
await new Promise((r) => setTimeout(r, 300));
alert.success(result.message);
queryClient.invalidateQueries({ queryKey: ["offer-templates"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
} else {
alert.error(result.error);
}
@@ -597,6 +581,7 @@ function ScopeTemplatesTab() {
setDeleteConfirm({ show: false, template: null });
alert.success(result.message);
queryClient.invalidateQueries({ queryKey: ["offer-templates"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
} else {
alert.error(result.error);
}

View File

@@ -1,7 +1,14 @@
import { useState, useEffect, useMemo, useRef, type ReactNode } from "react";
import DOMPurify from "dompurify";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { orderDetailOptions } from "../lib/queries/orders";
import {
orderDetailOptions,
type OrderData,
type OrderItem,
type OrderSection,
type OrderInvoice,
type OrderProject,
} from "../lib/queries/orders";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { useParams, useNavigate, Link } from "react-router-dom";
@@ -42,60 +49,6 @@ const TRANSITION_CLASSES: Record<string, string> = {
dokoncena: "admin-btn admin-btn-primary",
};
interface OrderItem {
id?: number;
description: string;
item_description?: string;
quantity: number;
unit: string;
unit_price: number;
is_included_in_total: number | boolean;
}
interface OrderSection {
id?: number;
title: string;
title_cz?: string;
content: string;
}
interface Invoice {
id: number;
invoice_number: string;
}
interface Project {
id: number;
project_number: string;
name: string;
has_nas_folder?: boolean;
}
interface OrderData {
id: number;
order_number: string;
quotation_id: number;
quotation_number: string;
project_code?: string;
customer_name: string;
customer_order_number: string;
currency: string;
created_at: string;
status: string;
notes: string;
attachment_name?: string;
apply_vat: number | boolean;
vat_rate: number;
language?: string;
items: OrderItem[];
sections: OrderSection[];
scope_title?: string;
scope_description?: string;
valid_transitions?: string[];
invoice?: Invoice;
project?: Project;
}
export default function OrderDetail() {
const { id } = useParams();
const alert = useAlert();
@@ -104,7 +57,7 @@ export default function OrderDetail() {
const queryClient = useQueryClient();
const orderQuery = useQuery(orderDetailOptions(id));
const order = orderQuery.data as OrderData | undefined;
const order = orderQuery.data;
const loading = orderQuery.isPending;
const [notes, setNotes] = useState("");
@@ -132,7 +85,7 @@ export default function OrderDetail() {
// Sync order data to local form state on first load
useEffect(() => {
if (orderQuery.data && !formInitializedRef.current) {
const orderData = orderQuery.data as OrderData;
const orderData = orderQuery.data!;
setNotes(orderData.notes || "");
initialNotesRef.current = orderData.notes || "";
formInitializedRef.current = true;
@@ -199,9 +152,10 @@ export default function OrderDetail() {
const result = await response.json();
if (result.success) {
alert.success(result.message || "Stav byl změněn");
queryClient.invalidateQueries({ queryKey: ["orders", id] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Nepodařilo se změnit stav");
}
@@ -224,9 +178,10 @@ export default function OrderDetail() {
if (result.success) {
alert.success("Poznámky byly uloženy");
initialNotesRef.current = notes;
queryClient.invalidateQueries({ queryKey: ["orders", id] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Nepodařilo se uložit poznámky");
}
@@ -315,9 +270,10 @@ export default function OrderDetail() {
const result = await response.json();
if (result.success) {
alert.success(result.message || "Objednávka byla smazána");
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
await queryClient.invalidateQueries({ queryKey: ["orders"] });
await queryClient.invalidateQueries({ queryKey: ["offers"] });
await queryClient.invalidateQueries({ queryKey: ["projects"] });
await queryClient.invalidateQueries({ queryKey: ["invoices"] });
navigate("/orders");
} else {
alert.error(result.error || "Nepodařilo se smazat objednávku");
@@ -416,24 +372,26 @@ export default function OrderDetail() {
</Link>
)
)}
<button
onClick={() => setShowConfirmationModal(true)}
className="admin-btn admin-btn-secondary"
disabled={confirmationLoading}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
{hasPermission("orders.export") && (
<button
onClick={() => setShowConfirmationModal(true)}
className="admin-btn admin-btn-secondary"
disabled={confirmationLoading}
>
<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>
Potvrzení objednávky
</button>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
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>
Potvrzení objednávky
</button>
)}
{hasPermission("orders.edit") &&
order.valid_transitions?.filter((s) => s !== "stornovana")
.length! > 0 &&

View File

@@ -91,6 +91,7 @@ export default function Orders() {
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Nepodařilo se smazat objednávku");
}

View File

@@ -5,8 +5,12 @@ import { useAuth } from "../context/AuthContext";
import { useParams, useNavigate, Link } from "react-router-dom";
import { motion } from "framer-motion";
import { projectDetailOptions } from "../lib/queries/projects";
import { userListOptions } from "../lib/queries/users";
import {
projectDetailOptions,
type ProjectData,
type ProjectNote,
} from "../lib/queries/projects";
import { userListOptions, type User as ApiUser } from "../lib/queries/users";
import Forbidden from "../components/Forbidden";
import { Skeleton } from "boneyard-js/react";
import ProjectDetailFixture from "../fixtures/ProjectDetailFixture";
@@ -35,36 +39,11 @@ function formatNoteDate(dateStr: string) {
return `${day}. ${month}. ${year} ${hours}:${mins}`;
}
interface Note {
id: number;
content: string;
user_name: string;
created_at: string;
}
interface User {
id: number;
name: string;
}
interface ProjectData {
id: number;
project_number: string;
name: string;
status: string;
start_date: string;
end_date: string;
customer_name: string;
responsible_user_id: string;
notes?: string;
order_id?: number;
order_number?: string;
order_status?: string;
quotation_id?: number;
quotation_number?: string;
has_nas_folder?: boolean;
}
interface ProjectForm {
name: string;
status: string;
@@ -99,22 +78,15 @@ export default function ProjectDetail() {
const queryClient = useQueryClient();
const projectQuery = useQuery(projectDetailOptions(id));
const project = projectQuery.data as
| (ProjectData & { project_notes?: Note[] })
| undefined;
const project = projectQuery.data;
const isPending = projectQuery.isPending;
const notes: Note[] = project?.project_notes || [];
const notes: ProjectNote[] = project?.project_notes || [];
const { data: usersData } = useQuery(userListOptions());
const rawUsers = ((usersData as Record<string, unknown>)?.items ??
usersData ??
[]) as any[];
const users: User[] = Array.isArray(rawUsers)
? rawUsers.map((u: any) => ({
id: u.id,
name: `${u.first_name || ""} ${u.last_name || ""}`.trim() || u.username,
}))
: [];
const { data: usersData } = useQuery(userListOptions("projects.view"));
const users: User[] = (usersData ?? []).map((u: ApiUser) => ({
id: u.id,
name: `${u.first_name || ""} ${u.last_name || ""}`.trim() || u.username,
}));
// Reset form sync when navigating to a different project
const formInitialized = useRef(false);
@@ -174,6 +146,8 @@ export default function ProjectDetail() {
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
} else {
alert.error(result.error || "Nepodařilo se uložit projekt");
}
@@ -197,6 +171,8 @@ export default function ProjectDetail() {
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
navigate("/projects");
setTimeout(() => alert.success("Projekt byl smazán"), 300);
} else {
@@ -223,7 +199,11 @@ export default function ProjectDetail() {
if (result.success) {
setNewNote("");
alert.success("Poznámka byla přidána");
queryClient.invalidateQueries({ queryKey: ["projects", id] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
} else {
alert.error(result.error || "Nepodařilo se přidat poznámku");
}
@@ -246,7 +226,11 @@ export default function ProjectDetail() {
const result = await response.json();
if (result.success) {
alert.success("Poznámka byla smazána");
queryClient.invalidateQueries({ queryKey: ["projects", id] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
} else {
alert.error(result.error || "Nepodařilo se smazat poznámku");
}

View File

@@ -83,6 +83,8 @@ export default function Projects() {
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
} else {
alert.error(data.error || "Nepodařilo se smazat projekt");
}
@@ -162,8 +164,7 @@ export default function Projects() {
fontSize: "0.875rem",
}}
>
Vytvořte první projekt tlačítkem výše nebo automaticky při
vytvoření objednávky.
Projekt se vytvoří automaticky při vytvoření objednávky.
</p>
</div>
) : (
@@ -291,7 +292,7 @@ export default function Projects() {
</svg>
</Link>
{!p.order_id &&
hasPermission("projects.create") && (
hasPermission("projects.delete") && (
<button
onClick={() => setDeleteTarget(p)}
className="admin-btn-icon danger"

View File

@@ -11,11 +11,17 @@ import SortIcon from "../components/SortIcon";
import useTableSort from "../hooks/useTableSort";
import useModalLock from "../hooks/useModalLock";
import AdminDatePicker from "../components/AdminDatePicker";
import { companySettingsOptions } from "../lib/queries/settings";
import {
companySettingsOptions,
type CompanySettingsData,
} from "../lib/queries/settings";
import { supplierListOptions } from "../lib/queries/common";
import {
receivedInvoiceListOptions,
receivedInvoiceStatsOptions,
type ReceivedInvoice,
type ReceivedStats,
type CurrencyAmount,
} from "../lib/queries/invoices";
import { Skeleton } from "boneyard-js/react";
import ReceivedInvoicesFixture from "../fixtures/ReceivedInvoicesFixture";
@@ -48,37 +54,6 @@ const MONTH_NAMES = [
"prosinec",
];
interface CurrencyAmount {
amount: number;
currency: string;
}
interface ReceivedInvoice {
id: number;
supplier_name: string;
invoice_number: string;
amount: number;
currency: string;
vat_rate: number;
issue_date: string;
due_date: string;
notes: string;
status: string;
file_name?: string;
created_at: string;
}
interface ReceivedStats {
total_month: CurrencyAmount[];
total_month_czk: number | null;
vat_month: CurrencyAmount[];
vat_month_czk: number | null;
unpaid: CurrencyAmount[];
unpaid_czk: number | null;
unpaid_count: number;
month_count: number;
}
interface UploadMeta {
supplier_name: string;
invoice_number: string;
@@ -133,14 +108,9 @@ function formatCzkWithDetail(
return { value: formatMultiCurrency(amounts), detail: null };
}
interface CompanySettings {
default_currency: string;
default_vat_rate: number;
available_currencies: string[];
available_vat_rates: number[];
}
function emptyMeta(settings: CompanySettings | null | undefined): UploadMeta {
function emptyMeta(
settings: CompanySettingsData | null | undefined,
): UploadMeta {
return {
supplier_name: "",
invoice_number: "",
@@ -182,9 +152,7 @@ export default function ReceivedInvoices({
const prevYear = useRef(statsYear);
const { data: supplierNames = [] } = useQuery(supplierListOptions());
const companySettings = useQuery(companySettingsOptions()).data as unknown as
| CompanySettings
| undefined;
const companySettings = useQuery(companySettingsOptions()).data;
// List query — auto-refetches when filters change
const listQuery = useQuery(
@@ -203,11 +171,11 @@ export default function ReceivedInvoices({
);
// Derive list data from query (paginatedJsonQuery returns { data, pagination })
const invoices = (listQuery.data?.data ?? []) as ReceivedInvoice[];
const invoices = listQuery.data?.data ?? [];
if (listQuery.data || statsQuery.data) hasLoadedOnce.current = true;
// Derive stats from query
const stats = (statsQuery.data as unknown as ReceivedStats) ?? null;
const stats = statsQuery.data ?? null;
// Trigger slide animation when stats data changes
useEffect(() => {
@@ -345,8 +313,10 @@ export default function ReceivedInvoices({
setUploadMeta([]);
setUploadErrors({});
queryClient.invalidateQueries({
queryKey: ["invoices", "received"],
queryKey: ["invoices"],
});
queryClient.invalidateQueries({ queryKey: ["suppliers"] });
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
} else {
alert.error(data.error || "Chyba při nahrávání");
}
@@ -416,8 +386,10 @@ export default function ReceivedInvoices({
setEditOpen(false);
setEditInvoice(null);
queryClient.invalidateQueries({
queryKey: ["invoices", "received"],
queryKey: ["invoices"],
});
queryClient.invalidateQueries({ queryKey: ["suppliers"] });
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
} else {
alert.error(data.error || "Chyba při ukládání");
}
@@ -445,8 +417,10 @@ export default function ReceivedInvoices({
alert.success(data.message || "Faktura byla smazána");
setDeleteConfirm({ show: false, invoice: null });
queryClient.invalidateQueries({
queryKey: ["invoices", "received"],
queryKey: ["invoices"],
});
queryClient.invalidateQueries({ queryKey: ["suppliers"] });
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
} else {
alert.error(data.error || "Chyba při mazání");
}
@@ -491,8 +465,10 @@ export default function ReceivedInvoices({
if (data.success) {
alert.success("Faktura označena jako uhrazená");
queryClient.invalidateQueries({
queryKey: ["invoices", "received"],
queryKey: ["invoices"],
});
queryClient.invalidateQueries({ queryKey: ["suppliers"] });
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
} else {
alert.error(data.error || "Nepodařilo se změnit stav");
}

View File

@@ -47,10 +47,12 @@ interface SystemSettingsData {
const MODULE_LABELS: Record<string, string> = {
attendance: "Docházka",
trips: "Kniha jízd",
vehicles: "Vozidla",
offers: "Nabídky",
orders: "Objednávky",
projects: "Projekty",
invoices: "Faktury",
customers: "Zákazníci",
users: "Uživatelé",
settings: "Nastavení",
};
@@ -107,11 +109,11 @@ export default function Settings() {
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const canManage = hasPermission("settings.manage");
const canManage = hasPermission("settings.roles");
// ── TanStack Query: roles, permissions, users ──
const { data: rolesData, isPending: rolesLoading } = useQuery({
queryKey: ["roles"],
queryKey: ["settings", "roles"],
queryFn: async () => {
const [rolesRes, permsRes, usersRes] = await Promise.all([
apiFetch(`${API_BASE}/roles`),
@@ -205,39 +207,39 @@ export default function Settings() {
// ── Populate sysForm from query data ──
useEffect(() => {
if (!sysSettingsData || sysFormInitialized) return;
const d = sysSettingsData as Record<string, unknown>;
setSysForm({
break_threshold_hours: (d.break_threshold_hours as number) ?? 6,
break_duration_short: (d.break_duration_short as number) ?? 15,
break_duration_long: (d.break_duration_long as number) ?? 30,
clock_rounding_minutes: (d.clock_rounding_minutes as number) ?? 15,
invoice_alert_email: (d.invoice_alert_email as string) || "",
leave_notify_email: (d.leave_notify_email as string) || "",
smtp_from: (d.smtp_from as string) || "",
smtp_from_name: (d.smtp_from_name as string) || "",
max_login_attempts: (d.max_login_attempts as number) ?? 5,
lockout_minutes: (d.lockout_minutes as number) ?? 15,
max_requests_per_minute: (d.max_requests_per_minute as number) ?? 300,
default_currency: (d.default_currency as string) || "CZK",
default_vat_rate: (d.default_vat_rate as number) ?? 21,
break_threshold_hours: sysSettingsData.break_threshold_hours ?? 6,
break_duration_short: sysSettingsData.break_duration_short ?? 15,
break_duration_long: sysSettingsData.break_duration_long ?? 30,
clock_rounding_minutes: sysSettingsData.clock_rounding_minutes ?? 15,
invoice_alert_email: sysSettingsData.invoice_alert_email || "",
leave_notify_email: sysSettingsData.leave_notify_email || "",
smtp_from: sysSettingsData.smtp_from || "",
smtp_from_name: sysSettingsData.smtp_from_name || "",
max_login_attempts: sysSettingsData.max_login_attempts ?? 5,
lockout_minutes: sysSettingsData.lockout_minutes ?? 15,
max_requests_per_minute: sysSettingsData.max_requests_per_minute ?? 300,
default_currency: sysSettingsData.default_currency || "CZK",
default_vat_rate: sysSettingsData.default_vat_rate ?? 21,
available_vat_rates:
Array.isArray(d.available_vat_rates) && d.available_vat_rates.length > 0
? (d.available_vat_rates as number[])
Array.isArray(sysSettingsData.available_vat_rates) &&
sysSettingsData.available_vat_rates.length > 0
? sysSettingsData.available_vat_rates
: [0, 10, 12, 15, 21],
available_currencies:
Array.isArray(d.available_currencies) &&
d.available_currencies.length > 0
? (d.available_currencies as string[])
Array.isArray(sysSettingsData.available_currencies) &&
sysSettingsData.available_currencies.length > 0
? sysSettingsData.available_currencies
: ["CZK", "EUR", "USD", "GBP"],
quotation_prefix: (d.quotation_prefix as string) || "NA",
order_type_code: (d.order_type_code as string) || "71",
invoice_type_code: (d.invoice_type_code as string) || "81",
quotation_prefix: sysSettingsData.quotation_prefix || "NA",
order_type_code: sysSettingsData.order_type_code || "71",
invoice_type_code: sysSettingsData.invoice_type_code || "81",
offer_number_pattern:
(d.offer_number_pattern as string) || "{YYYY}/{PREFIX}/{NNN}",
sysSettingsData.offer_number_pattern || "{YYYY}/{PREFIX}/{NNN}",
order_number_pattern:
(d.order_number_pattern as string) || "{YY}{CODE}{NNNN}",
sysSettingsData.order_number_pattern || "{YY}{CODE}{NNNN}",
invoice_number_pattern:
(d.invoice_number_pattern as string) || "{YY}{CODE}{NNNN}",
sysSettingsData.invoice_number_pattern || "{YY}{CODE}{NNNN}",
});
setSysFormInitialized(true);
}, [sysSettingsData, sysFormInitialized]);
@@ -261,6 +263,11 @@ export default function Settings() {
if (result.success) {
alert.success(result.message || "Systémová nastavení byla uložena");
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["leave"] });
} else {
alert.error(result.error || "Nepodařilo se uložit nastavení");
}
@@ -282,7 +289,9 @@ export default function Settings() {
const result = await response.json();
if (result.success) {
alert.success(result.message || "2FA nastavení uloženo");
queryClient.invalidateQueries({ queryKey: ["settings", "2fa"] });
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
queryClient.invalidateQueries({ queryKey: ["users"] });
} else {
alert.error(result.error || "Nepodařilo se uložit nastavení");
}
@@ -401,7 +410,9 @@ export default function Settings() {
result.message ||
(editingRole ? "Role byla aktualizována" : "Role byla vytvořena"),
);
queryClient.invalidateQueries({ queryKey: ["settings", "roles"] });
queryClient.invalidateQueries({ queryKey: ["roles"] });
queryClient.invalidateQueries({ queryKey: ["users"] });
} else {
alert.error(result.error || "Nepodařilo se uložit roli");
}
@@ -429,7 +440,9 @@ export default function Settings() {
if (result.success) {
setDeleteConfirm({ show: false, role: null });
alert.success(result.message || "Role byla smazána");
queryClient.invalidateQueries({ queryKey: ["settings", "roles"] });
queryClient.invalidateQueries({ queryKey: ["roles"] });
queryClient.invalidateQueries({ queryKey: ["users"] });
} else {
alert.error(result.error || "Nepodařilo se smazat roli");
}

View File

@@ -13,32 +13,16 @@ import Forbidden from "../components/Forbidden";
import { formatDate } from "../utils/attendanceHelpers";
import { formatKm } from "../utils/formatters";
import apiFetch from "../utils/api";
import { tripListOptions, tripVehiclesOptions } from "../lib/queries/trips";
import {
tripListOptions,
tripVehiclesOptions,
type BackendTrip,
type TripVehicle,
} from "../lib/queries/trips";
import { Skeleton } from "boneyard-js/react";
import TripsFixture from "../fixtures/TripsFixture";
const API_BASE = "/api/admin";
interface Vehicle {
id: number | string;
spz: string;
name: string;
}
interface Trip {
id: number;
vehicle_id: number | string;
trip_date: string;
start_km: number;
end_km: number;
distance?: number | null;
route_from: string;
route_to: string;
is_business: boolean;
notes?: string | null;
users?: { id: number; first_name: string; last_name: string };
vehicles?: { id: number; name: string; spz: string };
}
interface TripForm {
vehicle_id: string;
trip_date: string;
@@ -60,15 +44,12 @@ export default function Trips() {
);
const { data: vehiclesData } = useQuery(tripVehiclesOptions());
const trips = (tripsData ?? []) as Record<string, unknown>[] as Trip[];
const vehicles = (vehiclesData ?? []) as Record<
string,
unknown
>[] as Vehicle[];
const trips = tripsData ?? [];
const vehicles = vehiclesData ?? [];
const [submitting, setSubmitting] = useState(false);
const [showModal, setShowModal] = useState(false);
const [editingTrip, setEditingTrip] = useState<Trip | null>(null);
const [editingTrip, setEditingTrip] = useState<BackendTrip | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
tripId: number | null;
@@ -130,7 +111,7 @@ export default function Trips() {
setShowModal(true);
};
const openEditModal = (trip: Trip) => {
const openEditModal = (trip: BackendTrip) => {
setEditingTrip(trip);
setForm({
vehicle_id: String(trip.vehicle_id),

View File

@@ -10,8 +10,14 @@ import {
tripListOptions,
tripVehiclesOptions,
tripUsersOptions,
type BackendTrip,
type TripVehicle,
type TripUser,
} from "../lib/queries/trips";
import { companySettingsOptions } from "../lib/queries/settings";
import {
companySettingsOptions,
type CompanySettingsData,
} from "../lib/queries/settings";
import AdminDatePicker from "../components/AdminDatePicker";
import FormField from "../components/FormField";
@@ -23,17 +29,6 @@ import { Skeleton } from "boneyard-js/react";
import TripsAdminFixture from "../fixtures/TripsAdminFixture";
const API_BASE = "/api/admin";
interface Vehicle {
id: number | string;
spz: string;
name: string;
}
interface UserShort {
id: number | string;
name: string;
}
interface Trip {
id: number;
vehicle_id: number | string;
@@ -49,22 +44,6 @@ interface Trip {
driver_name: string;
}
interface BackendTrip {
id: number;
vehicle_id: number;
user_id: number;
trip_date: string;
start_km: number;
end_km: number;
distance: number | null;
route_from: string;
route_to: string;
is_business: boolean;
notes: string | null;
users: { id: number; first_name: string; last_name: string };
vehicles: { id: number; name: string; spz: string };
}
interface EditForm {
vehicle_id: string;
trip_date: string;
@@ -127,15 +106,13 @@ export default function TripsAdmin() {
}>({ show: false, trip: null });
const { data: vehiclesData = [] } = useQuery(tripVehiclesOptions());
const vehicles = vehiclesData as Vehicle[];
const vehicles = vehiclesData;
const { data: tripUsersData = [] } = useQuery(tripUsersOptions());
const tripUsers = tripUsersData as UserShort[];
const tripUsers = tripUsersData;
const { data: companySettings } = useQuery(companySettingsOptions());
const companyName =
((companySettings as Record<string, unknown> | undefined)
?.company_name as string) ?? "";
const companyName = companySettings?.company_name ?? "";
const { data: tripsData, isPending } = useQuery(
tripListOptions({
@@ -146,11 +123,11 @@ export default function TripsAdmin() {
perPage: 100,
}),
);
const trips = ((tripsData ?? []) as BackendTrip[]).map(mapTrip);
const trips = (tripsData ?? []).map(mapTrip);
useModalLock(showEditModal);
if (!hasPermission("trips.admin")) return <Forbidden />;
if (!hasPermission("trips.manage")) return <Forbidden />;
const openEditModal = (trip: Trip) => {
setEditingTrip(trip);

View File

@@ -8,16 +8,15 @@ import Forbidden from "../components/Forbidden";
import { formatDate } from "../utils/attendanceHelpers";
import { formatKm } from "../utils/formatters";
import FormField from "../components/FormField";
import { tripHistoryOptions, tripVehiclesOptions } from "../lib/queries/trips";
import {
tripHistoryOptions,
tripVehiclesOptions,
type BackendTrip,
type TripVehicle,
} from "../lib/queries/trips";
import { Skeleton } from "boneyard-js/react";
import TripsHistoryFixture from "../fixtures/TripsHistoryFixture";
interface Vehicle {
id: number | string;
spz: string;
name: string;
}
interface Trip {
id: number;
trip_date: string;
@@ -42,7 +41,7 @@ export default function TripsHistory() {
const [vehicleId, setVehicleId] = useState("");
const { data: vehiclesData = [] } = useQuery(tripVehiclesOptions());
const vehicles = vehiclesData as Vehicle[];
const vehicles = vehiclesData;
const { data: tripsData, isPending } = useQuery(
tripHistoryOptions({
@@ -51,14 +50,21 @@ export default function TripsHistory() {
userId: user?.id,
}),
);
const trips = ((tripsData ?? []) as Record<string, unknown>[]).map((t) => ({
...t,
spz: (t.vehicles as Record<string, string>)?.spz || "",
const trips = (tripsData ?? []).map((t) => ({
id: t.id,
trip_date: t.trip_date,
spz: t.vehicles?.spz ?? "",
driver_name: t.users
? `${(t.users as Record<string, string>).first_name || ""} ${(t.users as Record<string, string>).last_name || ""}`.trim()
? `${t.users.first_name} ${t.users.last_name}`.trim()
: "",
distance: ((t.end_km as number) || 0) - ((t.start_km as number) || 0),
})) as Trip[];
route_from: t.route_from,
route_to: t.route_to,
start_km: t.start_km,
end_km: t.end_km,
distance: t.distance ?? t.end_km - t.start_km,
is_business: t.is_business,
notes: t.notes ?? undefined,
}));
const totals = trips.reduce(
(acc, t) => ({

View File

@@ -8,29 +8,17 @@ import ConfirmModal from "../components/ConfirmModal";
import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden";
import useModalLock from "../hooks/useModalLock";
import { userListOptions, roleListOptions } from "../lib/queries/users";
import {
userListOptions,
roleListOptions,
type User,
type Role,
} from "../lib/queries/users";
import UsersFixture from "../fixtures/UsersFixture";
import apiFetch from "../utils/api";
const API_BASE = "/api/admin";
interface User {
id: number;
username: string;
email: string;
first_name: string;
last_name: string;
role_id: number;
roles?: { id: number; name: string; display_name: string } | null;
is_active: boolean;
}
interface Role {
id: number;
name: string;
display_name: string;
}
interface FormData {
username: string;
email: string;
@@ -46,10 +34,8 @@ export default function Users() {
const alert = useAlert();
const queryClient = useQueryClient();
const { data: usersData, isPending } = useQuery(userListOptions());
const users = ((usersData as Record<string, unknown>)?.items ??
usersData ??
[]) as User[];
const { data: roles = [] } = useQuery(roleListOptions()) as { data: Role[] };
const users = usersData ?? [];
const { data: roles = [] } = useQuery(roleListOptions());
const [showModal, setShowModal] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [deleteModal, setDeleteModal] = useState<{
@@ -144,6 +130,11 @@ export default function Users() {
wasEditing ? "Uživatel byl upraven" : "Uživatel byl vytvořen",
);
queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["trips"] });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
queryClient.invalidateQueries({ queryKey: ["leave-requests"] });
queryClient.invalidateQueries({ queryKey: ["leave"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
} else {
alert.error(data.error || "Nepodařilo se uložit uživatele");
}
@@ -177,6 +168,11 @@ export default function Users() {
if (data.success) {
closeDeleteModal();
queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["trips"] });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
queryClient.invalidateQueries({ queryKey: ["leave-requests"] });
queryClient.invalidateQueries({ queryKey: ["leave"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
alert.success("Uživatel byl smazán");
} else {
alert.error(data.error || "Nepodařilo se smazat uživatele");
@@ -202,6 +198,11 @@ export default function Users() {
if (data.success) {
queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["trips"] });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
queryClient.invalidateQueries({ queryKey: ["leave-requests"] });
queryClient.invalidateQueries({ queryKey: ["leave"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
alert.success(
user.is_active
? "Uživatel byl deaktivován"

View File

@@ -63,7 +63,7 @@ export default function Vehicles() {
useModalLock(showModal);
if (!hasPermission("trips.vehicles")) return <Forbidden />;
if (!hasPermission("vehicles.manage")) return <Forbidden />;
const openCreateModal = () => {
setEditingVehicle(null);
@@ -117,6 +117,7 @@ export default function Vehicles() {
if (result.success) {
setShowModal(false);
queryClient.invalidateQueries({ queryKey: ["vehicles"] });
queryClient.invalidateQueries({ queryKey: ["trips"] });
alert.success(result.message);
} else {
alert.error(result.error);
@@ -142,6 +143,7 @@ export default function Vehicles() {
if (result.success) {
setDeleteConfirm({ show: false, vehicle: null });
queryClient.invalidateQueries({ queryKey: ["vehicles"] });
queryClient.invalidateQueries({ queryKey: ["trips"] });
alert.success(result.message);
} else {
alert.error(result.error);
@@ -163,6 +165,7 @@ export default function Vehicles() {
if (result.success) {
queryClient.invalidateQueries({ queryKey: ["vehicles"] });
queryClient.invalidateQueries({ queryKey: ["trips"] });
alert.success(
vehicle.is_active
? "Vozidlo bylo deaktivováno"

View File

@@ -100,7 +100,7 @@ export default async function attendanceRoutes(
// --- action=balances: leave balance overview for all users ---
if (action === "balances") {
if (!authData.permissions.includes("attendance.admin")) {
if (!authData.permissions.includes("attendance.balances")) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const yr = Number(query.year) || new Date().getFullYear();
@@ -110,7 +110,7 @@ export default async function attendanceRoutes(
// --- action=workfund: monthly work fund overview ---
if (action === "workfund") {
if (!authData.permissions.includes("attendance.admin")) {
if (!authData.permissions.includes("attendance.manage")) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const yr = Number(query.year) || new Date().getFullYear();
@@ -120,7 +120,7 @@ export default async function attendanceRoutes(
// --- action=project_report: monthly project hours ---
if (action === "project_report") {
if (!authData.permissions.includes("attendance.admin")) {
if (!authData.permissions.includes("attendance.manage")) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const yr = Number(query.year) || new Date().getFullYear();
@@ -130,7 +130,7 @@ export default async function attendanceRoutes(
// --- action=print: attendance print data for admin ---
if (action === "print") {
if (!authData.permissions.includes("attendance.admin")) {
if (!authData.permissions.includes("attendance.manage")) {
return error(reply, "Nedostatečná oprávnění", 403);
}
@@ -145,8 +145,7 @@ export default async function attendanceRoutes(
// --- action=attendance_users: users with attendance.record permission ---
if (action === "attendance_users") {
if (
!authData.permissions.includes("attendance.admin") &&
!authData.permissions.includes("attendance.view") &&
!authData.permissions.includes("attendance.manage") &&
!authData.permissions.includes("attendance.record")
) {
return error(reply, "Nedostatečná oprávnění", 403);
@@ -182,10 +181,7 @@ export default async function attendanceRoutes(
// --- action=project_logs: get project logs for a specific attendance record ---
if (action === "project_logs") {
if (
!authData.permissions.includes("attendance.view") &&
!authData.permissions.includes("attendance.record")
) {
if (!authData.permissions.includes("attendance.record")) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const attendanceId = Number(query.attendance_id);
@@ -200,7 +196,7 @@ export default async function attendanceRoutes(
if (!id) return error(reply, "Missing id", 400);
const record = await attendanceService.getLocationRecord(id);
if (!record) return error(reply, "Záznam nenalezen", 404);
const isAdmin = authData.permissions.includes("attendance.admin");
const isAdmin = authData.permissions.includes("attendance.manage");
if (record.user_id !== authData.userId && !isAdmin) {
return error(reply, "Nedostatečná oprávnění", 403);
}
@@ -209,7 +205,7 @@ export default async function attendanceRoutes(
// --- Default: paginated records list ---
const { page, limit, skip, order } = parsePagination(query);
const isAdmin = authData.permissions.includes("attendance.admin");
const isAdmin = authData.permissions.includes("attendance.manage");
const userId = query.user_id ? Number(query.user_id) : undefined;
const result = await attendanceService.listAttendance({
@@ -285,7 +281,7 @@ export default async function attendanceRoutes(
// --- action=bulk_attendance: bulk fill month ---
if (postQuery.action === "bulk_attendance") {
if (!authData.permissions.includes("attendance.admin")) {
if (!authData.permissions.includes("attendance.manage")) {
return error(reply, "Nedostatečná oprávnění", 403);
}
@@ -328,7 +324,7 @@ export default async function attendanceRoutes(
if (
leaveBody.user_id != null &&
leaveBody.user_id !== authData.userId &&
!authData.permissions.includes("attendance.admin")
!authData.permissions.includes("attendance.manage")
) {
return error(reply, "Nedostatečná oprávnění", 403);
}
@@ -384,7 +380,7 @@ export default async function attendanceRoutes(
if (
body.user_id != null &&
body.user_id !== authData.userId &&
!authData.permissions.includes("attendance.admin")
!authData.permissions.includes("attendance.manage")
) {
return error(reply, "Nedostatečná oprávnění", 403);
}
@@ -429,7 +425,7 @@ export default async function attendanceRoutes(
// PUT /api/admin/attendance/:id
fastify.put<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("attendance.edit") },
{ preHandler: requirePermission("attendance.manage") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
@@ -438,7 +434,7 @@ export default async function attendanceRoutes(
const body = parsed.data;
const authData = request.authData!;
const isAdmin = authData.permissions.includes("attendance.admin");
const isAdmin = authData.permissions.includes("attendance.manage");
const result = await attendanceService.updateAttendance(
id,
@@ -481,7 +477,7 @@ export default async function attendanceRoutes(
// DELETE /api/admin/attendance/:id
fastify.delete<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("attendance.admin") },
{ preHandler: requirePermission("attendance.manage") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;

View File

@@ -14,7 +14,7 @@ export default async function bankAccountsRoutes(
): Promise<void> {
fastify.get(
"/",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.banking") },
async (_request, reply) => {
const accounts = await prisma.bank_accounts.findMany({
orderBy: { position: "asc" },
@@ -25,7 +25,7 @@ export default async function bankAccountsRoutes(
fastify.post(
"/",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.banking") },
async (request, reply) => {
const parsed = parseBody(CreateBankAccountSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
@@ -59,7 +59,7 @@ export default async function bankAccountsRoutes(
fastify.put<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.banking") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
@@ -126,7 +126,7 @@ export default async function bankAccountsRoutes(
fastify.delete<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.banking") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;

View File

@@ -99,7 +99,7 @@ export default async function companySettingsRoutes(
// POST /api/admin/company-settings/logo?variant=light|dark
fastify.post(
"/logo",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.company") },
async (request, reply) => {
const query = request.query as Record<string, string>;
const variant = query.variant === "dark" ? "dark" : "light";
@@ -333,7 +333,7 @@ export default async function companySettingsRoutes(
// GET /api/admin/company-settings/system-info
fastify.get(
"/system-info",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.company") },
async (request, reply) => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const pkg = require("../../../package.json") as { version: string };
@@ -404,7 +404,7 @@ export default async function companySettingsRoutes(
fastify.put(
"/",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.company") },
async (request, reply) => {
const parsed = parseBody(UpdateCompanySettingsSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);

View File

@@ -126,7 +126,7 @@ export default async function customersRoutes(
fastify.post(
"/",
{ preHandler: requirePermission("customers.manage") },
{ preHandler: requirePermission("customers.create") },
async (request, reply) => {
const parsed = parseBody(CreateCustomerSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
@@ -164,7 +164,7 @@ export default async function customersRoutes(
fastify.put<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("customers.manage") },
{ preHandler: requirePermission("customers.edit") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
@@ -239,7 +239,7 @@ export default async function customersRoutes(
fastify.delete<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("customers.manage") },
{ preHandler: requirePermission("customers.delete") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;

View File

@@ -42,8 +42,8 @@ export default async function dashboardRoutes(
result.my_shift = { has_ongoing: myShift !== null };
}
// Attendance admin — only for attendance.admin
if (has("attendance.admin")) {
// Attendance admin — only for attendance.manage
if (has("attendance.manage")) {
const [todayAttendance, onLeaveToday, usersCount] = await Promise.all([
prisma.attendance.findMany({
where: {

View File

@@ -632,14 +632,6 @@ ${indentCSS}
border: none;
vertical-align: top;
}
.logo-header {
text-align: right;
padding-bottom: 4mm;
}
.first-content {
margin-top: -26mm;
}
/* ---- Page break helpers ---- */
table.page-layout thead { display: table-header-group; }
table.items tbody tr { break-inside: avoid; }
@@ -696,30 +688,16 @@ ${indentCSS}
display: block;
width: 100%;
}
.first-content {
margin-top: 0 !important;
}
.logo-header {
text-align: right;
padding-bottom: 0;
margin-bottom: -18mm;
}
}
</style>
</head>
<body>
<!-- ============ QUOTATION (logo repeats via thead, full header only on first page) ============ -->
<!-- ============ QUOTATION (full header in thead repeats on every page) ============ -->
<div class="quotation-page">
<table class="page-layout">
<thead>
<tr><td>
<div class="logo-header">${logoImg}</div>
</td></tr>
</thead>
<tbody>
<tr><td>
<div class="first-content">
<div class="page-header">
<div class="left">
<div class="page-title">${escapeHtml(t("title"))}</div>
@@ -727,9 +705,13 @@ ${indentCSS}
${quotation.project_code ? `<div class="project-code">${escapeHtml(quotation.project_code)}</div>` : ""}
<div class="valid-until">${escapeHtml(t("valid_until"))}: ${escapeHtml(formatDate(quotation.valid_until))}</div>
</div>
${logoImg ? `<div class="right">${logoImg}</div>` : ""}
</div>
<hr class="separator" />
</td></tr>
</thead>
<tbody>
<tr><td>
<div class="addresses">
<div class="address-block left">
<div class="address-label">${escapeHtml(t("customer"))}</div>
@@ -763,7 +745,6 @@ ${indentCSS}
${totalsHtml}
</div>
</div>
</div>
</td></tr>
</tbody>
</table>

View File

@@ -36,6 +36,13 @@ export default async function profileRoutes(
const body = parsed.data;
const userId = request.authData!.userId;
if (body.new_password && !body.current_password) {
return error(reply, "Pro změnu hesla zadejte aktuální heslo", 400);
}
if (body.current_password && !body.new_password) {
return error(reply, "Pro změnu hesla zadejte nové heslo", 400);
}
const data: Record<string, unknown> = {};
if (body.email) {
data.email = String(body.email).trim();

View File

@@ -156,7 +156,6 @@ export default async function projectFilesRoutes(
"/upload",
{
preHandler: requirePermission("projects.files"),
bodyLimit: config.nas.maxUploadSize,
},
async (request, reply) => {
const parsedQuery = parseProjectFilesQuery(request.query);
@@ -183,13 +182,25 @@ export default async function projectFilesRoutes(
const rawFileName = file.filename;
const fileName = rawFileName.replace(/[\/\\:*?"<>|]/g, "_");
const buffer = await file.toBuffer();
const err = await fm.uploadFile(
project.project_number,
subPath,
file.file,
buffer,
fileName,
);
if (err !== null) return error(reply, err);
if (err !== null) {
request.log.error(
{
err,
project_number: project.project_number,
subPath,
fileName,
},
"uploadFile failed",
);
return error(reply, err);
}
await logAudit({
request,

View File

@@ -12,7 +12,7 @@ export default async function rolesRoutes(
// GET /api/admin/roles
fastify.get(
"/",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.roles") },
async (request, reply) => {
const roles = await prisma.roles.findMany({
include: {
@@ -35,7 +35,7 @@ export default async function rolesRoutes(
// GET /api/admin/roles/permissions
fastify.get(
"/permissions",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.roles") },
async (_request, reply) => {
const permissions = await prisma.permissions.findMany({
orderBy: { module: "asc" },
@@ -47,7 +47,7 @@ export default async function rolesRoutes(
// POST /api/admin/roles
fastify.post(
"/",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.roles") },
async (request, reply) => {
const parsed = parseBody(CreateRoleSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
@@ -90,7 +90,7 @@ export default async function rolesRoutes(
// PUT /api/admin/roles/:id
fastify.put<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.roles") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
@@ -142,7 +142,7 @@ export default async function rolesRoutes(
// DELETE /api/admin/roles/:id
fastify.delete<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.roles") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;

View File

@@ -22,7 +22,7 @@ export default async function scopeTemplatesRoutes(
// Legacy ?action= dispatcher for item templates
fastify.get(
"/",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.templates") },
async (request, reply) => {
const query = request.query as Record<string, unknown>;
const action = query.action ? String(query.action) : null;
@@ -53,7 +53,7 @@ export default async function scopeTemplatesRoutes(
// Item template CRUD via ?action=item
fastify.post(
"/",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.templates") },
async (request, reply) => {
const query = request.query as Record<string, unknown>;
@@ -121,7 +121,7 @@ export default async function scopeTemplatesRoutes(
// Item template delete via DELETE ?action=item&id=X
fastify.delete(
"/",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.templates") },
async (request, reply) => {
const query = request.query as Record<string, unknown>;
@@ -140,7 +140,7 @@ export default async function scopeTemplatesRoutes(
fastify.get<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.templates") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
@@ -161,7 +161,7 @@ export default async function scopeTemplatesRoutes(
fastify.put<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.templates") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
@@ -210,7 +210,7 @@ export default async function scopeTemplatesRoutes(
fastify.delete<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.templates") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;

View File

@@ -193,7 +193,7 @@ export default async function totpRoutes(
// GET - check if 2FA is required company-wide
fastify.get(
"/required",
{ preHandler: [requireAuth, requirePermission("settings.manage")] },
{ preHandler: requireAuth },
async (request, reply) => {
const settings = await prisma.company_settings.findFirst({
select: { require_2fa: true },
@@ -207,7 +207,7 @@ export default async function totpRoutes(
fastify.post(
"/required",
{
preHandler: [requireAuth, requirePermission("settings.manage")],
preHandler: [requireAuth, requirePermission("settings.company")],
bodyLimit: 10240,
},
async (request, reply) => {

View File

@@ -14,7 +14,12 @@ export default async function tripsRoutes(
const query = request.query as Record<string, unknown>;
const { page, limit, skip, order } = parsePagination(query);
const authData = request.authData!;
const isAdmin = authData.permissions.includes("trips.admin");
const hasTripsAccess =
authData.permissions.includes("trips.manage") ||
authData.permissions.includes("trips.record") ||
authData.permissions.includes("trips.history");
if (!hasTripsAccess) return error(reply, "Nedostatečná oprávnění", 403);
const isAdmin = authData.permissions.includes("trips.manage");
const where: Record<string, unknown> = {};
if (!isAdmin) where.user_id = authData.userId;
@@ -107,7 +112,7 @@ export default async function tripsRoutes(
// GET /api/admin/trips/print — print data for trip report
fastify.get(
"/print",
{ preHandler: requirePermission("trips.admin") },
{ preHandler: requirePermission("trips.manage") },
async (request, reply) => {
const query = request.query as Record<string, unknown>;
const filterUserId = query.user_id ? Number(query.user_id) : null;
@@ -182,7 +187,7 @@ export default async function tripsRoutes(
// Matches PHP: COALESCE(MAX(end_km), vehicle.initial_km, 0)
fastify.get<{ Params: { vehicleId: string } }>(
"/last-km/:vehicleId",
{ preHandler: requirePermission("trips.view") },
{ preHandler: requirePermission("trips.manage") },
async (request, reply) => {
const vehicleId = parseInt(request.params.vehicleId, 10);
if (isNaN(vehicleId)) return error(reply, "Neplatné ID vozidla", 400);
@@ -213,6 +218,11 @@ export default async function tripsRoutes(
const body = parsed.data;
const authData = request.authData!;
const canRecord =
authData.permissions.includes("trips.manage") ||
authData.permissions.includes("trips.record");
if (!canRecord) return error(reply, "Nedostatečná oprávnění", 403);
if (body.end_km < body.start_km) {
return error(reply, "Konečný stav km nesmí být menší než počáteční", 400);
}
@@ -274,7 +284,7 @@ export default async function tripsRoutes(
if (!existing) return error(reply, "Jízda nenalezena", 404);
// Ownership check — same as DELETE handler
const isAdmin = authData.permissions.includes("trips.admin");
const isAdmin = authData.permissions.includes("trips.manage");
if (existing.user_id !== authData.userId && !isAdmin) {
return error(reply, "Nemáte oprávnění upravit tuto jízdu", 403);
}
@@ -332,7 +342,7 @@ export default async function tripsRoutes(
if (!existing) return error(reply, "Jízda nenalezena", 404);
// Allow users to delete their own trips, admins can delete any
const isAdmin = authData.permissions.includes("trips.admin");
const isAdmin = authData.permissions.includes("trips.manage");
if (existing.user_id !== authData.userId && !isAdmin) {
return error(reply, "Nemáte oprávnění smazat tuto jízdu", 403);
}

View File

@@ -22,8 +22,12 @@ export default async function usersRoutes(
"/",
{ preHandler: requirePermission("users.view") },
async (request, reply) => {
const params = parsePagination(request.query as Record<string, unknown>);
const result = await listUsers(params);
const query = request.query as Record<string, unknown>;
const params = parsePagination(query);
const permission = query.permission
? String(query.permission)
: undefined;
const result = await listUsers({ ...params, permission });
return paginated(
reply,

View File

@@ -44,7 +44,7 @@ export default async function vehiclesRoutes(
fastify.post(
"/",
{ preHandler: requirePermission("trips.vehicles") },
{ preHandler: requirePermission("vehicles.manage") },
async (request, reply) => {
const parsed = parseBody(CreateVehicleSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
@@ -75,7 +75,7 @@ export default async function vehiclesRoutes(
fastify.put<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("trips.vehicles") },
{ preHandler: requirePermission("vehicles.manage") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
@@ -124,7 +124,7 @@ export default async function vehiclesRoutes(
fastify.delete<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("trips.vehicles") },
{ preHandler: requirePermission("vehicles.manage") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;

View File

@@ -1381,7 +1381,7 @@ export async function punchAction(userId: number, data: PunchData) {
const shiftMs = departureTime.getTime() - ongoing.arrival_time.getTime();
const shiftHours = shiftMs / (1000 * 60 * 60);
if (shiftHours > settings.break_threshold_hours) {
const midpoint = new Date(ongoing.arrival_time.getTime() + shiftMs / 2);
const midpoint = roundDown(new Date(ongoing.arrival_time.getTime() + shiftMs / 2), settings.clock_rounding_minutes);
const breakMins =
shiftHours > settings.break_threshold_hours * 2
? settings.break_duration_long

View File

@@ -1,6 +1,5 @@
import fs from "fs";
import path from "path";
import { pipeline } from "stream/promises";
import { config } from "../config/env";
import { localDateStr, localTimeStr } from "../utils/date";
@@ -237,7 +236,7 @@ export class NasFileManager {
if (isLink) {
try {
const target = fs.readlinkSync(fullPath);
item.link_target = target.replace(/\//g, "\\");
item.link_target = target;
} catch {
// ignore
}
@@ -288,14 +287,14 @@ export class NasFileManager {
path: subPath,
items,
breadcrumb,
full_path: realDirPath.replace(/\//g, "\\"),
full_path: realDirPath,
};
}
public async uploadFile(
projectNumber: string,
subPath: string,
fileStream: NodeJS.ReadableStream,
fileBuffer: Buffer,
fileName: string,
): Promise<string | null> {
const dirPath = this.resolveProjectPath(projectNumber, subPath);
@@ -304,14 +303,21 @@ export class NasFileManager {
}
try {
const stat = await fs.promises.stat(dirPath);
if (!stat.isDirectory()) {
return "Cílová složka neexistuje";
}
await fs.promises.mkdir(dirPath, { recursive: true, mode: 0o775 });
} catch {
return "Cílová složka neexistuje";
}
let stat: fs.Stats;
try {
stat = await fs.promises.stat(dirPath);
} catch {
return "Cílová složka neexistuje";
}
if (!stat.isDirectory()) {
return "Cílová složka neexistuje";
}
const originalName = path.basename(fileName);
let safeName = this.sanitizeFilename(originalName);
if (safeName === "") {
@@ -323,46 +329,27 @@ export class NasFileManager {
return "Tento typ souboru není povolen";
}
const tempPath = path.join(
require("os").tmpdir(),
`upload-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
try {
await pipeline(fileStream, fs.createWriteStream(tempPath));
} catch {
await fs.promises.unlink(tempPath).catch(() => {});
return "Nepodařilo se uložit soubor";
}
try {
const typeResult = await FileType.fromFile(tempPath);
if (typeResult) {
if (this.isSuspiciousMime(typeResult.mime)) {
await fs.promises.unlink(tempPath).catch(() => {});
return "Obsah souboru neodpovídá jeho příponě";
}
const expectedMime = ext ? MIME_MAP[ext] : null;
if (expectedMime && typeResult.mime !== expectedMime) {
await fs.promises.unlink(tempPath).catch(() => {});
return "Obsah souboru neodpovídá jeho příponě";
}
// MIME validation
const typeResult = await FileType.fromBuffer(fileBuffer);
if (typeResult) {
if (this.isSuspiciousMime(typeResult.mime)) {
return "Obsah souboru neodpovídá jeho příponě";
}
const expectedMime = ext ? MIME_MAP[ext] : null;
if (expectedMime && typeResult.mime !== expectedMime) {
return "Obsah souboru neodpovídá jeho příponě";
}
} catch {
// If file-type fails, continue without MIME check
}
let destPath = dirPath + "/" + safeName;
// Attempt atomic rename; if destination exists, append counter
let renamed = false;
// If destination exists, append counter
let attempts = 0;
const maxAttempts = 1000;
do {
while (attempts < maxAttempts) {
try {
await fs.promises.rename(tempPath, destPath);
renamed = true;
break;
await fs.promises.writeFile(destPath, fileBuffer, { flag: "wx" });
return null;
} catch (err) {
const e = err as NodeJS.ErrnoException;
if (e.code === "EEXIST") {
@@ -371,17 +358,12 @@ export class NasFileManager {
safeName = base + "_" + attempts + (ext ? "." + ext : "");
destPath = dirPath + "/" + safeName;
} else {
break;
return `Nepodařilo se uložit soubor (${e?.message || String(e)})`;
}
}
} while (!renamed && attempts < maxAttempts);
if (!renamed) {
await fs.promises.unlink(tempPath).catch(() => {});
return "Nepodařilo se uložit soubor";
}
return null;
return "Nepodařilo se uložit soubor";
}
public downloadFile(

View File

@@ -102,7 +102,26 @@ export async function listOffers(params: ListOffersParams) {
prisma.quotations.count({ where }),
]);
const enriched = quotations.map(enrichQuotation);
// Batch-fetch linked order statuses
const orderIds = quotations
.map((q) => q.order_id)
.filter((id): id is number => id != null);
const orderStatusMap = new Map<number, string>();
if (orderIds.length > 0) {
const orders = await prisma.orders.findMany({
where: { id: { in: orderIds } },
select: { id: true, status: true },
});
for (const o of orders) {
orderStatusMap.set(o.id, o.status || "");
}
}
const enriched = quotations.map((q) => ({
...enrichQuotation(q),
order_status:
q.order_id != null ? orderStatusMap.get(q.order_id) || null : null,
}));
return { data: enriched, total, page, limit };
}

View File

@@ -33,6 +33,7 @@ export interface ListUsersParams {
sort: string;
order: "asc" | "desc";
search: string;
permission?: string;
}
export interface CreateUserData {
@@ -56,10 +57,10 @@ export interface UpdateUserData {
}
export async function listUsers(params: ListUsersParams) {
const { page, limit, skip, sort, order, search } = params;
const { page, limit, skip, sort, order, search, permission } = params;
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
const where = search
let where: any = search
? {
OR: [
{ username: { contains: search } },
@@ -70,6 +71,14 @@ export async function listUsers(params: ListUsersParams) {
}
: {};
if (permission) {
where.roles = {
role_permissions: {
some: { permissions: { name: permission } },
},
};
}
const [users, total] = await Promise.all([
prisma.users.findMany({
where,