Compare commits

...

13 Commits

Author SHA1 Message Date
BOHA
2254a13ad0 v1.7.0: fix offer created_at override, invoice PDF translations, remove has_order filter
- Add created_at to offer create/update schemas so user-selected date is stored instead of always defaulting to now()
- Translate payment method in invoice PDF based on language (Příkazem → Bank transfer, etc.)
- Add order_date and cnb_rate translation keys, replace hardcoded inline strings
- Remove duplicate has_order filter from offers backend route

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:35:36 +02:00
BOHA
1f5885de84 v1.6.9: bump version
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:20:37 +02:00
BOHA
705f58e3d1 v1.6.9: cleanup offer dead columns, add item template picker to offer form
- Remove dead DB columns: uuid, sync_version from quotations/quotation_items/scope_sections; is_deleted from quotation_items/scope_sections; content_editor_height from scope_sections
- Remove unused scope_title/scope_description from offer form state
- Remove dead CSS: offers-template-menu, offers-draft-indicator, exchange-rate
- Remove dead scope_title translation key from offers PDF
- Fix duplicate Customer interface in OfferDetail.tsx (use shared type)
- Add missing title field to ScopeTemplate interface
- Add item template picker dropdown next to "Přidat položku" button

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:20:22 +02:00
BOHA
0330453ad6 v1.6.8: remove offer exchange rate, add invoice item description, offer filters, table animations
- Remove exchange_rate and exchange_rate_date from quotations (schema, service, PDF, form)
- Add item_description field to invoice_items (schema, migration, service, form, PDF)
- Add offer status/customer/order filters with tab-based UI
- Clean up offer statuses to active/ordered/invalidated
- Allow invalidate action for non-ordered offers (not just expired)
- Add fade animations on offers and invoices table data changes

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:58:51 +02:00
BOHA
66b98e2765 v1.6.7: add settings.system permission, move numbering/VAT to company tab, rename security tab to roles
- New settings.system permission with migration (idempotent INSERT)
- requireAnyPermission helper for route guards accepting multiple perms
- Move document numbering + currency/VAT cards from system tab to CompanySettings
- Rename security tab to roles, add canManageSystem alongside canManageCompany
- TOTP required endpoint and system-info now use settings.system
- Roles list now includes user_count
- Sidebar Settings link includes settings.system

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 14:03:31 +02:00
BOHA
55648c9a30 v1.6.6: fix permissions order in role edit modal
- Add secondary sort by id in permissions API to ensure deterministic within-module order

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 18:17:30 +02:00
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
BOHA
e4f14a24b7 fix: handle plain month number in attendance route, not just YYYY-MM
AttendanceAdmin sends ?year=YYYY&month=M (separate params), but the
route handler assumed month was always in "YYYY-MM" format. When
query.month was just "4", the split produced NaN for month and 4 for
year, causing the service to skip the month filter entirely.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 16:50:20 +02:00
BOHA
3bd0d055d9 v1.6.0: fix offer items mobile layout and localStorage draft save/restore
- Fix items table description column width on mobile (was ~82px, now ~260px)
- Activate unused offers-items-table CSS class in OfferDetail form
- Save all form fields to localStorage draft (language, VAT, exchange rate were missing)
- Use DRAFT_KEY constant in loadOfferDraft, add error logging

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 15:23:42 +02:00
BOHA
746d17e182 fix: parse YYYY-MM month filter correctly in attendance history
The frontend sends month as "YYYY-MM" but the route handler was passing
it through Number() which parsed only the year portion, causing the
service to ignore the month filter entirely.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 09:29:47 +02:00
92 changed files with 6052 additions and 5192 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.

572
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "app-ts",
"version": "1.5.8",
"version": "1.7.0",
"description": "",
"main": "dist/server.js",
"scripts": {
@@ -18,7 +18,10 @@
"db:studio": "prisma studio",
"test": "vitest run",
"test:watch": "vitest",
"bones": "boneyard-js build http://localhost:3000"
"seed": "tsx prisma/seed.ts"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"keywords": [],
"author": "",
@@ -38,7 +41,6 @@
"@tanstack/react-query": "^5.100.5",
"@types/jsdom": "^28.0.1",
"bcryptjs": "^3.0.3",
"boneyard-js": "^1.8.1",
"date-fns": "^4.1.0",
"dompurify": "^3.3.3",
"dotenv": "^17.3.1",

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

@@ -0,0 +1,4 @@
-- Insert the settings.system permission (also available via seed)
INSERT INTO `permissions` (`name`, `display_name`, `module`, `description`)
VALUES ('settings.system', 'Systémová nastavení', 'settings', 'Spravovat systémová nastavení, 2FA, e-maily, limity')
ON DUPLICATE KEY UPDATE `name` = `name`;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE `quotations` DROP COLUMN `exchange_rate`;
ALTER TABLE `quotations` DROP COLUMN `exchange_rate_date`;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `invoice_items` ADD COLUMN `item_description` TEXT NULL;

View File

@@ -0,0 +1,14 @@
-- AlterTable: quotation_items
ALTER TABLE `quotation_items` DROP COLUMN `uuid`;
ALTER TABLE `quotation_items` DROP COLUMN `is_deleted`;
ALTER TABLE `quotation_items` DROP COLUMN `sync_version`;
-- AlterTable: quotations
ALTER TABLE `quotations` DROP COLUMN `uuid`;
ALTER TABLE `quotations` DROP COLUMN `sync_version`;
-- AlterTable: scope_sections
ALTER TABLE `scope_sections` DROP COLUMN `content_editor_height`;
ALTER TABLE `scope_sections` DROP COLUMN `uuid`;
ALTER TABLE `scope_sections` DROP COLUMN `is_deleted`;
ALTER TABLE `scope_sections` DROP COLUMN `sync_version`;

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)
@@ -153,8 +153,9 @@ model customers {
model invoice_items {
id Int @id @default(autoincrement())
invoice_id Int
description String? @db.VarChar(500)
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
description String? @db.VarChar(500)
item_description String? @db.Text
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
unit String? @db.VarChar(20)
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
@@ -377,10 +378,7 @@ model quotation_items {
unit String? @db.VarChar(20)
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
is_included_in_total Boolean? @default(true)
uuid String? @db.VarChar(36)
modified_at DateTime? @db.DateTime(0)
is_deleted Boolean? @default(false)
sync_version Int? @default(0)
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "quotation_items_ibfk_1")
@@index([quotation_id], map: "quotation_id")
@@ -389,7 +387,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
@@ -397,17 +395,13 @@ model quotations {
language String? @default("cs") @db.VarChar(5)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
apply_vat Boolean? @default(true)
exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
exchange_rate_date DateTime? @db.Date
order_id Int?
status String @default("active") @db.VarChar(20)
scope_title String? @db.VarChar(500)
scope_description String? @db.Text
locked_by Int?
locked_at DateTime? @db.DateTime(0)
uuid String? @db.VarChar(36)
modified_at DateTime? @db.DateTime(0)
sync_version Int? @default(0)
orders orders[]
projects projects[]
quotation_items quotation_items[]
@@ -437,7 +431,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 +443,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)
@@ -492,11 +486,7 @@ model scope_sections {
title String? @db.VarChar(500)
title_cz String? @db.VarChar(500)
content String? @db.Text
content_editor_height Int?
uuid String? @db.VarChar(36)
modified_at DateTime? @db.DateTime(0)
is_deleted Boolean? @default(false)
sync_version Int? @default(0)
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "scope_sections_ibfk_1")
@@index([quotation_id], map: "quotation_id")
@@ -632,7 +622,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 {

436
prisma/seed.ts Normal file
View File

@@ -0,0 +1,436 @@
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",
},
{
name: "settings.system",
display_name: "Systémová nastavení",
module: "settings",
description: "Spravovat systémová nastavení, 2FA, e-maily, limity",
},
{
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,13 @@ const menuSections: MenuSection[] = [
{
path: "/settings",
label: "Nastavení",
permission: "settings.manage",
permission: [
"settings.company",
"settings.banking",
"settings.roles",
"settings.system",
"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,68 @@
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;
title?: 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: {
@@ -25,6 +71,8 @@ export const offerListOptions = (filters: {
order?: string;
page?: number;
perPage?: number;
status?: string;
customer_id?: number;
}) =>
queryOptions({
queryKey: ["offers", "list", filters],
@@ -35,16 +83,67 @@ export const offerListOptions = (filters: {
if (filters.order) params.set("order", filters.order);
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
if (filters.status) params.set("status", filters.status);
if (filters.customer_id)
params.set("customer_id", String(filters.customer_id));
const qs = params.toString();
return paginatedJsonQuery(`/api/admin/offers${qs ? `?${qs}` : ""}`);
},
});
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;
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

@@ -46,33 +46,6 @@
min-height: 32px;
}
/* Template dropdown menu */
.offers-template-menu {
position: absolute;
top: 100%;
right: 0;
z-index: 100;
min-width: 200px;
max-height: 250px;
overflow-y: auto;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
margin-top: 0.25rem;
}
.offers-template-menu-item {
padding: 0.5rem 0.75rem;
cursor: pointer;
font-size: 0.875rem;
transition: background var(--transition);
}
.offers-template-menu-item:hover {
background: var(--bg-secondary);
}
/* Language badges */
.offers-lang-badge {
display: inline-flex;
@@ -491,10 +464,69 @@
display: none;
}
@media (max-width: 768px) {
.offers-items-table .admin-table td .admin-form-input {
font-size: 16px;
min-height: 44px;
padding: 9px 10px;
}
/* Give the description column more room by shrinking numeric columns */
.offers-col-qty {
width: 4rem !important;
}
.offers-col-unit {
width: 4rem !important;
}
.offers-col-price {
width: 5.5rem !important;
}
.offers-col-included {
width: 3.5rem !important;
}
.offers-col-total {
width: 5.5rem !important;
}
.offers-col-del {
width: 2.5rem !important;
}
}
@media (max-width: 640px) {
.offers-items-table {
margin: 0 -1rem;
width: calc(100% + 2rem);
border-radius: 0;
border-left: none;
border-right: none;
}
.offers-items-table .admin-table {
min-width: 700px;
}
.offers-items-table .admin-table td {
padding: 6px;
font-size: 12px;
}
.offers-items-table .admin-table th {
font-size: 10px;
padding: 6px;
}
/* Further reduce numeric columns on very small screens */
.offers-col-qty {
width: 3.5rem !important;
}
.offers-col-unit {
width: 3.5rem !important;
}
.offers-col-price {
width: 5rem !important;
}
.offers-col-total {
width: 5rem !important;
}
}
@@ -542,6 +574,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 {
@@ -550,13 +590,32 @@
}
/* Offer draft indicator */
.offers-draft-indicator {
/* Filters */
.admin-filters {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.72rem;
font-weight: 500;
color: var(--text-tertiary);
margin-top: 0.2rem;
opacity: 0.8;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-start;
}
.admin-filter-group {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.admin-filter-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.admin-filter-group .admin-tabs {
flex-wrap: wrap;
}
.admin-filter-group .admin-form-select {
min-width: 10rem;
}

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;

File diff suppressed because it is too large Load Diff

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} />
)}

File diff suppressed because it is too large Load Diff

View File

@@ -20,9 +20,6 @@ import {
type CurrencyAmount,
} from "../lib/queries/invoices";
import Pagination from "../components/Pagination";
import { Skeleton } from "boneyard-js/react";
import InvoicesFixture from "../fixtures/InvoicesFixture";
import ReceivedInvoicesFixture from "../fixtures/ReceivedInvoicesFixture";
const ReceivedInvoices = lazy(() => import("./ReceivedInvoices"));
const API_BASE = "/api/admin";
@@ -220,6 +217,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 +241,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");
}
@@ -281,13 +280,9 @@ export default function Invoices() {
if (initialLoad) {
return (
<Skeleton
name="invoices"
loading={initialLoad}
fixture={<InvoicesFixture />}
>
<div />
</Skeleton>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
@@ -418,13 +413,9 @@ export default function Invoices() {
>
<Suspense
fallback={
<Skeleton
name="invoices-received-kpi"
loading={true}
fixture={<ReceivedInvoicesFixture />}
>
<div />
</Skeleton>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
}
>
<ReceivedInvoices
@@ -443,13 +434,9 @@ export default function Invoices() {
transition={{ duration: 0.25, delay: 0.1 }}
>
{statsQuery.isPending && !hasLoadedOnce.current ? (
<Skeleton
name="invoices-kpi"
loading={statsQuery.isPending && !hasLoadedOnce.current}
fixture={<InvoicesFixture />}
>
<div />
</Skeleton>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : (
stats && (
<div style={{ overflow: "hidden", marginBottom: "1.5rem" }}>
@@ -630,245 +617,143 @@ export default function Invoices() {
/>
</div>
{invoices.length === 0 && !(draft && !statusFilter) ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
</div>
<p>Zatím nejsou žádné faktury.</p>
{hasPermission("invoices.create") && (
<p
className="text-tertiary"
style={{ fontSize: "0.875rem" }}
>
Vytvořte první fakturu tlačítkem výše.
</p>
)}
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("invoice_number")}
<AnimatePresence mode="wait">
<motion.div
key={`${statusFilter}-${search}-${statsMonth}-${statsYear}-${page}-${invoices.length}`}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{invoices.length === 0 && !(draft && !statusFilter) ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
Číslo{" "}
<SortIcon
column="invoice_number"
sort={activeSort}
order={order}
/>
</th>
<th>Zákazník</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("status")}
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
</div>
<p>Zatím nejsou žádné faktury.</p>
{hasPermission("invoices.create") && (
<p
className="text-tertiary"
style={{ fontSize: "0.875rem" }}
>
Stav{" "}
<SortIcon
column="status"
sort={activeSort}
order={order}
/>
</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("issue_date")}
>
Vystaveno{" "}
<SortIcon
column="issue_date"
sort={activeSort}
order={order}
/>
</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("due_date")}
>
Splatnost{" "}
<SortIcon
column="due_date"
sort={activeSort}
order={order}
/>
</th>
<th className="text-right">Celkem</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{draft && !search && !statusFilter && (
<tr className="offers-draft-row">
<td>
<span className="offers-draft-row-label">
Koncept
{draft.savedAt && (
<span style={{ fontWeight: 400, opacity: 0.8 }}>
{" · "}
{new Date(draft.savedAt).toLocaleTimeString(
"cs-CZ",
{ hour: "2-digit", minute: "2-digit" },
Vytvořte první fakturu tlačítkem výše.
</p>
)}
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("invoice_number")}
>
Číslo{" "}
<SortIcon
column="invoice_number"
sort={activeSort}
order={order}
/>
</th>
<th>Zákazník</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("status")}
>
Stav{" "}
<SortIcon
column="status"
sort={activeSort}
order={order}
/>
</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("issue_date")}
>
Vystaveno{" "}
<SortIcon
column="issue_date"
sort={activeSort}
order={order}
/>
</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("due_date")}
>
Splatnost{" "}
<SortIcon
column="due_date"
sort={activeSort}
order={order}
/>
</th>
<th className="text-right">Celkem</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{draft && !search && !statusFilter && (
<tr className="offers-draft-row">
<td>
<span className="offers-draft-row-label">
Koncept
{draft.savedAt && (
<span
style={{ fontWeight: 400, opacity: 0.8 }}
>
{" · "}
{new Date(
draft.savedAt,
).toLocaleTimeString("cs-CZ", {
hour: "2-digit",
minute: "2-digit",
})}
</span>
)}
</span>
)}
</span>
</td>
<td>
{(draft.form.customer_name as string) || "\u2014"}
</td>
<td>{"\u2014"}</td>
<td className="admin-mono">
{draft.form.issue_date
? formatDate(draft.form.issue_date as string)
: "\u2014"}
</td>
<td className="admin-mono">
{draft.form.due_date
? formatDate(draft.form.due_date as string)
: "\u2014"}
</td>
<td />
<td>
<div className="admin-table-actions">
<Link
to="/invoices/new"
className="admin-btn-icon"
title="Pokračovat v konceptu"
aria-label="Pokračovat v konceptu"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</Link>
<button
onClick={discardDraft}
className="admin-btn-icon danger"
title="Zahodit koncept"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</td>
</tr>
)}
{invoices.map((inv) => {
const isOverdue =
inv.status === "overdue" ||
(inv.status === "issued" &&
inv.due_date &&
new Date(inv.due_date) <
new Date(new Date().toDateString()));
return (
<tr
key={inv.id}
className={isOverdue ? "offers-expired-row" : ""}
>
<td className="admin-mono">
<Link
to={`/invoices/${inv.id}`}
className="link-accent"
>
{inv.invoice_number}
</Link>
</td>
<td>{inv.customer_name || "\u2014"}</td>
<td>
{inv.status === "paid" ? (
<span
className={`admin-badge ${STATUS_CLASSES[inv.status]}`}
>
{STATUS_LABELS[inv.status]}
</span>
) : (
<button
onClick={() => toggleStatus(inv)}
className={`admin-badge ${STATUS_CLASSES[inv.status] || ""}`}
style={{ cursor: "pointer" }}
>
{STATUS_LABELS[inv.status] || inv.status}
</button>
)}
</td>
<td className="admin-mono">
{formatDate(inv.issue_date)}
</td>
<td
className="admin-mono"
style={
inv.status === "overdue"
? { color: "var(--danger)", fontWeight: 600 }
: undefined
}
>
{formatDate(inv.due_date)}
</td>
<td
className="admin-mono"
style={{ textAlign: "right", fontWeight: 500 }}
>
{formatCurrency(inv.total, inv.currency)}
</td>
<td>
<div className="admin-table-actions">
<Link
to={`/invoices/${inv.id}`}
className="admin-btn-icon"
title={
inv.status === "paid" ? "Detail" : "Upravit"
}
aria-label={
inv.status === "paid" ? "Detail" : "Upravit"
}
>
{inv.status === "paid" ? (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
) : (
</td>
<td>
{(draft.form.customer_name as string) ||
"\u2014"}
</td>
<td>{"\u2014"}</td>
<td className="admin-mono">
{draft.form.issue_date
? formatDate(draft.form.issue_date as string)
: "\u2014"}
</td>
<td className="admin-mono">
{draft.form.due_date
? formatDate(draft.form.due_date as string)
: "\u2014"}
</td>
<td />
<td>
<div className="admin-table-actions">
<Link
to="/invoices/new"
className="admin-btn-icon"
title="Pokračovat v konceptu"
aria-label="Pokračovat v konceptu"
>
<svg
width="18"
height="18"
@@ -880,51 +765,11 @@ export default function Invoices() {
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
)}
</Link>
{hasPermission("invoices.export") && (
</Link>
<button
onClick={() => handlePdf(inv)}
className="admin-btn-icon"
title="Zobrazit fakturu"
disabled={pdfLoading === inv.id}
>
{pdfLoading === inv.id ? (
<div
className="admin-spinner"
style={{
width: 18,
height: 18,
borderWidth: 2,
}}
/>
) : (
<svg
width="18"
height="18"
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" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
)}
</button>
)}
{hasPermission("invoices.delete") && (
<button
onClick={() =>
setDeleteConfirm({
show: true,
invoice: inv,
})
}
onClick={discardDraft}
className="admin-btn-icon danger"
title="Smazat"
title="Zahodit koncept"
>
<svg
width="18"
@@ -938,16 +783,195 @@ export default function Invoices() {
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</td>
</tr>
)}
{invoices.map((inv) => {
const isOverdue =
inv.status === "overdue" ||
(inv.status === "issued" &&
inv.due_date &&
new Date(inv.due_date) <
new Date(new Date().toDateString()));
return (
<tr
key={inv.id}
className={
isOverdue ? "offers-expired-row" : ""
}
>
<td className="admin-mono">
<Link
to={`/invoices/${inv.id}`}
className="link-accent"
>
{inv.invoice_number}
</Link>
</td>
<td>{inv.customer_name || "\u2014"}</td>
<td>
{inv.status === "paid" ? (
<span
className={`admin-badge ${STATUS_CLASSES[inv.status]}`}
>
{STATUS_LABELS[inv.status]}
</span>
) : (
<button
onClick={() => toggleStatus(inv)}
className={`admin-badge ${STATUS_CLASSES[inv.status] || ""}`}
style={{ cursor: "pointer" }}
>
{STATUS_LABELS[inv.status] || inv.status}
</button>
)}
</td>
<td className="admin-mono">
{formatDate(inv.issue_date)}
</td>
<td
className="admin-mono"
style={
inv.status === "overdue"
? {
color: "var(--danger)",
fontWeight: 600,
}
: undefined
}
>
{formatDate(inv.due_date)}
</td>
<td
className="admin-mono"
style={{
textAlign: "right",
fontWeight: 500,
}}
>
{formatCurrency(inv.total, inv.currency)}
</td>
<td>
<div className="admin-table-actions">
<Link
to={`/invoices/${inv.id}`}
className="admin-btn-icon"
title={
inv.status === "paid"
? "Detail"
: "Upravit"
}
aria-label={
inv.status === "paid"
? "Detail"
: "Upravit"
}
>
{inv.status === "paid" ? (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
) : (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
)}
</Link>
{hasPermission("invoices.export") && (
<button
onClick={() => handlePdf(inv)}
className="admin-btn-icon"
title="Zobrazit fakturu"
disabled={pdfLoading === inv.id}
>
{pdfLoading === inv.id ? (
<div
className="admin-spinner"
style={{
width: 18,
height: 18,
borderWidth: 2,
}}
/>
) : (
<svg
width="18"
height="18"
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" />
<line
x1="16"
y1="13"
x2="8"
y2="13"
/>
<line
x1="16"
y1="17"
x2="8"
y2="17"
/>
</svg>
)}
</button>
)}
{hasPermission("invoices.delete") && (
<button
onClick={() =>
setDeleteConfirm({
show: true,
invoice: inv,
})
}
className="admin-btn-icon danger"
title="Smazat"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</motion.div>
</AnimatePresence>
<Pagination pagination={pagination} onPageChange={setPage} />
</div>
</motion.div>

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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í",
};
@@ -69,6 +71,7 @@ interface Role {
description: string | null;
permissions: Permission[];
role_permissions?: unknown[];
user_count?: number;
}
interface RoleForm {
@@ -107,38 +110,44 @@ export default function Settings() {
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const canManage = hasPermission("settings.manage");
const canManageRoles = hasPermission("settings.roles");
const canManageCompany = hasPermission("settings.company");
const canManageSystem = hasPermission("settings.system");
const canManageBanking = hasPermission("settings.banking");
const canAccessSettings =
canManageRoles || canManageCompany || canManageSystem || canManageBanking;
// ── TanStack Query: roles, permissions, users ──
const availableTabs = useMemo(() => {
const tabs: Array<"roles" | "system" | "firma"> = [];
if (canManageRoles) tabs.push("roles");
if (canManageSystem) tabs.push("system");
if (canManageCompany || canManageBanking) tabs.push("firma");
return tabs;
}, [canManageRoles, canManageCompany, canManageSystem, canManageBanking]);
// ── TanStack Query: roles, permissions ──
const { data: rolesData, isPending: rolesLoading } = useQuery({
queryKey: ["roles"],
queryKey: ["settings", "roles"],
queryFn: async () => {
const [rolesRes, permsRes, usersRes] = await Promise.all([
const [rolesRes, permsRes] = await Promise.all([
apiFetch(`${API_BASE}/roles`),
apiFetch(`${API_BASE}/roles/permissions`),
apiFetch(`${API_BASE}/users`),
]);
const rolesResult = await rolesRes.json();
const permsResult = await permsRes.json();
const usersResult = await usersRes.json();
if (!rolesResult.success)
throw new Error(rolesResult.error || "Nepodařilo se načíst role");
if (!permsResult.success)
throw new Error(permsResult.error || "Nepodařilo se načíst oprávnění");
if (!usersResult.success)
throw new Error(usersResult.error || "Nepodařilo se načíst uživatele");
return {
roles: Array.isArray(rolesResult.data) ? rolesResult.data : [],
permissions: Array.isArray(permsResult.data) ? permsResult.data : [],
users: Array.isArray(usersResult.data) ? usersResult.data : [],
};
},
enabled: canManage,
enabled: canManageRoles,
});
const roles = rolesData?.roles ?? [];
const users = rolesData?.users ?? [];
// Group permissions by module
const permissionGroups = useMemo<Record<string, Permission[]>>(() => {
const perms: Permission[] = rolesData?.permissions ?? [];
@@ -156,27 +165,32 @@ export default function Settings() {
useQuery(require2FAOptions());
const require2FA = totpData?.require_2fa ?? false;
// ── TanStack Query: system settings (lazy, only on system/security tab) ──
// ── TanStack Query: system settings (lazy, only on system/roles tab) ──
const [searchParams, setSearchParams] = useSearchParams();
const tabParam = searchParams.get("tab");
const defaultTab = availableTabs[0] ?? "roles";
const activeTab = (
tabParam === "system"
? "system"
: tabParam === "firma"
? "firma"
: "security"
) as "security" | "system" | "firma";
const setActiveTab = (tab: "security" | "system" | "firma") =>
tabParam === "roles" && availableTabs.includes("roles")
? "roles"
: tabParam === "system" && availableTabs.includes("system")
? "system"
: tabParam === "firma" && availableTabs.includes("firma")
? "firma"
: defaultTab
) as "roles" | "system" | "firma";
const setActiveTab = (tab: "roles" | "system" | "firma") =>
setSearchParams({ tab }, { replace: true });
const { data: sysSettingsData, isPending: sysSettingsLoading } = useQuery({
...companySettingsOptions(),
enabled: canManage && (activeTab === "system" || activeTab === "security"),
enabled:
(canManageRoles || canManageSystem) &&
(activeTab === "system" || activeTab === "roles"),
});
const { data: systemInfo } = useQuery({
...systemInfoOptions(),
enabled: canManage && activeTab === "system",
enabled: canManageSystem && activeTab === "system",
});
// ── Local state ──
@@ -205,39 +219,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]);
@@ -245,7 +259,7 @@ export default function Settings() {
useModalLock(showModal);
// ── Early return after all hooks ──
if (!canManage) {
if (!canAccessSettings) {
return <Navigate to="/" replace />;
}
@@ -261,6 +275,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 +301,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 +422,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 +452,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");
}
@@ -440,7 +465,7 @@ export default function Settings() {
}
};
if (rolesLoading) {
if (canManageRoles && rolesLoading) {
return (
<Skeleton
name="settings"
@@ -503,36 +528,42 @@ export default function Settings() {
? "Systémová nastavení"
: activeTab === "firma"
? "Informace o firmě"
: "Zabezpečení a správa rolí"}
: "Role"}
</p>
</div>
</motion.div>
{canManage && (
{availableTabs.length > 0 && (
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "1.5rem" }}>
<button
className={`admin-btn admin-btn-sm ${activeTab === "security" ? "admin-btn-primary" : "admin-btn-secondary"}`}
onClick={() => setActiveTab("security")}
>
Zabezpečení a role
</button>
<button
className={`admin-btn admin-btn-sm ${activeTab === "system" ? "admin-btn-primary" : "admin-btn-secondary"}`}
onClick={() => setActiveTab("system")}
>
Systémová nastavení
</button>
<button
className={`admin-btn admin-btn-sm ${activeTab === "firma" ? "admin-btn-primary" : "admin-btn-secondary"}`}
onClick={() => setActiveTab("firma")}
>
Firma
</button>
{availableTabs.includes("roles") && (
<button
className={`admin-btn admin-btn-sm ${activeTab === "roles" ? "admin-btn-primary" : "admin-btn-secondary"}`}
onClick={() => setActiveTab("roles")}
>
Role
</button>
)}
{availableTabs.includes("system") && (
<button
className={`admin-btn admin-btn-sm ${activeTab === "system" ? "admin-btn-primary" : "admin-btn-secondary"}`}
onClick={() => setActiveTab("system")}
>
Systémová nastavení
</button>
)}
{availableTabs.includes("firma") && (
<button
className={`admin-btn admin-btn-sm ${activeTab === "firma" ? "admin-btn-primary" : "admin-btn-secondary"}`}
onClick={() => setActiveTab("firma")}
>
Firma
</button>
)}
</div>
)}
{/* Security Settings */}
{activeTab === "security" && canManage && (
{activeTab === "system" && canManageSystem && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
@@ -616,7 +647,7 @@ export default function Settings() {
)}
{/* Login Security */}
{activeTab === "security" && canManage && (
{activeTab === "system" && canManageSystem && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
@@ -673,7 +704,7 @@ export default function Settings() {
)}
{/* Roles Table */}
{activeTab === "security" && canManage && (
{activeTab === "roles" && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
@@ -752,11 +783,7 @@ export default function Settings() {
</td>
<td>
<span className="admin-badge admin-badge-secondary">
{
users.filter(
(u: { role_id: number }) => u.role_id === role.id,
).length
}
{role.user_count ?? 0}
</span>
</td>
<td>
@@ -786,27 +813,16 @@ export default function Settings() {
}
className="admin-btn-icon danger"
title={
users.filter(
(u: { role_id: number }) =>
u.role_id === role.id,
).length > 0
(role.user_count ?? 0) > 0
? "Nelze smazat roli s přiřazenými uživateli"
: "Smazat"
}
aria-label={
users.filter(
(u: { role_id: number }) =>
u.role_id === role.id,
).length > 0
(role.user_count ?? 0) > 0
? "Nelze smazat roli s přiřazenými uživateli"
: "Smazat"
}
disabled={
users.filter(
(u: { role_id: number }) =>
u.role_id === role.id,
).length > 0
}
disabled={(role.user_count ?? 0) > 0}
>
<svg
width="16"
@@ -833,7 +849,7 @@ export default function Settings() {
)}
{/* System Settings Tab */}
{activeTab === "system" && canManage && (
{activeTab === "system" && (
<>
{sysSettingsLoading && !sysFormInitialized ? (
<Skeleton
@@ -1039,260 +1055,7 @@ export default function Settings() {
</div>
</motion.div>
{/* Section 5: Číslování dokladů */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.28 }}
>
<div className="admin-card-header">
<h2 className="admin-card-title">Číslování dokladů</h2>
</div>
<div className="admin-card-body">
<div className="admin-form">
<div
style={{
padding: "0.75rem",
background: "var(--bg-tertiary)",
borderRadius: 8,
fontSize: "0.8rem",
color: "var(--text-secondary)",
marginBottom: "1rem",
}}
>
<strong>Dostupné zástupné znaky:</strong>{" "}
<code>{"{YYYY}"}</code> rok, <code>{"{YY}"}</code> rok (2
číslice), <code>{"{PREFIX}"}</code> prefix nabídky,{" "}
<code>{"{CODE}"}</code> typový kód, <code>{"{NNN}"}</code>{" "}
pořadí (3 číslice), <code>{"{NNNN}"}</code> pořadí (4
číslice), <code>{"{NNNNN}"}</code> pořadí (5 číslic)
</div>
{[
{
label: "Nabídky",
patternKey: "offer_number_pattern" as const,
defaultPattern: "{YYYY}/{PREFIX}/{NNN}",
prefixKey: "quotation_prefix" as const,
prefixLabel: "Prefix",
codeKey: null,
},
{
label: "Objednávky a projekty",
patternKey: "order_number_pattern" as const,
defaultPattern: "{YY}{CODE}{NNNN}",
prefixKey: null,
codeKey: "order_type_code" as const,
codeLabel: "Typový kód",
},
{
label: "Faktury",
patternKey: "invoice_number_pattern" as const,
defaultPattern: "{YY}{CODE}{NNNN}",
prefixKey: null,
codeKey: "invoice_type_code" as const,
codeLabel: "Typový kód",
},
].map((cfg, idx) => {
const pattern =
sysForm[cfg.patternKey] || cfg.defaultPattern;
const yyyy = String(new Date().getFullYear());
const yy = yyyy.slice(-2);
const prefix = cfg.prefixKey
? sysForm[cfg.prefixKey] || "NA"
: "";
const code = cfg.codeKey
? sysForm[cfg.codeKey] || ""
: "";
const preview = pattern.replace(
/\{(\w+)\}/g,
(m: string, k: string) => {
if (k === "YYYY") return yyyy;
if (k === "YY") return yy;
if (k === "PREFIX") return prefix;
if (k === "CODE") return code;
if (/^N+$/.test(k))
return "1".padStart(k.length, "0");
return m;
},
);
return (
<div key={cfg.patternKey}>
{idx > 0 && (
<hr
style={{
border: "none",
borderTop: "1px solid var(--border-color)",
margin: "1rem 0",
}}
/>
)}
<div
className="fw-600 text-md"
style={{
marginBottom: "0.5rem",
}}
>
{cfg.label}
</div>
<div className="admin-form-row">
<FormField label="Formát">
<input
type="text"
value={sysForm[cfg.patternKey]}
onChange={(e) =>
setSysForm((p) => ({
...p,
[cfg.patternKey]: e.target.value,
}))
}
className="admin-form-input"
placeholder={cfg.defaultPattern}
/>
</FormField>
{cfg.prefixKey && (
<FormField label="Prefix">
<input
type="text"
value={sysForm[cfg.prefixKey]}
onChange={(e) =>
setSysForm((p) => ({
...p,
[cfg.prefixKey!]: e.target.value,
}))
}
className="admin-form-input"
style={{ maxWidth: 100 }}
/>
</FormField>
)}
{cfg.codeKey && (
<FormField label={cfg.codeLabel || "Kód"}>
<input
type="text"
value={sysForm[cfg.codeKey]}
onChange={(e) =>
setSysForm((p) => ({
...p,
[cfg.codeKey!]: e.target.value,
}))
}
className="admin-form-input"
style={{ maxWidth: 100 }}
/>
</FormField>
)}
</div>
<small
style={{
color: "var(--text-tertiary)",
fontSize: "0.8rem",
}}
>
Ukázka:{" "}
<strong style={{ color: "var(--text-primary)" }}>
{preview}
</strong>
</small>
</div>
);
})}
</div>
</div>
</motion.div>
{/* Section 6: Měna a DPH */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.3 }}
>
<div className="admin-card-header">
<h2 className="admin-card-title">Měna a DPH</h2>
</div>
<div className="admin-card-body">
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Výchozí měna">
<select
value={sysForm.default_currency}
onChange={(e) =>
setSysForm((prev) => ({
...prev,
default_currency: e.target.value,
}))
}
className="admin-form-input"
>
{sysForm.available_currencies.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</FormField>
<FormField label="Výchozí sazba DPH (%)">
<input
type="number"
value={sysForm.default_vat_rate}
onChange={(e) =>
setSysForm((prev) => ({
...prev,
default_vat_rate: Number(e.target.value),
}))
}
className="admin-form-input"
min={0}
step={1}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Dostupné měny">
<input
type="text"
value={sysForm.available_currencies.join(", ")}
onChange={(e) =>
setSysForm((prev) => ({
...prev,
available_currencies: e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
}))
}
className="admin-form-input"
placeholder="CZK, EUR, USD"
/>
</FormField>
<FormField label="Dostupné sazby DPH (%)">
<input
type="text"
value={sysForm.available_vat_rates.join(", ")}
onChange={(e) =>
setSysForm((prev) => ({
...prev,
available_vat_rates: e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map(Number)
.filter((n) => !isNaN(n)),
}))
}
className="admin-form-input"
placeholder="0, 12, 21"
/>
</FormField>
</div>
</div>
</div>
</motion.div>
{/* Section 6: Informace o aplikaci */}
{/* Section 5: Informace o aplikaci */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
@@ -1602,7 +1365,7 @@ export default function Settings() {
)}
{/* Firma tab */}
{activeTab === "firma" && canManage && <CompanySettings embedded />}
{activeTab === "firma" && <CompanySettings embedded />}
{/* Create/Edit Modal */}
<AnimatePresence>

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

@@ -54,3 +54,25 @@ export function requirePermission(...permissionNames: string[]) {
}
};
}
export function requireAnyPermission(...permissionNames: string[]) {
return async (
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> => {
await requireAuth(request, reply);
if (reply.sent) return;
const authData = request.authData!;
// Admin has all permissions
if (authData.roleName === "admin") return;
const hasAny = permissionNames.some((p) =>
authData.permissions.includes(p),
);
if (!hasAny) {
return error(reply, "Nedostatečná oprávnění", 403);
}
};
}

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({
@@ -220,8 +216,20 @@ export default async function attendanceRoutes(
userId,
isAdmin,
authUserId: authData.userId,
month: query.month ? Number(query.month) : undefined,
year: query.year ? Number(query.year) : undefined,
month: query.month
? String(query.month).includes("-")
? Number(String(query.month).split("-")[1])
: Number(query.month)
: undefined,
year: query.month
? String(query.month).includes("-")
? Number(String(query.month).split("-")[0])
: query.year
? Number(query.year)
: undefined
: query.year
? Number(query.year)
: undefined,
});
return reply.send({
@@ -273,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);
}
@@ -316,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);
}
@@ -372,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);
}
@@ -417,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;
@@ -426,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,
@@ -469,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

@@ -3,6 +3,7 @@ import prisma from "../../config/database";
import {
requireAuth,
requirePermission,
requireAnyPermission,
optionalAuth,
} from "../../middleware/auth";
import { logAudit } from "../../services/audit";
@@ -99,7 +100,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 +334,7 @@ export default async function companySettingsRoutes(
// GET /api/admin/company-settings/system-info
fastify.get(
"/system-info",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.system") },
async (request, reply) => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const pkg = require("../../../package.json") as { version: string };
@@ -404,7 +405,7 @@ export default async function companySettingsRoutes(
fastify.put(
"/",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requireAnyPermission("settings.company", "settings.system") },
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

@@ -168,6 +168,24 @@ function buildAddressLines(
/* ── Translations ────────────────────────────────────────────────── */
const paymentMethodMap: Record<string, Record<string, string>> = {
cs: {
"Bank transfer": "Příkazem",
Cash: "Hotově",
"Cash on delivery": "Dobírka",
},
en: {
Příkazem: "Bank transfer",
Hotově: "Cash",
Dobírka: "Cash on delivery",
},
};
function translatePaymentMethod(value: string | null, lang: string): string {
if (!value) return "";
return paymentMethodMap[lang]?.[value] || value;
}
const translations: Record<string, Record<string, string>> = {
cs: {
title: "Faktura",
@@ -181,6 +199,7 @@ const translations: Record<string, Record<string, string>> = {
var_symbol: "Variabilní s.:",
const_symbol: "Konstantní s.:",
order_no: "Objednávka č.:",
order_date: "Objednávka ze dne:",
issue_date: "Datum vystavení:",
due_date: "Datum splatnosti:",
tax_date: "Datum uskutečnění plnění:",
@@ -213,6 +232,7 @@ const translations: Record<string, Record<string, string>> = {
stamp: "Razítko:",
ico: "IČ: ",
dic: "DIČ: ",
cnb_rate: "Přepočet kurzem ČNB ke dni",
},
en: {
title: "Invoice",
@@ -226,6 +246,7 @@ const translations: Record<string, Record<string, string>> = {
var_symbol: "Variable symbol:",
const_symbol: "Constant symbol:",
order_no: "Order No.:",
order_date: "Order date:",
issue_date: "Issue date:",
due_date: "Due date:",
tax_date: "Tax point date:",
@@ -257,6 +278,7 @@ const translations: Record<string, Record<string, string>> = {
stamp: "Stamp:",
ico: "Reg. No.: ",
dic: "Tax ID: ",
cnb_rate: "CNB exchange rate as of",
},
};
@@ -450,10 +472,11 @@ export default async function invoicesPdfRoutes(
const lineVat = applyVat ? (lineSubtotal * vatRate) / 100 : 0;
const lineTotal = lineSubtotal + lineVat;
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
const subDesc = item.item_description || "";
return `<tr>
<td class="row-num">${i + 1}</td>
<td class="desc">${escapeHtml(item.description)}</td>
<td class="desc">${escapeHtml(item.description)}${subDesc ? `<div class="item-subdesc">${escapeHtml(subDesc)}</div>` : ""}</td>
<td class="center">${formatNum(qty, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
<td class="right">${formatNum(unitPrice)}</td>
<td class="right">${formatNum(lineSubtotal)}</td>
@@ -693,6 +716,11 @@ export default async function invoicesPdfRoutes(
font-weight: 600;
color: #1a1a1a;
}
.item-subdesc {
font-size: 9pt;
color: #646464;
margin-top: 2px;
}
table.items tbody td.total-cell { font-weight: 700; }
/* Soucet + total - styl z nabidek */
@@ -917,9 +945,9 @@ ${indentCSS}
<div class="info-row"><span class="lbl">${escapeHtml(t.issue_date)}</span> <span class="val">${escapeHtml(formatDate(invoice.issue_date))}</span></div>
<div class="info-row"><span class="lbl">${escapeHtml(t.due_date)}</span> <span class="val">${escapeHtml(formatDate(invoice.due_date))}</span></div>
<div class="info-row"><span class="lbl">${escapeHtml(t.tax_date)}</span> <span class="val">${escapeHtml(formatDate(invoice.tax_date))}</span></div>
<div class="info-row"><span class="lbl">${escapeHtml(t.payment_method)}</span> <span class="val">${escapeHtml(invoice.payment_method)}</span></div>
${orderNumber ? `<div class="info-row"><span class="lbl">${lang === "cs" ? "Objednávka č.:" : "Order no.:"}</span> <span class="val">${orderNumber}</span></div>` : ""}
${orderDate ? `<div class="info-row"><span class="lbl">${lang === "cs" ? "Objednávka ze dne:" : "Order date:"}</span> <span class="val">${escapeHtml(orderDate)}</span></div>` : ""}
<div class="info-row"><span class="lbl">${escapeHtml(t.payment_method)}</span> <span class="val">${escapeHtml(translatePaymentMethod(invoice.payment_method, lang))}</span></div>
${orderNumber ? `<div class="info-row"><span class="lbl">${escapeHtml(t.order_no)}</span> <span class="val">${orderNumber}</span></div>` : ""}
${orderDate ? `<div class="info-row"><span class="lbl">${escapeHtml(t.order_date)}</span> <span class="val">${escapeHtml(orderDate)}</span></div>` : ""}
</td>
</tr>
</table>
@@ -1002,7 +1030,7 @@ ${indentCSS}
? `<tfoot>
<tr>
<td colspan="4" style="font-size:0.7em; color:#666; padding-top:6px; text-align:left;">
Přepočet kurzem ČNB ke dni ${formatDate(invoice.issue_date)}: 1 ${escapeHtml(currency)} = ${cnbRate.toFixed(3).replace(".", ",")} CZK
${escapeHtml(t.cnb_rate)} ${formatDate(invoice.issue_date)}: 1 ${escapeHtml(currency)} = ${cnbRate.toFixed(3).replace(".", ",")} CZK
</td>
</tr>
</tfoot>`

View File

@@ -186,7 +186,6 @@ function buildAddressLines(
const TRANSLATIONS: Record<string, Record<string, string>> = {
title: { EN: "PRICE QUOTATION", CZ: "CENOV\u00C1 NAB\u00CDDKA" },
scope_title: { EN: "SCOPE OF THE PROJECT", CZ: "ROZSAH PROJEKTU" },
valid_until: { EN: "Valid until", CZ: "Platnost do" },
customer: { EN: "Customer", CZ: "Z\u00E1kazn\u00EDk" },
supplier: { EN: "Supplier", CZ: "Dodavatel" },
@@ -199,7 +198,6 @@ const TRANSLATIONS: Record<string, Record<string, string>> = {
subtotal: { EN: "Subtotal", CZ: "Mezisou\u010Det" },
vat: { EN: "VAT", CZ: "DPH" },
total_to_pay: { EN: "Total to pay", CZ: "Celkem k \u00FAhrad\u011B" },
exchange_rate: { EN: "Exchange rate", CZ: "Sm\u011Bnn\u00FD kurz" },
ico: { EN: "ID", CZ: "I\u010CO" },
dic: { EN: "VAT ID", CZ: "DI\u010C" },
page: { EN: "Page", CZ: "Strana" },
@@ -262,8 +260,6 @@ export default async function offersPdfRoutes(
const vatRate = Number(quotation.vat_rate) || 21;
const vatAmount = applyVat ? subtotal * (vatRate / 100) : 0;
const totalToPay = subtotal + vatAmount;
const exchangeRate = Number(quotation.exchange_rate) || 0;
let hasScopeContent = false;
for (const s of quotation.scope_sections) {
if ((s.content || "").trim() || (s.title || "").trim()) {
@@ -331,10 +327,6 @@ export default async function offersPdfRoutes(
<span class="label">${escapeHtml(t("total_to_pay"))}</span>
<span class="value">${formatCurrency(totalToPay, currency)}</span>
</div>`;
if (exchangeRate > 0) {
totalsHtml += `<div class="exchange-rate">${escapeHtml(t("exchange_rate"))}: ${formatNum(exchangeRate, 4)}</div>`;
}
const quotationNumber = escapeHtml(quotation.quotation_number);
let scopeHtml = "";
@@ -578,12 +570,6 @@ ${indentCSS}
border-bottom: 2.5pt solid #de3a3a;
padding-bottom: 1mm;
}
.totals .exchange-rate {
text-align: right;
font-size: 7.5pt;
color: #969696;
margin-top: 3mm;
}
/* ---- Scope sections ---- */
.scope-page {
@@ -632,14 +618,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 +674,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 +691,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 +731,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,20 +12,31 @@ 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: {
role_permissions: {
include: { permissions: true },
const [roles, userCounts] = await Promise.all([
prisma.roles.findMany({
include: {
role_permissions: {
include: { permissions: true },
},
},
},
orderBy: { id: "asc" },
});
orderBy: { id: "asc" },
}),
prisma.users.groupBy({
by: ["role_id"],
_count: { id: true },
}),
]);
const countMap = new Map(
userCounts.map((u) => [u.role_id, Number(u._count.id)]),
);
const data = roles.map((r) => ({
...r,
permissions: r.role_permissions.map((rp) => rp.permissions),
user_count: countMap.get(r.id) || 0,
}));
return success(reply, data);
@@ -35,10 +46,10 @@ 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" },
orderBy: [{ module: "asc" }, { id: "asc" }],
});
return success(reply, permissions);
},
@@ -47,7 +58,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 +101,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 +153,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.system")],
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

@@ -2,6 +2,7 @@ import { z } from "zod";
const InvoiceItemSchema = z.object({
description: z.string().nullish(),
item_description: z.string().nullish(),
quantity: z
.union([z.number(), z.string()])
.transform((v) => {

View File

@@ -46,6 +46,7 @@ export const CreateQuotationSchema = z.object({
.transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Musí být platné číslo" })
.nullish(),
created_at: z.string().nullish(),
valid_until: z.string().nullish(),
currency: z.string().optional().default("CZK"),
language: z.string().optional().default("cs"),
@@ -59,16 +60,10 @@ export const CreateQuotationSchema = z.object({
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
.optional()
.default(true),
exchange_rate: z
.union([z.number(), z.string()])
.transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional()
.default(1.0),
status: z
.enum(["nova", "odeslana", "prijata", "odmitnuta", "dokoncena", "active"])
.enum(["active", "ordered", "invalidated"])
.optional()
.default("nova"),
.default("active"),
scope_title: z.string().nullish(),
scope_description: z.string().nullish(),
items: z.array(QuotationItemSchema).optional(),
@@ -82,6 +77,7 @@ export const UpdateQuotationSchema = z.object({
.transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional(),
created_at: z.union([z.string(), z.null()]).optional(),
valid_until: z.union([z.string(), z.null()]).optional(),
currency: z.string().optional(),
language: z.string().optional(),
@@ -93,14 +89,7 @@ export const UpdateQuotationSchema = z.object({
apply_vat: z
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
.optional(),
exchange_rate: z
.union([z.number(), z.string()])
.transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional(),
status: z
.enum(["nova", "odeslana", "prijata", "odmitnuta", "dokoncena", "active"])
.optional(),
status: z.enum(["active", "ordered", "invalidated"]).optional(),
scope_title: z.string().nullish(),
scope_description: z.string().nullish(),
items: z.array(QuotationItemSchema).optional(),

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

@@ -23,6 +23,7 @@ const ALLOWED_SORT_FIELDS = [
interface InvoiceItemInput {
description?: string;
item_description?: string;
quantity?: number;
unit?: string;
unit_price?: number;
@@ -374,6 +375,7 @@ export async function createInvoice(body: Record<string, any>) {
data: (body.items as InvoiceItemInput[]).map((item, i) => ({
invoice_id: invoice.id,
description: item.description ?? null,
item_description: item.item_description ?? null,
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
@@ -471,6 +473,7 @@ export async function updateInvoice(id: number, body: Record<string, any>) {
data: (body.items as InvoiceItemInput[]).map((item, i) => ({
invoice_id: id,
description: item.description ?? null,
item_description: item.item_description ?? null,
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,

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 };
}
@@ -158,6 +177,9 @@ export async function createOffer(body: Record<string, any>) {
quotation_number: quotationNumber,
project_code: body.project_code ? String(body.project_code) : null,
customer_id: body.customer_id ? Number(body.customer_id) : null,
created_at: body.created_at
? new Date(String(body.created_at))
: undefined,
valid_until: body.valid_until
? new Date(String(body.valid_until))
: null,
@@ -165,7 +187,6 @@ export async function createOffer(body: Record<string, any>) {
language: body.language ? String(body.language) : "cs",
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
apply_vat: body.apply_vat !== false,
exchange_rate: body.exchange_rate ? Number(body.exchange_rate) : 1.0,
status: body.status ? String(body.status) : "active",
scope_title: body.scope_title ? String(body.scope_title) : null,
scope_description: body.scope_description
@@ -221,6 +242,12 @@ export async function updateOffer(id: number, body: Record<string, any>) {
const data = {
customer_id:
body.customer_id !== undefined ? Number(body.customer_id) : undefined,
created_at:
body.created_at !== undefined
? body.created_at
? new Date(String(body.created_at))
: null
: undefined,
valid_until:
body.valid_until !== undefined
? body.valid_until
@@ -236,8 +263,6 @@ export async function updateOffer(id: number, body: Record<string, any>) {
body.apply_vat === 1 ||
body.apply_vat === "1"
: undefined,
exchange_rate:
body.exchange_rate !== undefined ? Number(body.exchange_rate) : undefined,
status: body.status !== undefined ? String(body.status) : undefined,
project_code:
body.project_code !== undefined
@@ -335,7 +360,6 @@ export async function duplicateOffer(id: number) {
language: original.language,
vat_rate: original.vat_rate,
apply_vat: original.apply_vat,
exchange_rate: original.exchange_rate,
status: "active",
scope_title: original.scope_title,
scope_description: original.scope_description,

View File

@@ -206,7 +206,6 @@ export async function createOrderFromQuotation(
language: quotation.language || "cs",
vat_rate: quotation.vat_rate ?? 21.0,
apply_vat: quotation.apply_vat ?? true,
exchange_rate: quotation.exchange_rate ?? 1.0,
scope_title: quotation.scope_title,
scope_description: quotation.scope_description,
attachment_data: attachmentBuffer

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,