Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2254a13ad0 | ||
|
|
1f5885de84 | ||
|
|
705f58e3d1 | ||
|
|
0330453ad6 | ||
|
|
66b98e2765 | ||
|
|
55648c9a30 | ||
|
|
cd6d3daf43 | ||
|
|
1db5060c42 | ||
|
|
4cabba395d | ||
|
|
59b478f262 |
91
CLAUDE.md
91
CLAUDE.md
@@ -75,10 +75,14 @@ npm test # vitest run (single pass)
|
|||||||
npm run test:watch # vitest watch
|
npm run test:watch # vitest watch
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
npx prisma migrate dev # Apply migrations (dev)
|
npx prisma migrate dev # Create migration from schema changes + apply to dev
|
||||||
npx prisma migrate deploy # Apply migrations (prod)
|
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 generate # Regenerate Prisma client after schema changes
|
||||||
npx prisma studio # DB browser GUI
|
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.
|
**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`.
|
- Custom hooks: `useApiCall`, `useListData`, `useTableSort`, `useDebounce`, `useModalLock`.
|
||||||
- Styling: CSS files in `src/admin/` — no CSS-in-JS, no Tailwind. Use CSS variables.
|
- 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
|
## 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
|
## 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.
|
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.
|
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.
|
9. **No CSRF tokens** — CSRF protection relies on `SameSite=Strict` cookies + CORS. Do not weaken CORS configuration.
|
||||||
|
|
||||||
|
|||||||
572
package-lock.json
generated
572
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "1.6.1",
|
"version": "1.7.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -18,7 +18,10 @@
|
|||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"bones": "boneyard-js build http://localhost:3000"
|
"seed": "tsx prisma/seed.ts"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "tsx prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@@ -38,7 +41,6 @@
|
|||||||
"@tanstack/react-query": "^5.100.5",
|
"@tanstack/react-query": "^5.100.5",
|
||||||
"@types/jsdom": "^28.0.1",
|
"@types/jsdom": "^28.0.1",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"boneyard-js": "^1.8.1",
|
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dompurify": "^3.3.3",
|
"dompurify": "^3.3.3",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
-- Add billing_text column to invoices table
|
|
||||||
ALTER TABLE `invoices` ADD COLUMN `billing_text` VARCHAR(500) NULL AFTER `issued_by`;
|
|
||||||
@@ -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`;
|
|
||||||
@@ -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;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE `invoices` ADD COLUMN `language` VARCHAR(5) DEFAULT 'cs' AFTER `billing_text`;
|
|
||||||
@@ -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`;
|
|
||||||
@@ -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`;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE `company_settings` ADD COLUMN `logo_data_dark` MEDIUMBLOB NULL AFTER `logo_data`;
|
|
||||||
@@ -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;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
ALTER TABLE `company_settings`
|
|
||||||
ADD COLUMN `smtp_from` VARCHAR(255) NULL,
|
|
||||||
ADD COLUMN `smtp_from_name` VARCHAR(255) NULL;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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');
|
|
||||||
@@ -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`);
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE `users` ADD COLUMN `totp_last_used_counter` INTEGER NULL;
|
|
||||||
@@ -98,6 +98,7 @@ CREATE TABLE `company_settings` (
|
|||||||
`vat_id` VARCHAR(50) NULL,
|
`vat_id` VARCHAR(50) NULL,
|
||||||
`custom_fields` LONGTEXT NULL,
|
`custom_fields` LONGTEXT NULL,
|
||||||
`logo_data` LONGBLOB NULL,
|
`logo_data` LONGBLOB NULL,
|
||||||
|
`logo_data_dark` MEDIUMBLOB NULL,
|
||||||
`quotation_prefix` VARCHAR(20) NULL,
|
`quotation_prefix` VARCHAR(20) NULL,
|
||||||
`default_currency` VARCHAR(10) NULL DEFAULT 'CZK',
|
`default_currency` VARCHAR(10) NULL DEFAULT 'CZK',
|
||||||
`default_vat_rate` DECIMAL(5, 2) NULL DEFAULT 21.00,
|
`default_vat_rate` DECIMAL(5, 2) NULL DEFAULT 21.00,
|
||||||
@@ -108,7 +109,24 @@ CREATE TABLE `company_settings` (
|
|||||||
`order_type_code` VARCHAR(10) NULL,
|
`order_type_code` VARCHAR(10) NULL,
|
||||||
`invoice_type_code` VARCHAR(10) NULL,
|
`invoice_type_code` VARCHAR(10) NULL,
|
||||||
`require_2fa` BOOLEAN NOT NULL DEFAULT false,
|
`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`)
|
PRIMARY KEY (`id`)
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
@@ -167,14 +185,18 @@ CREATE TABLE `invoices` (
|
|||||||
`tax_date` DATE NULL,
|
`tax_date` DATE NULL,
|
||||||
`paid_date` DATE NULL,
|
`paid_date` DATE NULL,
|
||||||
`issued_by` VARCHAR(255) NULL,
|
`issued_by` VARCHAR(255) NULL,
|
||||||
|
`billing_text` VARCHAR(500) NULL,
|
||||||
|
`language` VARCHAR(5) NULL DEFAULT 'cs',
|
||||||
`notes` TEXT NULL,
|
`notes` TEXT NULL,
|
||||||
`internal_notes` TEXT NULL,
|
`internal_notes` TEXT NULL,
|
||||||
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
`modified_at` DATETIME(0) NULL,
|
`modified_at` DATETIME(0) NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `idx_invoices_number_unique`(`invoice_number`),
|
||||||
INDEX `customer_id`(`customer_id`),
|
INDEX `customer_id`(`customer_id`),
|
||||||
INDEX `idx_invoices_due_date`(`due_date`),
|
INDEX `idx_invoices_due_date`(`due_date`),
|
||||||
INDEX `idx_invoices_status_issue`(`status`, `issue_date`),
|
INDEX `idx_invoices_status_issue`(`status`, `issue_date`),
|
||||||
|
INDEX `idx_invoices_status_due`(`status`, `due_date`),
|
||||||
INDEX `order_id`(`order_id`),
|
INDEX `order_id`(`order_id`),
|
||||||
PRIMARY KEY (`id`)
|
PRIMARY KEY (`id`)
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
@@ -239,6 +261,7 @@ CREATE TABLE `number_sequences` (
|
|||||||
`year` INTEGER NULL,
|
`year` INTEGER NULL,
|
||||||
`last_number` INTEGER NULL DEFAULT 0,
|
`last_number` INTEGER NULL DEFAULT 0,
|
||||||
|
|
||||||
|
UNIQUE INDEX `idx_number_sequences_type_year`(`type`, `year`),
|
||||||
PRIMARY KEY (`id`)
|
PRIMARY KEY (`id`)
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
@@ -294,6 +317,7 @@ CREATE TABLE `orders` (
|
|||||||
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
`modified_at` DATETIME(0) NULL,
|
`modified_at` DATETIME(0) NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `idx_orders_number_unique`(`order_number`),
|
||||||
INDEX `customer_id`(`customer_id`),
|
INDEX `customer_id`(`customer_id`),
|
||||||
INDEX `quotation_id`(`quotation_id`),
|
INDEX `quotation_id`(`quotation_id`),
|
||||||
PRIMARY KEY (`id`)
|
PRIMARY KEY (`id`)
|
||||||
@@ -341,6 +365,7 @@ CREATE TABLE `projects` (
|
|||||||
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
`modified_at` DATETIME(0) NULL,
|
`modified_at` DATETIME(0) NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `projects_project_number_key`(`project_number`),
|
||||||
INDEX `customer_id`(`customer_id`),
|
INDEX `customer_id`(`customer_id`),
|
||||||
INDEX `fk_projects_responsible_user`(`responsible_user_id`),
|
INDEX `fk_projects_responsible_user`(`responsible_user_id`),
|
||||||
INDEX `order_id`(`order_id`),
|
INDEX `order_id`(`order_id`),
|
||||||
@@ -386,10 +411,13 @@ CREATE TABLE `quotations` (
|
|||||||
`status` VARCHAR(20) NOT NULL DEFAULT 'active',
|
`status` VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||||
`scope_title` VARCHAR(500) NULL,
|
`scope_title` VARCHAR(500) NULL,
|
||||||
`scope_description` TEXT NULL,
|
`scope_description` TEXT NULL,
|
||||||
|
`locked_by` INTEGER NULL,
|
||||||
|
`locked_at` DATETIME(0) NULL,
|
||||||
`uuid` VARCHAR(36) NULL,
|
`uuid` VARCHAR(36) NULL,
|
||||||
`modified_at` DATETIME(0) NULL,
|
`modified_at` DATETIME(0) NULL,
|
||||||
`sync_version` INTEGER NULL DEFAULT 0,
|
`sync_version` INTEGER NULL DEFAULT 0,
|
||||||
|
|
||||||
|
UNIQUE INDEX `quotations_quotation_number_key`(`quotation_number`),
|
||||||
INDEX `customer_id`(`customer_id`),
|
INDEX `customer_id`(`customer_id`),
|
||||||
INDEX `idx_quotations_number`(`quotation_number`),
|
INDEX `idx_quotations_number`(`quotation_number`),
|
||||||
PRIMARY KEY (`id`)
|
PRIMARY KEY (`id`)
|
||||||
@@ -411,7 +439,6 @@ CREATE TABLE `received_invoices` (
|
|||||||
`due_date` DATE NULL,
|
`due_date` DATE NULL,
|
||||||
`paid_date` DATE NULL,
|
`paid_date` DATE NULL,
|
||||||
`status` ENUM('unpaid', 'paid') NOT NULL DEFAULT 'unpaid',
|
`status` ENUM('unpaid', 'paid') NOT NULL DEFAULT 'unpaid',
|
||||||
`file_data` MEDIUMBLOB NULL,
|
|
||||||
`file_name` VARCHAR(255) NULL,
|
`file_name` VARCHAR(255) NULL,
|
||||||
`file_mime` VARCHAR(100) NULL,
|
`file_mime` VARCHAR(100) NULL,
|
||||||
`file_size` INTEGER UNSIGNED NULL,
|
`file_size` INTEGER UNSIGNED NULL,
|
||||||
@@ -572,6 +599,7 @@ CREATE TABLE `users` (
|
|||||||
`totp_secret` VARCHAR(255) NULL,
|
`totp_secret` VARCHAR(255) NULL,
|
||||||
`totp_enabled` BOOLEAN NOT NULL DEFAULT false,
|
`totp_enabled` BOOLEAN NOT NULL DEFAULT false,
|
||||||
`totp_backup_codes` TEXT NULL,
|
`totp_backup_codes` TEXT NULL,
|
||||||
|
`totp_last_used_counter` INTEGER NULL,
|
||||||
|
|
||||||
UNIQUE INDEX `username`(`username`),
|
UNIQUE INDEX `username`(`username`),
|
||||||
UNIQUE INDEX `email`(`email`),
|
UNIQUE INDEX `email`(`email`),
|
||||||
@@ -597,12 +625,28 @@ CREATE TABLE `vehicles` (
|
|||||||
PRIMARY KEY (`id`)
|
PRIMARY KEY (`id`)
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
) 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
|
-- AddForeignKey
|
||||||
ALTER TABLE `attendance` ADD CONSTRAINT `attendance_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
ALTER TABLE `attendance` ADD CONSTRAINT `attendance_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- 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;
|
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
|
-- 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;
|
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
|
-- AddForeignKey
|
||||||
ALTER TABLE `users` ADD CONSTRAINT `users_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE SET NULL ON UPDATE NO ACTION;
|
ALTER TABLE `users` ADD CONSTRAINT `users_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||||
|
|
||||||
@@ -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`;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `quotations` DROP COLUMN `exchange_rate`;
|
||||||
|
ALTER TABLE `quotations` DROP COLUMN `exchange_rate_date`;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `invoice_items` ADD COLUMN `item_description` TEXT NULL;
|
||||||
@@ -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`;
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
# Please do not edit this file manually
|
# 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"
|
provider = "mysql"
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ model attendance {
|
|||||||
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "attendance_ibfk_1")
|
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "attendance_ibfk_1")
|
||||||
attendance_project_logs attendance_project_logs[]
|
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([user_id, departure_time], map: "idx_attendance_user_departure")
|
||||||
@@index([project_id], map: "idx_project_id")
|
@@index([project_id], map: "idx_project_id")
|
||||||
}
|
}
|
||||||
@@ -101,7 +101,7 @@ model company_settings {
|
|||||||
vat_id String? @db.VarChar(50)
|
vat_id String? @db.VarChar(50)
|
||||||
custom_fields String? @db.LongText
|
custom_fields String? @db.LongText
|
||||||
logo_data Bytes?
|
logo_data Bytes?
|
||||||
logo_data_dark Bytes?
|
logo_data_dark Bytes? @db.MediumBlob
|
||||||
quotation_prefix String? @db.VarChar(20)
|
quotation_prefix String? @db.VarChar(20)
|
||||||
default_currency String? @default("CZK") @db.VarChar(10)
|
default_currency String? @default("CZK") @db.VarChar(10)
|
||||||
default_vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
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)
|
order_type_code String? @db.VarChar(10)
|
||||||
invoice_type_code String? @db.VarChar(10)
|
invoice_type_code String? @db.VarChar(10)
|
||||||
require_2fa Boolean @default(false)
|
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_short Int? @default(15)
|
||||||
break_duration_long Int? @default(30)
|
break_duration_long Int? @default(30)
|
||||||
clock_rounding_minutes Int? @default(15)
|
clock_rounding_minutes Int? @default(15)
|
||||||
@@ -154,6 +154,7 @@ model invoice_items {
|
|||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
invoice_id Int
|
invoice_id Int
|
||||||
description String? @db.VarChar(500)
|
description String? @db.VarChar(500)
|
||||||
|
item_description String? @db.Text
|
||||||
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
|
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
|
||||||
unit String? @db.VarChar(20)
|
unit String? @db.VarChar(20)
|
||||||
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
|
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
|
||||||
@@ -377,10 +378,7 @@ model quotation_items {
|
|||||||
unit String? @db.VarChar(20)
|
unit String? @db.VarChar(20)
|
||||||
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
|
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
|
||||||
is_included_in_total Boolean? @default(true)
|
is_included_in_total Boolean? @default(true)
|
||||||
uuid String? @db.VarChar(36)
|
|
||||||
modified_at DateTime? @db.DateTime(0)
|
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")
|
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "quotation_items_ibfk_1")
|
||||||
|
|
||||||
@@index([quotation_id], map: "quotation_id")
|
@@index([quotation_id], map: "quotation_id")
|
||||||
@@ -389,7 +387,7 @@ model quotation_items {
|
|||||||
model quotations {
|
model quotations {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
quotation_number String? @unique @db.VarChar(50)
|
quotation_number String? @unique @db.VarChar(50)
|
||||||
project_code String? @db.VarChar(50)
|
project_code String? @db.VarChar(100)
|
||||||
customer_id Int?
|
customer_id Int?
|
||||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
valid_until DateTime? @db.Date
|
valid_until DateTime? @db.Date
|
||||||
@@ -397,17 +395,13 @@ model quotations {
|
|||||||
language String? @default("cs") @db.VarChar(5)
|
language String? @default("cs") @db.VarChar(5)
|
||||||
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
||||||
apply_vat Boolean? @default(true)
|
apply_vat Boolean? @default(true)
|
||||||
exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
|
|
||||||
exchange_rate_date DateTime? @db.Date
|
|
||||||
order_id Int?
|
order_id Int?
|
||||||
status String @default("active") @db.VarChar(20)
|
status String @default("active") @db.VarChar(20)
|
||||||
scope_title String? @db.VarChar(500)
|
scope_title String? @db.VarChar(500)
|
||||||
scope_description String? @db.Text
|
scope_description String? @db.Text
|
||||||
locked_by Int?
|
locked_by Int?
|
||||||
locked_at DateTime? @db.DateTime(0)
|
locked_at DateTime? @db.DateTime(0)
|
||||||
uuid String? @db.VarChar(36)
|
|
||||||
modified_at DateTime? @db.DateTime(0)
|
modified_at DateTime? @db.DateTime(0)
|
||||||
sync_version Int? @default(0)
|
|
||||||
orders orders[]
|
orders orders[]
|
||||||
projects projects[]
|
projects projects[]
|
||||||
quotation_items quotation_items[]
|
quotation_items quotation_items[]
|
||||||
@@ -437,7 +431,7 @@ model received_invoices {
|
|||||||
file_mime String? @db.VarChar(100)
|
file_mime String? @db.VarChar(100)
|
||||||
file_size Int? @db.UnsignedInt
|
file_size Int? @db.UnsignedInt
|
||||||
notes String? @db.Text
|
notes String? @db.Text
|
||||||
uploaded_by Int?
|
uploaded_by Int? @db.UnsignedInt
|
||||||
created_at DateTime @default(now()) @db.DateTime(0)
|
created_at DateTime @default(now()) @db.DateTime(0)
|
||||||
modified_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
|
/// 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 {
|
model refresh_tokens {
|
||||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
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)
|
token_hash String @unique(map: "token_hash") @db.VarChar(64)
|
||||||
expires_at DateTime @db.DateTime(0)
|
expires_at DateTime @db.DateTime(0)
|
||||||
replaced_at DateTime? @db.DateTime(0)
|
replaced_at DateTime? @db.DateTime(0)
|
||||||
@@ -492,11 +486,7 @@ model scope_sections {
|
|||||||
title String? @db.VarChar(500)
|
title String? @db.VarChar(500)
|
||||||
title_cz String? @db.VarChar(500)
|
title_cz String? @db.VarChar(500)
|
||||||
content String? @db.Text
|
content String? @db.Text
|
||||||
content_editor_height Int?
|
|
||||||
uuid String? @db.VarChar(36)
|
|
||||||
modified_at DateTime? @db.DateTime(0)
|
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")
|
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "scope_sections_ibfk_1")
|
||||||
|
|
||||||
@@index([quotation_id], map: "quotation_id")
|
@@index([quotation_id], map: "quotation_id")
|
||||||
@@ -632,7 +622,7 @@ model invoice_alert_log {
|
|||||||
sent_at DateTime @default(now()) @db.DateTime(0)
|
sent_at DateTime @default(now()) @db.DateTime(0)
|
||||||
created_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 {
|
enum received_invoices_status {
|
||||||
|
|||||||
436
prisma/seed.ts
Normal file
436
prisma/seed.ts
Normal 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());
|
||||||
@@ -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 ReactQuill from "react-quill-new";
|
||||||
import "react-quill-new/dist/quill.snow.css";
|
import "react-quill-new/dist/quill.snow.css";
|
||||||
|
|
||||||
@@ -96,11 +96,11 @@ export default function RichEditor({
|
|||||||
[onChange],
|
[onChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!quillRef.current) return;
|
if (!quillRef.current) return;
|
||||||
const editor = quillRef.current.getEditor();
|
const editor = quillRef.current.getEditor();
|
||||||
editor.format("font", "tahoma");
|
editor.root.style.fontFamily = "Tahoma, sans-serif";
|
||||||
editor.format("size", "14px");
|
editor.root.style.fontSize = "14px";
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ const menuSections: MenuSection[] = [
|
|||||||
{
|
{
|
||||||
path: "/attendance/admin",
|
path: "/attendance/admin",
|
||||||
label: "Správa",
|
label: "Správa",
|
||||||
permission: "attendance.admin",
|
permission: "attendance.manage",
|
||||||
matchPrefix: "/attendance/admin",
|
matchPrefix: "/attendance/admin",
|
||||||
matchAlso: ["/attendance/create", "/attendance/location"],
|
matchAlso: ["/attendance/create", "/attendance/location"],
|
||||||
icon: (
|
icon: (
|
||||||
@@ -198,7 +198,7 @@ const menuSections: MenuSection[] = [
|
|||||||
{
|
{
|
||||||
path: "/trips/admin",
|
path: "/trips/admin",
|
||||||
label: "Správa",
|
label: "Správa",
|
||||||
permission: "trips.admin",
|
permission: "trips.manage",
|
||||||
icon: (
|
icon: (
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -221,7 +221,7 @@ const menuSections: MenuSection[] = [
|
|||||||
{
|
{
|
||||||
path: "/vehicles",
|
path: "/vehicles",
|
||||||
label: "Vozidla",
|
label: "Vozidla",
|
||||||
permission: "trips.vehicles",
|
permission: "vehicles.manage",
|
||||||
icon: (
|
icon: (
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -315,7 +315,7 @@ const menuSections: MenuSection[] = [
|
|||||||
{
|
{
|
||||||
path: "/offers/customers",
|
path: "/offers/customers",
|
||||||
label: "Zákazníci",
|
label: "Zákazníci",
|
||||||
permission: "offers.view",
|
permission: "customers.view",
|
||||||
icon: (
|
icon: (
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -356,7 +356,13 @@ const menuSections: MenuSection[] = [
|
|||||||
{
|
{
|
||||||
path: "/settings",
|
path: "/settings",
|
||||||
label: "Nastavení",
|
label: "Nastavení",
|
||||||
permission: "settings.manage",
|
permission: [
|
||||||
|
"settings.company",
|
||||||
|
"settings.banking",
|
||||||
|
"settings.roles",
|
||||||
|
"settings.system",
|
||||||
|
"settings.templates",
|
||||||
|
],
|
||||||
icon: (
|
icon: (
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
|
|||||||
@@ -89,6 +89,21 @@ export default function DashProfile({
|
|||||||
const handleSubmit = async (e?: React.FormEvent) => {
|
const handleSubmit = async (e?: React.FormEvent) => {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
const dataToSave = { ...formData };
|
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 {
|
try {
|
||||||
const response = await apiFetch(`${API_BASE}/profile`, {
|
const response = await apiFetch(`${API_BASE}/profile`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -329,9 +344,24 @@ export default function DashProfile({
|
|||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="admin-form-group">
|
||||||
<label className="admin-form-label">
|
<label className="admin-form-label">
|
||||||
Nové heslo (ponechte prázdné pro zachování stávajícího)
|
Nové heslo
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
@@ -343,27 +373,9 @@ export default function DashProfile({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
|
placeholder="Pro změnu hesla, zadejte nové heslo"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<div className="admin-modal-footer">
|
<div className="admin-modal-footer">
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
import {
|
import {
|
||||||
calcProjectMinutesTotal,
|
calcProjectMinutesTotal,
|
||||||
@@ -461,6 +462,7 @@ function buildPrintHtml(
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export default function useAttendanceAdmin({ alert }: AlertContext) {
|
export default function useAttendanceAdmin({ alert }: AlertContext) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
// ---- Core state ----
|
// ---- Core state ----
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [month, setMonth] = useState(() => {
|
const [month, setMonth] = useState(() => {
|
||||||
@@ -771,6 +773,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setShowCreateModal(false);
|
setShowCreateModal(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||||
await fetchData(false);
|
await fetchData(false);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
alert.success(
|
alert.success(
|
||||||
@@ -842,6 +845,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setShowBulkModal(false);
|
setShowBulkModal(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||||
await fetchData(false);
|
await fetchData(false);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
alert.success(
|
alert.success(
|
||||||
@@ -976,6 +980,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||||
await fetchData(false);
|
await fetchData(false);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
alert.success(
|
alert.success(
|
||||||
@@ -1005,6 +1010,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setDeleteConfirm({ show: false, record: null });
|
setDeleteConfirm({ show: false, record: null });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||||
await fetchData(false);
|
await fetchData(false);
|
||||||
alert.success(
|
alert.success(
|
||||||
result.message || result.data?.message || "Záznam smazán",
|
result.message || result.data?.message || "Záznam smazán",
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -33,6 +33,94 @@ export interface LocationRecord {
|
|||||||
departure_address?: string | null;
|
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: {
|
export const attendanceHistoryOptions = (filters: {
|
||||||
month?: string;
|
month?: string;
|
||||||
userId?: number;
|
userId?: number;
|
||||||
@@ -44,7 +132,7 @@ export const attendanceHistoryOptions = (filters: {
|
|||||||
params.set("limit", "1000");
|
params.set("limit", "1000");
|
||||||
if (filters.month) params.set("month", filters.month);
|
if (filters.month) params.set("month", filters.month);
|
||||||
if (filters.userId) params.set("user_id", String(filters.userId));
|
if (filters.userId) params.set("user_id", String(filters.userId));
|
||||||
return jsonQuery<Record<string, unknown>[]>(
|
return jsonQuery<AttendanceRecord[]>(
|
||||||
`/api/admin/attendance?${params.toString()}`,
|
`/api/admin/attendance?${params.toString()}`,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -54,7 +142,7 @@ export const attendanceBalancesOptions = (year: number) =>
|
|||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["attendance", "balances", year],
|
queryKey: ["attendance", "balances", year],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
jsonQuery<Record<string, unknown>>(
|
jsonQuery<BalancesData>(
|
||||||
`/api/admin/attendance?action=balances&year=${year}`,
|
`/api/admin/attendance?action=balances&year=${year}`,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -63,16 +151,14 @@ export const attendanceWorkFundOptions = (year: number) =>
|
|||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["attendance", "workfund", year],
|
queryKey: ["attendance", "workfund", year],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
jsonQuery<Record<string, unknown>>(
|
jsonQuery<FundData>(`/api/admin/attendance?action=workfund&year=${year}`),
|
||||||
`/api/admin/attendance?action=workfund&year=${year}`,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const attendanceProjectReportOptions = (year: number) =>
|
export const attendanceProjectReportOptions = (year: number) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["attendance", "project-report", year],
|
queryKey: ["attendance", "project-report", year],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
jsonQuery<Record<string, unknown>>(
|
jsonQuery<ProjectData>(
|
||||||
`/api/admin/attendance?action=project_report&year=${year}`,
|
`/api/admin/attendance?action=project_report&year=${year}`,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
import { queryOptions } from "@tanstack/react-query";
|
import { queryOptions } from "@tanstack/react-query";
|
||||||
import { jsonQuery } from "../apiAdapter";
|
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 = () =>
|
export const bankAccountsOptions = () =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["bank-accounts"],
|
queryKey: ["bank-accounts"],
|
||||||
queryFn: () =>
|
queryFn: () => jsonQuery<BankAccount[]>("/api/admin/bank-accounts"),
|
||||||
jsonQuery<Record<string, unknown>[]>("/api/admin/bank-accounts"),
|
|
||||||
staleTime: 2 * 60_000,
|
staleTime: 2 * 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,89 @@ export interface InvoiceStats {
|
|||||||
vat_month_czk: number;
|
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: {
|
export const invoiceListOptions = (filters: {
|
||||||
search?: string;
|
search?: string;
|
||||||
sort?: string;
|
sort?: string;
|
||||||
@@ -93,7 +176,7 @@ export const receivedInvoiceListOptions = (filters: {
|
|||||||
if (filters.page) params.set("page", String(filters.page));
|
if (filters.page) params.set("page", String(filters.page));
|
||||||
if (filters.perPage) params.set("per_page", String(filters.perPage));
|
if (filters.perPage) params.set("per_page", String(filters.perPage));
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return paginatedJsonQuery(
|
return paginatedJsonQuery<ReceivedInvoice>(
|
||||||
`/api/admin/received-invoices${qs ? `?${qs}` : ""}`,
|
`/api/admin/received-invoices${qs ? `?${qs}` : ""}`,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -112,7 +195,7 @@ export const receivedInvoiceStatsOptions = (month: number, year: number) =>
|
|||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["invoices", "received", "stats", month, year],
|
queryKey: ["invoices", "received", "stats", month, year],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
jsonQuery<Record<string, unknown>>(
|
jsonQuery<ReceivedStats>(
|
||||||
`/api/admin/received-invoices/stats?month=${month}&year=${year}`,
|
`/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) =>
|
export const invoiceDetailOptions = (id: string | undefined) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["invoices", id],
|
queryKey: ["invoices", id],
|
||||||
queryFn: () =>
|
queryFn: () => jsonQuery<InvoiceDetail>(`/api/admin/invoices/${id}`),
|
||||||
jsonQuery<Record<string, unknown>>(`/api/admin/invoices/${id}`),
|
|
||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
import { queryOptions } from "@tanstack/react-query";
|
import { queryOptions } from "@tanstack/react-query";
|
||||||
import { jsonQuery } from "../apiAdapter";
|
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) =>
|
export const leaveRequestsOptions = (mine = true) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["leave-requests", mine ? "mine" : "all"],
|
queryKey: ["leave-requests", mine ? "mine" : "all"],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
jsonQuery<Record<string, unknown>>(
|
jsonQuery<LeaveRequest[]>(
|
||||||
`/api/admin/leave-requests${mine ? "?mine=1" : ""}`,
|
`/api/admin/leave-requests${mine ? "?mine=1" : ""}`,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +1,68 @@
|
|||||||
import { queryOptions } from "@tanstack/react-query";
|
import { queryOptions } from "@tanstack/react-query";
|
||||||
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
|
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 = () =>
|
export const offerCustomersOptions = () =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["offer-customers"],
|
queryKey: ["offer-customers"],
|
||||||
queryFn: () => jsonQuery<Record<string, unknown>>("/api/admin/customers"),
|
queryFn: () => jsonQuery<Customer[]>("/api/admin/customers"),
|
||||||
staleTime: 2 * 60_000,
|
staleTime: 2 * 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const offerTemplatesOptions = (action?: string) =>
|
export const itemTemplatesOptions = () =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["offer-templates", action ?? "all"],
|
queryKey: ["offer-templates", "items"],
|
||||||
queryFn: () => {
|
queryFn: () =>
|
||||||
const url = action
|
jsonQuery<ItemTemplate[]>("/api/admin/offers-templates?action=items"),
|
||||||
? `/api/admin/offers-templates?action=${action}`
|
});
|
||||||
: "/api/admin/offers-templates";
|
|
||||||
return jsonQuery<Record<string, unknown>[]>(url);
|
export const scopeTemplatesOptions = () =>
|
||||||
},
|
queryOptions({
|
||||||
|
queryKey: ["offer-templates", "scopes"],
|
||||||
|
queryFn: () => jsonQuery<ScopeTemplate[]>("/api/admin/offers-templates"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const offerListOptions = (filters: {
|
export const offerListOptions = (filters: {
|
||||||
@@ -25,6 +71,8 @@ export const offerListOptions = (filters: {
|
|||||||
order?: string;
|
order?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
perPage?: number;
|
perPage?: number;
|
||||||
|
status?: string;
|
||||||
|
customer_id?: number;
|
||||||
}) =>
|
}) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["offers", "list", filters],
|
queryKey: ["offers", "list", filters],
|
||||||
@@ -35,16 +83,67 @@ export const offerListOptions = (filters: {
|
|||||||
if (filters.order) params.set("order", filters.order);
|
if (filters.order) params.set("order", filters.order);
|
||||||
if (filters.page) params.set("page", String(filters.page));
|
if (filters.page) params.set("page", String(filters.page));
|
||||||
if (filters.perPage) params.set("per_page", String(filters.perPage));
|
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();
|
const qs = params.toString();
|
||||||
return paginatedJsonQuery(`/api/admin/offers${qs ? `?${qs}` : ""}`);
|
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) =>
|
export const offerDetailOptions = (id: string | undefined) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["offers", id],
|
queryKey: ["offers", id],
|
||||||
queryFn: () =>
|
queryFn: () => jsonQuery<OfferDetailData>(`/api/admin/offers/${id}`),
|
||||||
jsonQuery<Record<string, unknown>>(`/api/admin/offers/${id}`),
|
|
||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,60 @@
|
|||||||
import { queryOptions } from "@tanstack/react-query";
|
import { queryOptions } from "@tanstack/react-query";
|
||||||
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
|
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: {
|
export const orderListOptions = (filters: {
|
||||||
search?: string;
|
search?: string;
|
||||||
sort?: string;
|
sort?: string;
|
||||||
@@ -25,7 +79,6 @@ export const orderListOptions = (filters: {
|
|||||||
export const orderDetailOptions = (id: string | undefined) =>
|
export const orderDetailOptions = (id: string | undefined) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["orders", id],
|
queryKey: ["orders", id],
|
||||||
queryFn: () =>
|
queryFn: () => jsonQuery<OrderData>(`/api/admin/orders/${id}`),
|
||||||
jsonQuery<Record<string, unknown>>(`/api/admin/orders/${id}`),
|
|
||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,32 @@ import { queryOptions } from "@tanstack/react-query";
|
|||||||
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
|
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
|
||||||
import apiFetch from "../../utils/api";
|
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: {
|
export const projectListOptions = (filters: {
|
||||||
search?: string;
|
search?: string;
|
||||||
sort?: string;
|
sort?: string;
|
||||||
@@ -26,8 +52,7 @@ export const projectListOptions = (filters: {
|
|||||||
export const projectDetailOptions = (id: string | undefined) =>
|
export const projectDetailOptions = (id: string | undefined) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["projects", id],
|
queryKey: ["projects", id],
|
||||||
queryFn: () =>
|
queryFn: () => jsonQuery<ProjectData>(`/api/admin/projects/${id}`),
|
||||||
jsonQuery<Record<string, unknown>>(`/api/admin/projects/${id}`),
|
|
||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,63 @@
|
|||||||
import { queryOptions } from "@tanstack/react-query";
|
import { queryOptions } from "@tanstack/react-query";
|
||||||
import { jsonQuery } from "../apiAdapter";
|
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 = () =>
|
export const companySettingsOptions = () =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["company-settings"],
|
queryKey: ["company-settings"],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
jsonQuery<Record<string, unknown>>("/api/admin/company-settings"),
|
jsonQuery<CompanySettingsData>("/api/admin/company-settings"),
|
||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,33 @@
|
|||||||
import { queryOptions } from "@tanstack/react-query";
|
import { queryOptions } from "@tanstack/react-query";
|
||||||
import { jsonQuery } from "../apiAdapter";
|
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: {
|
export const tripListOptions = (filters: {
|
||||||
month?: number;
|
month?: number;
|
||||||
year?: number;
|
year?: number;
|
||||||
@@ -32,24 +59,21 @@ export const tripListOptions = (filters: {
|
|||||||
if (filters.page) params.set("page", String(filters.page));
|
if (filters.page) params.set("page", String(filters.page));
|
||||||
if (filters.perPage) params.set("per_page", String(filters.perPage));
|
if (filters.perPage) params.set("per_page", String(filters.perPage));
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return jsonQuery<Record<string, unknown>[]>(
|
return jsonQuery<BackendTrip[]>(`/api/admin/trips${qs ? `?${qs}` : ""}`);
|
||||||
`/api/admin/trips${qs ? `?${qs}` : ""}`,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const tripVehiclesOptions = () =>
|
export const tripVehiclesOptions = () =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["trips", "vehicles"],
|
queryKey: ["trips", "vehicles"],
|
||||||
queryFn: () => jsonQuery<Record<string, unknown>[]>("/api/admin/vehicles"),
|
queryFn: () => jsonQuery<TripVehicle[]>("/api/admin/vehicles"),
|
||||||
staleTime: 2 * 60_000,
|
staleTime: 2 * 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const tripUsersOptions = () =>
|
export const tripUsersOptions = () =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["trips", "users"],
|
queryKey: ["trips", "users"],
|
||||||
queryFn: () =>
|
queryFn: () => jsonQuery<TripUser[]>("/api/admin/trips/users"),
|
||||||
jsonQuery<Record<string, unknown>[]>("/api/admin/trips/users"),
|
|
||||||
staleTime: 2 * 60_000,
|
staleTime: 2 * 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,8 +99,6 @@ export const tripHistoryOptions = (filters: {
|
|||||||
params.set("vehicle_id", String(filters.vehicleId));
|
params.set("vehicle_id", String(filters.vehicleId));
|
||||||
if (filters.userId) params.set("user_id", String(filters.userId));
|
if (filters.userId) params.set("user_id", String(filters.userId));
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return jsonQuery<Record<string, unknown>[]>(
|
return jsonQuery<BackendTrip[]>(`/api/admin/trips${qs ? `?${qs}` : ""}`);
|
||||||
`/api/admin/trips${qs ? `?${qs}` : ""}`,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,18 +1,37 @@
|
|||||||
import { queryOptions } from "@tanstack/react-query";
|
import { queryOptions } from "@tanstack/react-query";
|
||||||
import { jsonQuery } from "../apiAdapter";
|
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({
|
queryOptions({
|
||||||
queryKey: ["users"],
|
queryKey: ["users", { permission }],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
// The users endpoint returns { success, data: { items, ... } } or { success, data: [...] }
|
const url = permission
|
||||||
return jsonQuery<Record<string, unknown>>("/api/admin/users");
|
? `/api/admin/users?permission=${encodeURIComponent(permission)}&limit=100`
|
||||||
|
: "/api/admin/users?limit=100";
|
||||||
|
return jsonQuery<User[]>(url);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const roleListOptions = () =>
|
export const roleListOptions = () =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["roles"],
|
queryKey: ["roles"],
|
||||||
queryFn: () => jsonQuery<Record<string, unknown>[]>("/api/admin/roles"),
|
queryFn: () => jsonQuery<Role[]>("/api/admin/roles"),
|
||||||
staleTime: 2 * 60_000,
|
staleTime: 2 * 60_000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,33 +46,6 @@
|
|||||||
min-height: 32px;
|
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 */
|
/* Language badges */
|
||||||
.offers-lang-badge {
|
.offers-lang-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -601,6 +574,14 @@
|
|||||||
color: var(--text-muted) !important;
|
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) */
|
/* Read-only form (invalidated offer detail) */
|
||||||
.offers-readonly input[readonly],
|
.offers-readonly input[readonly],
|
||||||
.offers-readonly select:disabled {
|
.offers-readonly select:disabled {
|
||||||
@@ -609,13 +590,32 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Offer draft indicator */
|
/* Offer draft indicator */
|
||||||
.offers-draft-indicator {
|
/* Filters */
|
||||||
|
.admin-filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-wrap: wrap;
|
||||||
gap: 0.3rem;
|
gap: 1rem;
|
||||||
font-size: 0.72rem;
|
align-items: flex-start;
|
||||||
font-weight: 500;
|
}
|
||||||
color: var(--text-tertiary);
|
|
||||||
margin-top: 0.2rem;
|
.admin-filter-group {
|
||||||
opacity: 0.8;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ export default function Attendance() {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: ["attendance", "status"],
|
queryKey: ["attendance"],
|
||||||
});
|
});
|
||||||
punchTimeoutRef.current = setTimeout(() => {
|
punchTimeoutRef.current = setTimeout(() => {
|
||||||
alert.success(result.data?.message || result.message || "Uloženo");
|
alert.success(result.data?.message || result.message || "Uloženo");
|
||||||
@@ -271,7 +271,7 @@ export default function Attendance() {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: ["attendance", "status"],
|
queryKey: ["attendance"],
|
||||||
});
|
});
|
||||||
alert.success(
|
alert.success(
|
||||||
result.data?.message || result.message || "Přestávka zaznamenána",
|
result.data?.message || result.message || "Přestávka zaznamenána",
|
||||||
@@ -297,6 +297,7 @@ export default function Attendance() {
|
|||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||||
alert.success("Poznámka byla uložena");
|
alert.success("Poznámka byla uložena");
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error);
|
alert.error(result.error);
|
||||||
@@ -319,7 +320,7 @@ export default function Attendance() {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: ["attendance", "status"],
|
queryKey: ["attendance"],
|
||||||
});
|
});
|
||||||
alert.success(
|
alert.success(
|
||||||
result.data?.message || result.message || "Projekt přepnut",
|
result.data?.message || result.message || "Projekt přepnut",
|
||||||
@@ -363,7 +364,13 @@ export default function Attendance() {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
setIsLeaveModalOpen(false);
|
setIsLeaveModalOpen(false);
|
||||||
await queryClient.invalidateQueries({
|
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));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
alert.success(
|
alert.success(
|
||||||
@@ -910,7 +917,7 @@ export default function Attendance() {
|
|||||||
<path d="M9 18l6-6-6-6" />
|
<path d="M9 18l6-6-6-6" />
|
||||||
</svg>
|
</svg>
|
||||||
</Link>
|
</Link>
|
||||||
{hasPermission("attendance.admin") && (
|
{hasPermission("attendance.manage") && (
|
||||||
<Link to="/attendance/admin" className="attendance-quick-link">
|
<Link to="/attendance/admin" className="attendance-quick-link">
|
||||||
<svg
|
<svg
|
||||||
width="20"
|
width="20"
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export default function AttendanceAdmin() {
|
|||||||
useModalLock(showEditModal);
|
useModalLock(showEditModal);
|
||||||
useModalLock(showCreateModal);
|
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
|
// Show skeleton only on initial load (no data yet), not on filter changes
|
||||||
const isInitialLoad =
|
const isInitialLoad =
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ import {
|
|||||||
attendanceBalancesOptions,
|
attendanceBalancesOptions,
|
||||||
attendanceWorkFundOptions,
|
attendanceWorkFundOptions,
|
||||||
attendanceProjectReportOptions,
|
attendanceProjectReportOptions,
|
||||||
|
type BalanceEntry,
|
||||||
|
type BalancesData,
|
||||||
|
type FundData,
|
||||||
|
type FundUserData,
|
||||||
|
type MonthFundData,
|
||||||
|
type ProjectData,
|
||||||
|
type UserShort,
|
||||||
} from "../lib/queries/attendance";
|
} from "../lib/queries/attendance";
|
||||||
|
|
||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
@@ -18,70 +25,6 @@ import { Skeleton } from "boneyard-js/react";
|
|||||||
import AttendanceBalancesFixture from "../fixtures/AttendanceBalancesFixture";
|
import AttendanceBalancesFixture from "../fixtures/AttendanceBalancesFixture";
|
||||||
const API_BASE = "/api/admin";
|
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 => {
|
const getVacationClass = (remaining: number): string => {
|
||||||
if (remaining <= 0) return "text-danger";
|
if (remaining <= 0) return "text-danger";
|
||||||
if (remaining < 20) return "text-warning";
|
if (remaining < 20) return "text-warning";
|
||||||
@@ -144,18 +87,15 @@ export default function AttendanceBalances() {
|
|||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [year, setYear] = useState(new Date().getFullYear());
|
const [year, setYear] = useState(new Date().getFullYear());
|
||||||
const { data: balancesRaw, isPending: balancesPending } = useQuery(
|
const { data: balancesData, isPending: balancesPending } = useQuery(
|
||||||
attendanceBalancesOptions(year),
|
attendanceBalancesOptions(year),
|
||||||
);
|
);
|
||||||
const { data: fundRaw, isPending: fundPending } = useQuery(
|
const { data: fundData, isPending: fundPending } = useQuery(
|
||||||
attendanceWorkFundOptions(year),
|
attendanceWorkFundOptions(year),
|
||||||
);
|
);
|
||||||
const { data: projectRaw, isPending: projectPending } = useQuery(
|
const { data: projectData, isPending: projectPending } = useQuery(
|
||||||
attendanceProjectReportOptions(year),
|
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 [showEditModal, setShowEditModal] = useState(false);
|
||||||
const [editingUser, setEditingUser] = useState<{
|
const [editingUser, setEditingUser] = useState<{
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { userListOptions } from "../lib/queries/users";
|
import { userListOptions } from "../lib/queries/users";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
@@ -14,11 +14,6 @@ import { Skeleton } from "boneyard-js/react";
|
|||||||
import AttendanceCreateFixture from "../fixtures/AttendanceCreateFixture";
|
import AttendanceCreateFixture from "../fixtures/AttendanceCreateFixture";
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
interface User {
|
|
||||||
id: number | string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CreateForm {
|
interface CreateForm {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
shift_date: string;
|
shift_date: string;
|
||||||
@@ -39,8 +34,12 @@ export default function AttendanceCreate() {
|
|||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { data: usersData, isPending: loading } = useQuery(userListOptions());
|
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 [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
const [form, setForm] = useState<CreateForm>(() => {
|
const [form, setForm] = useState<CreateForm>(() => {
|
||||||
@@ -83,6 +82,7 @@ export default function AttendanceCreate() {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
alert.success(result.message);
|
alert.success(result.message);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||||
navigate(`/attendance/admin?month=${form.shift_date.substring(0, 7)}`);
|
navigate(`/attendance/admin?month=${form.shift_date.substring(0, 7)}`);
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error);
|
alert.error(result.error);
|
||||||
@@ -107,7 +107,7 @@ export default function AttendanceCreate() {
|
|||||||
|
|
||||||
const isWorkType = form.leave_type === "work";
|
const isWorkType = form.leave_type === "work";
|
||||||
|
|
||||||
if (!hasPermission("attendance.admin")) return <Forbidden />;
|
if (!hasPermission("attendance.manage")) return <Forbidden />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Skeleton
|
<Skeleton
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import Forbidden from "../components/Forbidden";
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import AdminDatePicker from "../components/AdminDatePicker";
|
import AdminDatePicker from "../components/AdminDatePicker";
|
||||||
import { companySettingsOptions } from "../lib/queries/settings";
|
import { companySettingsOptions } from "../lib/queries/settings";
|
||||||
import { attendanceHistoryOptions } from "../lib/queries/attendance";
|
import {
|
||||||
|
attendanceHistoryOptions,
|
||||||
|
type AttendanceRecord,
|
||||||
|
type ProjectLogEntry,
|
||||||
|
} from "../lib/queries/attendance";
|
||||||
import {
|
import {
|
||||||
formatDate,
|
formatDate,
|
||||||
formatDatetime,
|
formatDatetime,
|
||||||
@@ -20,29 +24,6 @@ import {
|
|||||||
import FormField from "../components/FormField";
|
import FormField from "../components/FormField";
|
||||||
import { Skeleton } from "boneyard-js/react";
|
import { Skeleton } from "boneyard-js/react";
|
||||||
import AttendanceHistoryFixture from "../fixtures/AttendanceHistoryFixture";
|
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 = [
|
const MONTH_NAMES = [
|
||||||
"Leden",
|
"Leden",
|
||||||
@@ -196,9 +177,7 @@ export default function AttendanceHistory() {
|
|||||||
const { user, hasPermission } = useAuth();
|
const { user, hasPermission } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: companySettings } = useQuery(companySettingsOptions());
|
const { data: companySettings } = useQuery(companySettingsOptions());
|
||||||
const companyName =
|
const companyName = companySettings?.company_name || "";
|
||||||
((companySettings as Record<string, unknown> | undefined)
|
|
||||||
?.company_name as string) || "";
|
|
||||||
const printRef = useRef<HTMLDivElement>(null);
|
const printRef = useRef<HTMLDivElement>(null);
|
||||||
const [month, setMonth] = useState(() => {
|
const [month, setMonth] = useState(() => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -207,7 +186,7 @@ export default function AttendanceHistory() {
|
|||||||
const { data, isPending } = useQuery(
|
const { data, isPending } = useQuery(
|
||||||
attendanceHistoryOptions({ month, userId: user?.id }),
|
attendanceHistoryOptions({ month, userId: user?.id }),
|
||||||
);
|
);
|
||||||
const records = (data as AttendanceRecord[] | undefined) ?? [];
|
const records = data ?? [];
|
||||||
|
|
||||||
const computed = useMemo(() => {
|
const computed = useMemo(() => {
|
||||||
const [yearStr, monthStr] = month.split("-");
|
const [yearStr, monthStr] = month.split("-");
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export default function AttendanceLocation() {
|
|||||||
return `${d.getDate()}.${d.getMonth() + 1}.${d.getFullYear()} ${formatTime(datetime)}`;
|
return `${d.getDate()}.${d.getMonth() + 1}.${d.getFullYear()} ${formatTime(datetime)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!hasPermission("attendance.admin")) return <Forbidden />;
|
if (!hasPermission("attendance.manage")) return <Forbidden />;
|
||||||
|
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import { useAuth } from "../context/AuthContext";
|
|||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import FormField from "../components/FormField";
|
import FormField from "../components/FormField";
|
||||||
import ConfirmModal from "../components/ConfirmModal";
|
import ConfirmModal from "../components/ConfirmModal";
|
||||||
import { companySettingsOptions } from "../lib/queries/settings";
|
import {
|
||||||
import { bankAccountsOptions } from "../lib/queries/common";
|
companySettingsOptions,
|
||||||
|
type CompanySettingsData,
|
||||||
|
} from "../lib/queries/settings";
|
||||||
|
import { bankAccountsOptions, type BankAccount } from "../lib/queries/common";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
@@ -45,17 +48,16 @@ interface CompanyForm {
|
|||||||
country: string;
|
country: string;
|
||||||
company_id: string;
|
company_id: string;
|
||||||
vat_id: string;
|
vat_id: string;
|
||||||
}
|
offer_number_pattern: string;
|
||||||
|
order_number_pattern: string;
|
||||||
interface BankAccount {
|
invoice_number_pattern: string;
|
||||||
id: number;
|
quotation_prefix: string;
|
||||||
account_name: string;
|
order_type_code: string;
|
||||||
bank_name: string;
|
invoice_type_code: string;
|
||||||
account_number: string;
|
default_currency: string;
|
||||||
iban: string;
|
default_vat_rate: number;
|
||||||
bic: string;
|
available_currencies: string[];
|
||||||
currency: string;
|
available_vat_rates: number[];
|
||||||
is_default: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BankForm {
|
interface BankForm {
|
||||||
@@ -73,6 +75,8 @@ export default function CompanySettings({
|
|||||||
}: { embedded?: boolean } = {}) {
|
}: { embedded?: boolean } = {}) {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
|
const canEditCompany = hasPermission("settings.company");
|
||||||
|
const canManageBanking = hasPermission("settings.banking");
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [uploadingLogo, setUploadingLogo] = useState(false);
|
const [uploadingLogo, setUploadingLogo] = useState(false);
|
||||||
@@ -89,17 +93,21 @@ export default function CompanySettings({
|
|||||||
country: "",
|
country: "",
|
||||||
company_id: "",
|
company_id: "",
|
||||||
vat_id: "",
|
vat_id: "",
|
||||||
|
offer_number_pattern: "",
|
||||||
|
order_number_pattern: "",
|
||||||
|
invoice_number_pattern: "",
|
||||||
|
quotation_prefix: "",
|
||||||
|
order_type_code: "",
|
||||||
|
invoice_type_code: "",
|
||||||
|
default_currency: "CZK",
|
||||||
|
default_vat_rate: 21,
|
||||||
|
available_currencies: ["CZK", "EUR", "USD", "GBP"],
|
||||||
|
available_vat_rates: [0, 10, 12, 15, 21],
|
||||||
});
|
});
|
||||||
const [customFields, setCustomFields] = useState<CustomField[]>([]);
|
const [customFields, setCustomFields] = useState<CustomField[]>([]);
|
||||||
const [fieldOrder, setFieldOrder] = useState<string[]>([
|
const [fieldOrder, setFieldOrder] = useState<string[]>([
|
||||||
...DEFAULT_FIELD_ORDER,
|
...DEFAULT_FIELD_ORDER,
|
||||||
]);
|
]);
|
||||||
const [availableCurrencies, setAvailableCurrencies] = useState<string[]>([
|
|
||||||
"CZK",
|
|
||||||
"EUR",
|
|
||||||
"USD",
|
|
||||||
"GBP",
|
|
||||||
]);
|
|
||||||
const [bankSaving, setBankSaving] = useState(false);
|
const [bankSaving, setBankSaving] = useState(false);
|
||||||
const [editingBank, setEditingBank] = useState<number | null>(null);
|
const [editingBank, setEditingBank] = useState<number | null>(null);
|
||||||
const [bankDeleteConfirm, setBankDeleteConfirm] = useState<{
|
const [bankDeleteConfirm, setBankDeleteConfirm] = useState<{
|
||||||
@@ -196,49 +204,61 @@ export default function CompanySettings({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const bankAccountsList: BankAccount[] = Array.isArray(bankAccountsData)
|
const bankAccountsList: BankAccount[] = Array.isArray(bankAccountsData)
|
||||||
? (bankAccountsData as unknown as BankAccount[])
|
? bankAccountsData
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Populate form state when settings data arrives
|
// Populate form state when settings data arrives
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!settingsData) return;
|
if (!settingsData) return;
|
||||||
const d = settingsData as Record<string, unknown>;
|
|
||||||
setForm({
|
setForm({
|
||||||
company_name: (d.company_name as string) || "",
|
company_name: settingsData.company_name || "",
|
||||||
street: (d.street as string) || "",
|
street: settingsData.street || "",
|
||||||
city: (d.city as string) || "",
|
city: settingsData.city || "",
|
||||||
postal_code: (d.postal_code as string) || "",
|
postal_code: settingsData.postal_code || "",
|
||||||
country: (d.country as string) || "",
|
country: settingsData.country || "",
|
||||||
company_id: (d.company_id as string) || "",
|
company_id: settingsData.company_id || "",
|
||||||
vat_id: (d.vat_id as string) || "",
|
vat_id: settingsData.vat_id || "",
|
||||||
|
offer_number_pattern: settingsData.offer_number_pattern || "",
|
||||||
|
order_number_pattern: settingsData.order_number_pattern || "",
|
||||||
|
invoice_number_pattern: settingsData.invoice_number_pattern || "",
|
||||||
|
quotation_prefix: settingsData.quotation_prefix || "",
|
||||||
|
order_type_code: settingsData.order_type_code || "",
|
||||||
|
invoice_type_code: settingsData.invoice_type_code || "",
|
||||||
|
default_currency: settingsData.default_currency || "CZK",
|
||||||
|
default_vat_rate: settingsData.default_vat_rate ?? 21,
|
||||||
|
available_currencies:
|
||||||
|
Array.isArray(settingsData.available_currencies) &&
|
||||||
|
settingsData.available_currencies.length > 0
|
||||||
|
? settingsData.available_currencies
|
||||||
|
: ["CZK", "EUR", "USD", "GBP"],
|
||||||
|
available_vat_rates:
|
||||||
|
Array.isArray(settingsData.available_vat_rates) &&
|
||||||
|
settingsData.available_vat_rates.length > 0
|
||||||
|
? settingsData.available_vat_rates
|
||||||
|
: [0, 10, 12, 15, 21],
|
||||||
});
|
});
|
||||||
const cf: CustomField[] =
|
const cf: CustomField[] =
|
||||||
Array.isArray(d.custom_fields) && d.custom_fields.length > 0
|
Array.isArray(settingsData.custom_fields) &&
|
||||||
? (d.custom_fields as CustomField[]).map((f, i) => ({
|
settingsData.custom_fields.length > 0
|
||||||
|
? settingsData.custom_fields.map((f, i) => ({
|
||||||
...f,
|
...f,
|
||||||
showLabel: f.showLabel !== false,
|
showLabel: f.showLabel !== false,
|
||||||
_key: f._key || `cf-${Date.now()}-${i}`,
|
_key: (f as CustomField)._key || `cf-${Date.now()}-${i}`,
|
||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
setCustomFields(cf);
|
setCustomFields(cf);
|
||||||
if (
|
if (
|
||||||
Array.isArray(d.supplier_field_order) &&
|
Array.isArray(settingsData.supplier_field_order) &&
|
||||||
d.supplier_field_order.length > 0
|
settingsData.supplier_field_order.length > 0
|
||||||
) {
|
) {
|
||||||
setFieldOrder(d.supplier_field_order as string[]);
|
setFieldOrder(settingsData.supplier_field_order);
|
||||||
} else {
|
} else {
|
||||||
setFieldOrder([...DEFAULT_FIELD_ORDER]);
|
setFieldOrder([...DEFAULT_FIELD_ORDER]);
|
||||||
}
|
}
|
||||||
if (
|
if (settingsData.has_logo) {
|
||||||
Array.isArray(d.available_currencies) &&
|
|
||||||
d.available_currencies.length > 0
|
|
||||||
) {
|
|
||||||
setAvailableCurrencies(d.available_currencies as string[]);
|
|
||||||
}
|
|
||||||
if (d.has_logo) {
|
|
||||||
fetchLogo("light");
|
fetchLogo("light");
|
||||||
}
|
}
|
||||||
if (d.has_logo_dark) {
|
if (settingsData.has_logo_dark) {
|
||||||
fetchLogo("dark");
|
fetchLogo("dark");
|
||||||
}
|
}
|
||||||
}, [settingsData, fetchLogo]);
|
}, [settingsData, fetchLogo]);
|
||||||
@@ -277,6 +297,7 @@ export default function CompanySettings({
|
|||||||
alert.success(result.message);
|
alert.success(result.message);
|
||||||
resetBankForm();
|
resetBankForm();
|
||||||
queryClient.invalidateQueries({ queryKey: ["bank-accounts"] });
|
queryClient.invalidateQueries({ queryKey: ["bank-accounts"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error || "Chyba při ukládání");
|
alert.error(result.error || "Chyba při ukládání");
|
||||||
}
|
}
|
||||||
@@ -305,6 +326,7 @@ export default function CompanySettings({
|
|||||||
alert.success(result.message);
|
alert.success(result.message);
|
||||||
if (editingBank === bankDeleteConfirm.id) resetBankForm();
|
if (editingBank === bankDeleteConfirm.id) resetBankForm();
|
||||||
queryClient.invalidateQueries({ queryKey: ["bank-accounts"] });
|
queryClient.invalidateQueries({ queryKey: ["bank-accounts"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error || "Chyba při mazání");
|
alert.error(result.error || "Chyba při mazání");
|
||||||
}
|
}
|
||||||
@@ -355,6 +377,9 @@ export default function CompanySettings({
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
alert.success(result.message || "Nastavení bylo uloženo");
|
alert.success(result.message || "Nastavení bylo uloženo");
|
||||||
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
|
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error || "Nepodařilo se uložit nastavení");
|
alert.error(result.error || "Nepodařilo se uložit nastavení");
|
||||||
}
|
}
|
||||||
@@ -390,6 +415,9 @@ export default function CompanySettings({
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
alert.success(result.message || "Logo bylo nahráno");
|
alert.success(result.message || "Logo bylo nahráno");
|
||||||
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
|
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||||
fetchLogo(variant);
|
fetchLogo(variant);
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error || "Nepodařilo se nahrát logo");
|
alert.error(result.error || "Nepodařilo se nahrát logo");
|
||||||
@@ -406,7 +434,7 @@ export default function CompanySettings({
|
|||||||
setForm((prev) => ({ ...prev, [field]: value }));
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!embedded && !hasPermission("settings.manage")) return <Forbidden />;
|
if (!embedded && !canEditCompany) return <Forbidden />;
|
||||||
|
|
||||||
if (settingsLoading) {
|
if (settingsLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -482,6 +510,7 @@ export default function CompanySettings({
|
|||||||
|
|
||||||
<div className="admin-settings-grid">
|
<div className="admin-settings-grid">
|
||||||
{/* Company Info */}
|
{/* Company Info */}
|
||||||
|
{(canEditCompany || !embedded) && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-card"
|
className="admin-card"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
@@ -497,7 +526,9 @@ export default function CompanySettings({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={form.company_name}
|
value={form.company_name}
|
||||||
onChange={(e) => updateField("company_name", e.target.value)}
|
onChange={(e) =>
|
||||||
|
updateField("company_name", e.target.value)
|
||||||
|
}
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -524,7 +555,9 @@ export default function CompanySettings({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={form.postal_code}
|
value={form.postal_code}
|
||||||
onChange={(e) => updateField("postal_code", e.target.value)}
|
onChange={(e) =>
|
||||||
|
updateField("postal_code", e.target.value)
|
||||||
|
}
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -542,7 +575,9 @@ export default function CompanySettings({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={form.company_id}
|
value={form.company_id}
|
||||||
onChange={(e) => updateField("company_id", e.target.value)}
|
onChange={(e) =>
|
||||||
|
updateField("company_id", e.target.value)
|
||||||
|
}
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -705,8 +740,10 @@ export default function CompanySettings({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Bank Accounts */}
|
{/* Bank Accounts */}
|
||||||
|
{(canManageBanking || !embedded) && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-card"
|
className="admin-card"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
@@ -754,7 +791,9 @@ export default function CompanySettings({
|
|||||||
>
|
>
|
||||||
<td>{acc.account_name}</td>
|
<td>{acc.account_name}</td>
|
||||||
<td>{acc.bank_name}</td>
|
<td>{acc.bank_name}</td>
|
||||||
<td className="admin-mono">{acc.account_number}</td>
|
<td className="admin-mono">
|
||||||
|
{acc.account_number}
|
||||||
|
</td>
|
||||||
<td className="admin-mono">{acc.iban}</td>
|
<td className="admin-mono">{acc.iban}</td>
|
||||||
<td className="admin-mono">{acc.bic}</td>
|
<td className="admin-mono">{acc.bic}</td>
|
||||||
<td>{acc.currency}</td>
|
<td>{acc.currency}</td>
|
||||||
@@ -825,7 +864,9 @@ export default function CompanySettings({
|
|||||||
className="text-secondary"
|
className="text-secondary"
|
||||||
style={{ margin: "0 0 12px", fontSize: "0.9rem" }}
|
style={{ margin: "0 0 12px", fontSize: "0.9rem" }}
|
||||||
>
|
>
|
||||||
{editingBank !== null ? "Upravit účet" : "Přidat nový účet"}
|
{editingBank !== null
|
||||||
|
? "Upravit účet"
|
||||||
|
: "Přidat nový účet"}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="admin-form">
|
<div className="admin-form">
|
||||||
<div className="admin-form-row">
|
<div className="admin-form-row">
|
||||||
@@ -884,7 +925,7 @@ export default function CompanySettings({
|
|||||||
}
|
}
|
||||||
className="admin-form-select"
|
className="admin-form-select"
|
||||||
>
|
>
|
||||||
{availableCurrencies.map((c) => (
|
{form.available_currencies.map((c) => (
|
||||||
<option key={c} value={c}>
|
<option key={c} value={c}>
|
||||||
{c}
|
{c}
|
||||||
</option>
|
</option>
|
||||||
@@ -898,7 +939,10 @@ export default function CompanySettings({
|
|||||||
type="text"
|
type="text"
|
||||||
value={bankForm.iban}
|
value={bankForm.iban}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setBankForm((f) => ({ ...f, iban: e.target.value }))
|
setBankForm((f) => ({
|
||||||
|
...f,
|
||||||
|
iban: e.target.value,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
placeholder="CZ65 0800 0000 1920 0014 5399"
|
placeholder="CZ65 0800 0000 1920 0014 5399"
|
||||||
@@ -909,7 +953,10 @@ export default function CompanySettings({
|
|||||||
type="text"
|
type="text"
|
||||||
value={bankForm.bic}
|
value={bankForm.bic}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setBankForm((f) => ({ ...f, bic: e.target.value }))
|
setBankForm((f) => ({
|
||||||
|
...f,
|
||||||
|
bic: e.target.value,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
placeholder="GIBACZPX"
|
placeholder="GIBACZPX"
|
||||||
@@ -959,8 +1006,10 @@ export default function CompanySettings({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* PDF Field Order */}
|
{/* PDF Field Order */}
|
||||||
|
{(canEditCompany || !embedded) && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-card"
|
className="admin-card"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
@@ -1030,13 +1079,247 @@ export default function CompanySettings({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Logo */}
|
{/* Document Numbering */}
|
||||||
|
{(canEditCompany || !embedded) && (
|
||||||
|
<motion.div
|
||||||
|
className="admin-card"
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.25, delay: 0.1 }}
|
||||||
|
>
|
||||||
|
<div className="admin-card-header">
|
||||||
|
<h3 className="admin-card-title">Číslování dokladů</h3>
|
||||||
|
</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 = form[cfg.patternKey] || cfg.defaultPattern;
|
||||||
|
const yyyy = String(new Date().getFullYear());
|
||||||
|
const yy = yyyy.slice(-2);
|
||||||
|
const prefix = cfg.prefixKey
|
||||||
|
? form[cfg.prefixKey] || "NA"
|
||||||
|
: "";
|
||||||
|
const code = cfg.codeKey ? form[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={form[cfg.patternKey]}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateField(cfg.patternKey, e.target.value)
|
||||||
|
}
|
||||||
|
className="admin-form-input"
|
||||||
|
placeholder={cfg.defaultPattern}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
{cfg.prefixKey && (
|
||||||
|
<FormField label={cfg.prefixLabel || "Prefix"}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form[cfg.prefixKey]}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateField(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={form[cfg.codeKey]}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateField(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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Currency & VAT */}
|
||||||
|
{(canEditCompany || !embedded) && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-card"
|
className="admin-card"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.25, delay: 0.12 }}
|
transition={{ duration: 0.25, delay: 0.12 }}
|
||||||
|
>
|
||||||
|
<div className="admin-card-header">
|
||||||
|
<h3 className="admin-card-title">Měna a DPH</h3>
|
||||||
|
</div>
|
||||||
|
<div className="admin-card-body">
|
||||||
|
<div className="admin-form">
|
||||||
|
<div className="admin-form-row">
|
||||||
|
<FormField label="Výchozí měna">
|
||||||
|
<select
|
||||||
|
value={form.default_currency}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateField("default_currency", e.target.value)
|
||||||
|
}
|
||||||
|
className="admin-form-input"
|
||||||
|
>
|
||||||
|
{form.available_currencies.map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{c}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Výchozí sazba DPH (%)">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.default_vat_rate}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateField("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={form.available_currencies.join(", ")}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((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={form.available_vat_rates.join(", ")}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
available_vat_rates: e.target.value
|
||||||
|
.split(",")
|
||||||
|
.map((s) => Number(s.trim()))
|
||||||
|
.filter((n) => !isNaN(n)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="admin-form-input"
|
||||||
|
placeholder="0, 12, 21"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Logo */}
|
||||||
|
{(canEditCompany || !embedded) && (
|
||||||
|
<motion.div
|
||||||
|
className="admin-card"
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.25, delay: 0.14 }}
|
||||||
>
|
>
|
||||||
<div className="admin-card-header">
|
<div className="admin-card-header">
|
||||||
<h3 className="admin-card-title">Logo</h3>
|
<h3 className="admin-card-title">Logo</h3>
|
||||||
@@ -1146,9 +1429,10 @@ export default function CompanySettings({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{embedded && (
|
{embedded && canEditCompany && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export default function Dashboard() {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
alert.success(result.data?.message || "Docházka zaznamenána");
|
alert.success(result.data?.message || "Docházka zaznamenána");
|
||||||
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error || "Chyba při záznamu docházky");
|
alert.error(result.error || "Chyba při záznamu docházky");
|
||||||
}
|
}
|
||||||
@@ -171,7 +172,9 @@ export default function Dashboard() {
|
|||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.success) {
|
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);
|
setBackupCodes(data.data?.backup_codes || null);
|
||||||
setTotpSecret(null);
|
setTotpSecret(null);
|
||||||
setTotpQrUri(null);
|
setTotpQrUri(null);
|
||||||
@@ -199,7 +202,9 @@ export default function Dashboard() {
|
|||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
queryClient.invalidateQueries({ queryKey: ["settings", "2fa"] });
|
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||||
setShow2FADisable(false);
|
setShow2FADisable(false);
|
||||||
setDisableCode("");
|
setDisableCode("");
|
||||||
updateUser({ totpEnabled: false });
|
updateUser({ totpEnabled: false });
|
||||||
@@ -343,7 +348,7 @@ export default function Dashboard() {
|
|||||||
<DashActivityFeed activities={dashData?.recent_activity ?? null} />
|
<DashActivityFeed activities={dashData?.recent_activity ?? null} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasPermission("attendance.admin") && (
|
{hasPermission("attendance.manage") && (
|
||||||
<DashAttendanceToday attendance={dashData?.attendance ?? null} />
|
<DashAttendanceToday attendance={dashData?.attendance ?? null} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import DOMPurify from "dompurify";
|
|||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { Skeleton } from "boneyard-js/react";
|
|
||||||
import InvoiceDetailFixture from "../fixtures/InvoiceDetailFixture";
|
|
||||||
import FormField from "../components/FormField";
|
import FormField from "../components/FormField";
|
||||||
import AdminDatePicker from "../components/AdminDatePicker";
|
import AdminDatePicker from "../components/AdminDatePicker";
|
||||||
import ConfirmModal from "../components/ConfirmModal";
|
import ConfirmModal from "../components/ConfirmModal";
|
||||||
@@ -38,10 +36,16 @@ import {
|
|||||||
} from "@dnd-kit/modifiers";
|
} from "@dnd-kit/modifiers";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
import { companySettingsOptions } from "../lib/queries/settings";
|
import {
|
||||||
import { invoiceDetailOptions } from "../lib/queries/invoices";
|
companySettingsOptions,
|
||||||
import { offerCustomersOptions } from "../lib/queries/offers";
|
type CompanySettingsData,
|
||||||
import { bankAccountsOptions } from "../lib/queries/common";
|
} from "../lib/queries/settings";
|
||||||
|
import {
|
||||||
|
invoiceDetailOptions,
|
||||||
|
type InvoiceDetail,
|
||||||
|
} from "../lib/queries/invoices";
|
||||||
|
import { offerCustomersOptions, type Customer } from "../lib/queries/offers";
|
||||||
|
import { bankAccountsOptions, type BankAccount } from "../lib/queries/common";
|
||||||
import { jsonQuery } from "../lib/apiAdapter";
|
import { jsonQuery } from "../lib/apiAdapter";
|
||||||
import { formatCurrency, formatDate } from "../utils/formatters";
|
import { formatCurrency, formatDate } from "../utils/formatters";
|
||||||
|
|
||||||
@@ -68,29 +72,13 @@ interface InvoiceItem {
|
|||||||
id?: number;
|
id?: number;
|
||||||
_key: string;
|
_key: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
item_description: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
unit: string;
|
unit: string;
|
||||||
unit_price: number;
|
unit_price: number;
|
||||||
vat_rate: number;
|
vat_rate: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Customer {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
company_id?: string;
|
|
||||||
city?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BankAccount {
|
|
||||||
id: number;
|
|
||||||
account_name: string;
|
|
||||||
account_number?: string;
|
|
||||||
bank_name?: string;
|
|
||||||
bic?: string;
|
|
||||||
iban?: string;
|
|
||||||
is_default?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InvoiceForm {
|
interface InvoiceForm {
|
||||||
customer_id: number | null;
|
customer_id: number | null;
|
||||||
customer_name: string;
|
customer_name: string;
|
||||||
@@ -114,42 +102,6 @@ interface InvoiceForm {
|
|||||||
bank_account: string;
|
bank_account: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InvoiceCustomer {
|
|
||||||
company_id?: string;
|
|
||||||
vat_id?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Invoice {
|
|
||||||
id: number;
|
|
||||||
invoice_number: string;
|
|
||||||
customer_id?: number | null;
|
|
||||||
customer_name: string | null;
|
|
||||||
customer?: InvoiceCustomer;
|
|
||||||
order_id?: number;
|
|
||||||
order_number?: string;
|
|
||||||
currency: string;
|
|
||||||
status: string;
|
|
||||||
issue_date: string;
|
|
||||||
due_date: string;
|
|
||||||
tax_date: string;
|
|
||||||
payment_method: string;
|
|
||||||
constant_symbol?: string;
|
|
||||||
issued_by: string | null;
|
|
||||||
paid_date?: string;
|
|
||||||
notes: string;
|
|
||||||
language: string;
|
|
||||||
apply_vat: number | string;
|
|
||||||
vat_rate?: number;
|
|
||||||
billing_text?: string;
|
|
||||||
bank_name?: string;
|
|
||||||
bank_swift?: string;
|
|
||||||
bank_iban?: string;
|
|
||||||
bank_account?: string;
|
|
||||||
bank_account_id?: number | null;
|
|
||||||
items: Omit<InvoiceItem, "_key">[];
|
|
||||||
valid_transitions?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sortable row for create mode
|
// Sortable row for create mode
|
||||||
function SortableInvoiceRow({
|
function SortableInvoiceRow({
|
||||||
item,
|
item,
|
||||||
@@ -213,7 +165,10 @@ function SortableInvoiceRow({
|
|||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="text-tertiary text-center fw-500">{index + 1}</td>
|
<td className="text-tertiary text-center fw-500">{index + 1}</td>
|
||||||
<td>
|
<td style={{ verticalAlign: "top" }}>
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={item.description}
|
value={item.description}
|
||||||
@@ -221,12 +176,23 @@ function SortableInvoiceRow({
|
|||||||
className="admin-form-input fw-500"
|
className="admin-form-input fw-500"
|
||||||
placeholder="Popis položky..."
|
placeholder="Popis položky..."
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={item.item_description}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdate(index, "item_description", e.target.value)
|
||||||
|
}
|
||||||
|
className="admin-form-input"
|
||||||
|
placeholder="Podrobný popis (volitelný)"
|
||||||
|
style={{ fontSize: "0.8rem", opacity: 0.8 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={item.quantity}
|
value={item.quantity}
|
||||||
onChange={(e) => onUpdate(index, "quantity", e.target.value)}
|
onChange={(e) => onUpdate(index, "quantity", Number(e.target.value))}
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
min="0"
|
min="0"
|
||||||
step="any"
|
step="any"
|
||||||
@@ -255,7 +221,9 @@ function SortableInvoiceRow({
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={item.unit_price}
|
value={item.unit_price}
|
||||||
onChange={(e) => onUpdate(index, "unit_price", e.target.value)}
|
onChange={(e) =>
|
||||||
|
onUpdate(index, "unit_price", Number(e.target.value))
|
||||||
|
}
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
step="any"
|
step="any"
|
||||||
style={{
|
style={{
|
||||||
@@ -326,6 +294,7 @@ export default function InvoiceDetail() {
|
|||||||
(): InvoiceItem => ({
|
(): InvoiceItem => ({
|
||||||
_key: `inv-${++keyCounterRef.current}`,
|
_key: `inv-${++keyCounterRef.current}`,
|
||||||
description: "",
|
description: "",
|
||||||
|
item_description: "",
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
unit: "ks",
|
unit: "ks",
|
||||||
unit_price: 0,
|
unit_price: 0,
|
||||||
@@ -395,14 +364,7 @@ export default function InvoiceDetail() {
|
|||||||
const [customerSearch, setCustomerSearch] = useState("");
|
const [customerSearch, setCustomerSearch] = useState("");
|
||||||
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
|
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
|
||||||
|
|
||||||
const companySettings = useQuery(companySettingsOptions()).data as unknown as
|
const companySettings = useQuery(companySettingsOptions()).data;
|
||||||
| {
|
|
||||||
default_currency: string;
|
|
||||||
default_vat_rate: number;
|
|
||||||
available_currencies: string[];
|
|
||||||
available_vat_rates: number[];
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (companySettings && !isEdit) {
|
if (companySettings && !isEdit) {
|
||||||
@@ -441,20 +403,13 @@ export default function InvoiceDetail() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const customersQuery = useQuery(offerCustomersOptions());
|
const customersQuery = useQuery(offerCustomersOptions());
|
||||||
const customers = useMemo<Customer[]>(() => {
|
const customers = customersQuery.data ?? [];
|
||||||
const data = customersQuery.data;
|
|
||||||
if (!data) return [];
|
|
||||||
if (Array.isArray(data)) return data as Customer[];
|
|
||||||
const obj = data as Record<string, unknown>;
|
|
||||||
if (Array.isArray(obj.customers)) return obj.customers as Customer[];
|
|
||||||
return [];
|
|
||||||
}, [customersQuery.data]);
|
|
||||||
|
|
||||||
const bankAccountsQuery = useQuery(bankAccountsOptions());
|
const bankAccountsQuery = useQuery(bankAccountsOptions());
|
||||||
const bankAccounts = (bankAccountsQuery.data ?? []) as BankAccount[];
|
const bankAccounts = bankAccountsQuery.data ?? [];
|
||||||
|
|
||||||
const invoiceQuery = useQuery(invoiceDetailOptions(id));
|
const invoiceQuery = useQuery(invoiceDetailOptions(id));
|
||||||
const invoice = (invoiceQuery.data as Invoice | undefined) ?? null;
|
const invoice = invoiceQuery.data ?? null;
|
||||||
|
|
||||||
const nextNumberQuery = useQuery({
|
const nextNumberQuery = useQuery({
|
||||||
queryKey: ["invoices", "next-number"],
|
queryKey: ["invoices", "next-number"],
|
||||||
@@ -505,56 +460,54 @@ export default function InvoiceDetail() {
|
|||||||
return;
|
return;
|
||||||
if (!invoiceQuery.data) return;
|
if (!invoiceQuery.data) return;
|
||||||
|
|
||||||
const inv = invoiceQuery.data as Record<string, unknown>;
|
const inv = invoiceQuery.data;
|
||||||
|
|
||||||
// Match bank account from invoice's bank details
|
// Match bank account from invoice's bank details
|
||||||
let matchedBankId: number | string = "";
|
let matchedBankId: number | string = "";
|
||||||
const bankData = bankAccountsQuery.data ?? [];
|
const bankData = bankAccountsQuery.data ?? [];
|
||||||
if (Array.isArray(bankData)) {
|
|
||||||
const match = bankData.find(
|
const match = bankData.find(
|
||||||
(b: BankAccount) =>
|
(b) =>
|
||||||
(inv.bank_iban && b.iban === inv.bank_iban) ||
|
(inv.bank_iban && b.iban === inv.bank_iban) ||
|
||||||
(inv.bank_account && b.account_number === inv.bank_account),
|
(inv.bank_account && b.account_number === inv.bank_account),
|
||||||
);
|
);
|
||||||
if (match) matchedBankId = match.id;
|
if (match) matchedBankId = match.id;
|
||||||
}
|
|
||||||
|
|
||||||
const formData = {
|
const formData: InvoiceForm = {
|
||||||
customer_id: (inv.customer_id as number) || null,
|
customer_id: inv.customer_id || null,
|
||||||
customer_name: (inv.customer_name as string) || "",
|
customer_name: inv.customer_name || "",
|
||||||
order_id: (inv.order_id as number) || null,
|
order_id: inv.order_id || null,
|
||||||
issue_date: inv.issue_date
|
issue_date: inv.issue_date
|
||||||
? new Date(inv.issue_date as string).toISOString().split("T")[0]
|
? new Date(inv.issue_date).toISOString().split("T")[0]
|
||||||
: "",
|
: "",
|
||||||
due_date: inv.due_date
|
due_date: inv.due_date
|
||||||
? new Date(inv.due_date as string).toISOString().split("T")[0]
|
? new Date(inv.due_date).toISOString().split("T")[0]
|
||||||
: "",
|
: "",
|
||||||
tax_date: inv.tax_date
|
tax_date: inv.tax_date
|
||||||
? new Date(inv.tax_date as string).toISOString().split("T")[0]
|
? new Date(inv.tax_date).toISOString().split("T")[0]
|
||||||
: "",
|
: "",
|
||||||
currency: (inv.currency as string) || "CZK",
|
currency: inv.currency || "CZK",
|
||||||
apply_vat: Number(inv.apply_vat) ? 1 : 0,
|
apply_vat: Number(inv.apply_vat) ? 1 : 0,
|
||||||
vat_rate: Number(inv.vat_rate) || 21,
|
vat_rate: Number(inv.vat_rate) || 21,
|
||||||
payment_method: (inv.payment_method as string) || "Příkazem",
|
payment_method: inv.payment_method || "Příkazem",
|
||||||
constant_symbol: (inv.constant_symbol as string) || "0308",
|
constant_symbol: inv.constant_symbol || "0308",
|
||||||
issued_by: (inv.issued_by as string) || "",
|
issued_by: inv.issued_by || "",
|
||||||
billing_text: (inv.billing_text as string) || "",
|
billing_text: inv.billing_text || "",
|
||||||
notes: (inv.notes as string) || "",
|
notes: inv.notes || "",
|
||||||
language: (inv.language as string) || "cs",
|
language: inv.language || "cs",
|
||||||
bank_account_id: matchedBankId,
|
bank_account_id: matchedBankId,
|
||||||
bank_name: (inv.bank_name as string) || "",
|
bank_name: inv.bank_name || "",
|
||||||
bank_swift: (inv.bank_swift as string) || "",
|
bank_swift: inv.bank_swift || "",
|
||||||
bank_iban: (inv.bank_iban as string) || "",
|
bank_iban: inv.bank_iban || "",
|
||||||
bank_account: (inv.bank_account as string) || "",
|
bank_account: inv.bank_account || "",
|
||||||
};
|
};
|
||||||
setForm(formData);
|
setForm(formData);
|
||||||
setNotes((inv.notes as string) || "");
|
setNotes(inv.notes || "");
|
||||||
setInvoiceNumber((inv.invoice_number as string) || "");
|
setInvoiceNumber(inv.invoice_number || "");
|
||||||
|
|
||||||
// Calculate dueDays from existing dates
|
// Calculate dueDays from existing dates
|
||||||
if (inv.issue_date && inv.due_date) {
|
if (inv.issue_date && inv.due_date) {
|
||||||
const issue = new Date(inv.issue_date as string);
|
const issue = new Date(inv.issue_date);
|
||||||
const due = new Date(inv.due_date as string);
|
const due = new Date(inv.due_date);
|
||||||
const diffDays = Math.round(
|
const diffDays = Math.round(
|
||||||
(due.getTime() - issue.getTime()) / (1000 * 60 * 60 * 24),
|
(due.getTime() - issue.getTime()) / (1000 * 60 * 60 * 24),
|
||||||
);
|
);
|
||||||
@@ -562,17 +515,18 @@ export default function InvoiceDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Populate items from existing invoice
|
// Populate items from existing invoice
|
||||||
const invItems = inv.items as Record<string, unknown>[] | undefined;
|
const invItems = inv.items;
|
||||||
const mappedItems =
|
const mappedItems =
|
||||||
invItems && invItems.length > 0
|
invItems && invItems.length > 0
|
||||||
? invItems.map((item) => ({
|
? invItems.map((item) => ({
|
||||||
_key: `inv-${++keyCounterRef.current}`,
|
_key: `inv-${++keyCounterRef.current}`,
|
||||||
id: item.id as number | undefined,
|
id: item.id,
|
||||||
description: (item.description as string) || "",
|
description: item.description || "",
|
||||||
|
item_description: item.item_description || "",
|
||||||
quantity: Number(item.quantity) || 1,
|
quantity: Number(item.quantity) || 1,
|
||||||
unit: (item.unit as string) || "",
|
unit: item.unit || "",
|
||||||
unit_price: Number(item.unit_price) || 0,
|
unit_price: Number(item.unit_price) || 0,
|
||||||
vat_rate: Number(item.vat_rate) || Number(inv.vat_rate) || 21,
|
vat_rate: Number(inv.vat_rate) || 21,
|
||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
if (mappedItems.length > 0) {
|
if (mappedItems.length > 0) {
|
||||||
@@ -613,7 +567,7 @@ export default function InvoiceDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set default bank account
|
// Set default bank account
|
||||||
const defaultAcc = bankAccounts.find((a: BankAccount) => a.is_default);
|
const defaultAcc = bankAccounts.find((a) => a.is_default);
|
||||||
if (defaultAcc) {
|
if (defaultAcc) {
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -648,6 +602,7 @@ export default function InvoiceDetail() {
|
|||||||
orderItems.map((item) => ({
|
orderItems.map((item) => ({
|
||||||
_key: `inv-${++keyCounterRef.current}`,
|
_key: `inv-${++keyCounterRef.current}`,
|
||||||
description: (item.description as string) || "",
|
description: (item.description as string) || "",
|
||||||
|
item_description: (item.item_description as string) || "",
|
||||||
quantity: Number(item.quantity) || 1,
|
quantity: Number(item.quantity) || 1,
|
||||||
unit: (item.unit as string) || "",
|
unit: (item.unit as string) || "",
|
||||||
unit_price: Number(item.unit_price) || 0,
|
unit_price: Number(item.unit_price) || 0,
|
||||||
@@ -859,12 +814,14 @@ export default function InvoiceDetail() {
|
|||||||
);
|
);
|
||||||
initialSnapshotRef.current = JSON.stringify({ form, items });
|
initialSnapshotRef.current = JSON.stringify({ form, items });
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
queryClient.invalidateQueries({ queryKey: ["invoices", id] });
|
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
} else {
|
} else {
|
||||||
navigate(`/invoices/${result.data.invoice_id}`);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
|
navigate(`/invoices/${result.data.invoice_id}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
alert.error(
|
alert.error(
|
||||||
@@ -895,8 +852,9 @@ export default function InvoiceDetail() {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
alert.success(result.message || "Stav byl změněn");
|
alert.success(result.message || "Stav byl změněn");
|
||||||
queryClient.invalidateQueries({ queryKey: ["invoices", id] });
|
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error || "Nepodařilo se změnit stav");
|
alert.error(result.error || "Nepodařilo se změnit stav");
|
||||||
}
|
}
|
||||||
@@ -943,6 +901,7 @@ export default function InvoiceDetail() {
|
|||||||
alert.success(result.message || "Faktura byla smazána");
|
alert.success(result.message || "Faktura byla smazána");
|
||||||
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
navigate("/invoices");
|
navigate("/invoices");
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error || "Nepodařilo se smazat fakturu");
|
alert.error(result.error || "Nepodařilo se smazat fakturu");
|
||||||
@@ -967,12 +926,14 @@ export default function InvoiceDetail() {
|
|||||||
if (isEdit && !invoice) return null;
|
if (isEdit && !invoice) return null;
|
||||||
|
|
||||||
if (isPaid && invoice) {
|
if (isPaid && invoice) {
|
||||||
|
if (!dataReady) {
|
||||||
|
return (
|
||||||
|
<div className="admin-loading">
|
||||||
|
<div className="admin-spinner" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Skeleton
|
|
||||||
name="invoice-detail"
|
|
||||||
loading={!dataReady}
|
|
||||||
fixture={<InvoiceDetailFixture />}
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -1181,23 +1142,29 @@ export default function InvoiceDetail() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="fw-500">
|
<td className="fw-500">
|
||||||
{item.description || "\u2014"}
|
{item.description || "\u2014"}
|
||||||
|
{item.item_description && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
opacity: 0.8,
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.item_description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-center">
|
<td className="text-center">
|
||||||
{item.quantity}{" "}
|
{item.quantity}{" "}
|
||||||
{item.unit && (
|
{item.unit && (
|
||||||
<span className="text-tertiary">
|
<span className="text-tertiary">{item.unit}</span>
|
||||||
{item.unit}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-center">
|
<td className="text-center">
|
||||||
{item.unit || "\u2014"}
|
{item.unit || "\u2014"}
|
||||||
</td>
|
</td>
|
||||||
<td className="admin-mono text-right">
|
<td className="admin-mono text-right">
|
||||||
{formatCurrency(
|
{formatCurrency(item.unit_price, invoice.currency)}
|
||||||
item.unit_price,
|
|
||||||
invoice.currency,
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="text-center">
|
<td className="text-center">
|
||||||
{Number(invoice.apply_vat)
|
{Number(invoice.apply_vat)
|
||||||
@@ -1229,14 +1196,12 @@ export default function InvoiceDetail() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{Number(invoice.apply_vat) > 0 &&
|
{Number(invoice.apply_vat) > 0 &&
|
||||||
Object.entries(createTotals.vatByRate).map(
|
Object.entries(createTotals.vatByRate).map(([rate, amount]) => (
|
||||||
([rate, amount]) => (
|
|
||||||
<div key={rate} className="admin-totals-row">
|
<div key={rate} className="admin-totals-row">
|
||||||
<span>DPH {rate}%:</span>
|
<span>DPH {rate}%:</span>
|
||||||
<span>{formatCurrency(amount, invoice.currency)}</span>
|
<span>{formatCurrency(amount, invoice.currency)}</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
))}
|
||||||
)}
|
|
||||||
<div className="admin-totals-row admin-totals-total">
|
<div className="admin-totals-row admin-totals-total">
|
||||||
<span>Celkem k úhradě:</span>
|
<span>Celkem k úhradě:</span>
|
||||||
<span>
|
<span>
|
||||||
@@ -1277,19 +1242,20 @@ export default function InvoiceDetail() {
|
|||||||
loading={deleting}
|
loading={deleting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Skeleton>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// CREATE MODE + EDIT (not paid) — shared form
|
// CREATE MODE + EDIT (not paid) — shared form
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
if (!dataReady) {
|
||||||
|
return (
|
||||||
|
<div className="admin-loading">
|
||||||
|
<div className="admin-spinner" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Skeleton
|
|
||||||
name="invoice-detail"
|
|
||||||
loading={!dataReady}
|
|
||||||
fixture={<InvoiceDetailFixture />}
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-page-header"
|
className="admin-page-header"
|
||||||
@@ -1443,11 +1409,7 @@ export default function InvoiceDetail() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField
|
<FormField label="Odběratel" error={errors.customer_id} required>
|
||||||
label="Odběratel"
|
|
||||||
error={errors.customer_id}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
{form.customer_id ? (
|
{form.customer_id ? (
|
||||||
<div className="admin-customer-selected">
|
<div className="admin-customer-selected">
|
||||||
<span>{form.customer_name}</span>
|
<span>{form.customer_name}</span>
|
||||||
@@ -1789,19 +1751,15 @@ export default function InvoiceDetail() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{form.apply_vat &&
|
{form.apply_vat &&
|
||||||
Object.entries(createTotals.vatByRate).map(
|
Object.entries(createTotals.vatByRate).map(([rate, amount]) => (
|
||||||
([rate, amount]) => (
|
|
||||||
<div key={rate} className="admin-totals-row">
|
<div key={rate} className="admin-totals-row">
|
||||||
<span>DPH {rate}%:</span>
|
<span>DPH {rate}%:</span>
|
||||||
<span>{formatCurrency(amount, form.currency)}</span>
|
<span>{formatCurrency(amount, form.currency)}</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
))}
|
||||||
)}
|
|
||||||
<div className="admin-totals-row admin-totals-total">
|
<div className="admin-totals-row admin-totals-total">
|
||||||
<span>Celkem k úhradě:</span>
|
<span>Celkem k úhradě:</span>
|
||||||
<span>
|
<span>{formatCurrency(createTotals.total, form.currency)}</span>
|
||||||
{formatCurrency(createTotals.total, form.currency)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1857,6 +1815,5 @@ export default function InvoiceDetail() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Skeleton>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,9 +20,6 @@ import {
|
|||||||
type CurrencyAmount,
|
type CurrencyAmount,
|
||||||
} from "../lib/queries/invoices";
|
} from "../lib/queries/invoices";
|
||||||
import Pagination from "../components/Pagination";
|
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 ReceivedInvoices = lazy(() => import("./ReceivedInvoices"));
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
@@ -220,6 +217,7 @@ export default function Invoices() {
|
|||||||
alert.success(result.message || "Faktura byla smazána");
|
alert.success(result.message || "Faktura byla smazána");
|
||||||
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error || "Nepodařilo se smazat fakturu");
|
alert.error(result.error || "Nepodařilo se smazat fakturu");
|
||||||
}
|
}
|
||||||
@@ -243,6 +241,7 @@ export default function Invoices() {
|
|||||||
alert.success("Faktura označena jako zaplacená");
|
alert.success("Faktura označena jako zaplacená");
|
||||||
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
} else {
|
} else {
|
||||||
alert.error(data.error || "Nepodařilo se změnit stav");
|
alert.error(data.error || "Nepodařilo se změnit stav");
|
||||||
}
|
}
|
||||||
@@ -281,13 +280,9 @@ export default function Invoices() {
|
|||||||
|
|
||||||
if (initialLoad) {
|
if (initialLoad) {
|
||||||
return (
|
return (
|
||||||
<Skeleton
|
<div className="admin-loading">
|
||||||
name="invoices"
|
<div className="admin-spinner" />
|
||||||
loading={initialLoad}
|
</div>
|
||||||
fixture={<InvoicesFixture />}
|
|
||||||
>
|
|
||||||
<div />
|
|
||||||
</Skeleton>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,13 +413,9 @@ export default function Invoices() {
|
|||||||
>
|
>
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<Skeleton
|
<div className="admin-loading">
|
||||||
name="invoices-received-kpi"
|
<div className="admin-spinner" />
|
||||||
loading={true}
|
</div>
|
||||||
fixture={<ReceivedInvoicesFixture />}
|
|
||||||
>
|
|
||||||
<div />
|
|
||||||
</Skeleton>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ReceivedInvoices
|
<ReceivedInvoices
|
||||||
@@ -443,13 +434,9 @@ export default function Invoices() {
|
|||||||
transition={{ duration: 0.25, delay: 0.1 }}
|
transition={{ duration: 0.25, delay: 0.1 }}
|
||||||
>
|
>
|
||||||
{statsQuery.isPending && !hasLoadedOnce.current ? (
|
{statsQuery.isPending && !hasLoadedOnce.current ? (
|
||||||
<Skeleton
|
<div className="admin-loading">
|
||||||
name="invoices-kpi"
|
<div className="admin-spinner" />
|
||||||
loading={statsQuery.isPending && !hasLoadedOnce.current}
|
</div>
|
||||||
fixture={<InvoicesFixture />}
|
|
||||||
>
|
|
||||||
<div />
|
|
||||||
</Skeleton>
|
|
||||||
) : (
|
) : (
|
||||||
stats && (
|
stats && (
|
||||||
<div style={{ overflow: "hidden", marginBottom: "1.5rem" }}>
|
<div style={{ overflow: "hidden", marginBottom: "1.5rem" }}>
|
||||||
@@ -630,6 +617,14 @@ export default function Invoices() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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) ? (
|
{invoices.length === 0 && !(draft && !statusFilter) ? (
|
||||||
<div className="admin-empty-state">
|
<div className="admin-empty-state">
|
||||||
<div className="admin-empty-icon">
|
<div className="admin-empty-icon">
|
||||||
@@ -721,18 +716,23 @@ export default function Invoices() {
|
|||||||
<span className="offers-draft-row-label">
|
<span className="offers-draft-row-label">
|
||||||
Koncept
|
Koncept
|
||||||
{draft.savedAt && (
|
{draft.savedAt && (
|
||||||
<span style={{ fontWeight: 400, opacity: 0.8 }}>
|
<span
|
||||||
|
style={{ fontWeight: 400, opacity: 0.8 }}
|
||||||
|
>
|
||||||
{" · "}
|
{" · "}
|
||||||
{new Date(draft.savedAt).toLocaleTimeString(
|
{new Date(
|
||||||
"cs-CZ",
|
draft.savedAt,
|
||||||
{ hour: "2-digit", minute: "2-digit" },
|
).toLocaleTimeString("cs-CZ", {
|
||||||
)}
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{(draft.form.customer_name as string) || "\u2014"}
|
{(draft.form.customer_name as string) ||
|
||||||
|
"\u2014"}
|
||||||
</td>
|
</td>
|
||||||
<td>{"\u2014"}</td>
|
<td>{"\u2014"}</td>
|
||||||
<td className="admin-mono">
|
<td className="admin-mono">
|
||||||
@@ -797,7 +797,9 @@ export default function Invoices() {
|
|||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={inv.id}
|
key={inv.id}
|
||||||
className={isOverdue ? "offers-expired-row" : ""}
|
className={
|
||||||
|
isOverdue ? "offers-expired-row" : ""
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<td className="admin-mono">
|
<td className="admin-mono">
|
||||||
<Link
|
<Link
|
||||||
@@ -832,7 +834,10 @@ export default function Invoices() {
|
|||||||
className="admin-mono"
|
className="admin-mono"
|
||||||
style={
|
style={
|
||||||
inv.status === "overdue"
|
inv.status === "overdue"
|
||||||
? { color: "var(--danger)", fontWeight: 600 }
|
? {
|
||||||
|
color: "var(--danger)",
|
||||||
|
fontWeight: 600,
|
||||||
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -840,7 +845,10 @@ export default function Invoices() {
|
|||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
className="admin-mono"
|
className="admin-mono"
|
||||||
style={{ textAlign: "right", fontWeight: 500 }}
|
style={{
|
||||||
|
textAlign: "right",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{formatCurrency(inv.total, inv.currency)}
|
{formatCurrency(inv.total, inv.currency)}
|
||||||
</td>
|
</td>
|
||||||
@@ -850,10 +858,14 @@ export default function Invoices() {
|
|||||||
to={`/invoices/${inv.id}`}
|
to={`/invoices/${inv.id}`}
|
||||||
className="admin-btn-icon"
|
className="admin-btn-icon"
|
||||||
title={
|
title={
|
||||||
inv.status === "paid" ? "Detail" : "Upravit"
|
inv.status === "paid"
|
||||||
|
? "Detail"
|
||||||
|
: "Upravit"
|
||||||
}
|
}
|
||||||
aria-label={
|
aria-label={
|
||||||
inv.status === "paid" ? "Detail" : "Upravit"
|
inv.status === "paid"
|
||||||
|
? "Detail"
|
||||||
|
: "Upravit"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{inv.status === "paid" ? (
|
{inv.status === "paid" ? (
|
||||||
@@ -909,8 +921,18 @@ export default function Invoices() {
|
|||||||
>
|
>
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
<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" />
|
<polyline points="14 2 14 8 20 8" />
|
||||||
<line x1="16" y1="13" x2="8" y2="13" />
|
<line
|
||||||
<line x1="16" y1="17" x2="8" y2="17" />
|
x1="16"
|
||||||
|
y1="13"
|
||||||
|
x2="8"
|
||||||
|
y2="13"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="16"
|
||||||
|
y1="17"
|
||||||
|
x2="8"
|
||||||
|
y2="17"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -948,6 +970,8 @@ export default function Invoices() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
<Pagination pagination={pagination} onPageChange={setPage} />
|
<Pagination pagination={pagination} onPageChange={setPage} />
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -158,6 +158,8 @@ export default function LeaveApproval() {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
setApproveModal({ open: false, request: null });
|
setApproveModal({ open: false, request: null });
|
||||||
await queryClient.invalidateQueries({ queryKey: ["leave"] });
|
await queryClient.invalidateQueries({ queryKey: ["leave"] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["leave-requests"] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||||
alert.success("Žádost byla schválena");
|
alert.success("Žádost byla schválena");
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error);
|
alert.error(result.error);
|
||||||
@@ -195,6 +197,8 @@ export default function LeaveApproval() {
|
|||||||
setRejectModal({ open: false, request: null });
|
setRejectModal({ open: false, request: null });
|
||||||
setRejectNote("");
|
setRejectNote("");
|
||||||
await queryClient.invalidateQueries({ queryKey: ["leave"] });
|
await queryClient.invalidateQueries({ queryKey: ["leave"] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["leave-requests"] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||||
alert.success("Žádost byla zamítnuta");
|
alert.success("Žádost byla zamítnuta");
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error);
|
alert.error(result.error);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import LeaveRequestsFixture from "../fixtures/LeaveRequestsFixture";
|
|||||||
import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
|
import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
|
||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
import ConfirmModal from "../components/ConfirmModal";
|
import ConfirmModal from "../components/ConfirmModal";
|
||||||
import { leaveRequestsOptions } from "../lib/queries/leave";
|
import { leaveRequestsOptions, type LeaveRequest } from "../lib/queries/leave";
|
||||||
|
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
@@ -39,26 +39,13 @@ const leaveTypeClasses: Record<string, string> = {
|
|||||||
unpaid: "badge-unpaid",
|
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() {
|
export default function LeaveRequests() {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: requests = [], isPending } = useQuery(
|
const { data: requests = [], isPending } = useQuery(
|
||||||
leaveRequestsOptions(true),
|
leaveRequestsOptions(true),
|
||||||
) as { data: LeaveRequest[]; isPending: boolean };
|
);
|
||||||
const [cancelModal, setCancelModal] = useState<{
|
const [cancelModal, setCancelModal] = useState<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
id: number | null;
|
id: number | null;
|
||||||
@@ -82,6 +69,8 @@ export default function LeaveRequests() {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
setCancelModal({ open: false, id: null });
|
setCancelModal({ open: false, id: null });
|
||||||
queryClient.invalidateQueries({ queryKey: ["leave-requests"] });
|
queryClient.invalidateQueries({ queryKey: ["leave-requests"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["leave"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||||
alert.success(result.message);
|
alert.success(result.message);
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error);
|
alert.error(result.error);
|
||||||
|
|||||||
@@ -14,8 +14,17 @@ import { motion, AnimatePresence } from "framer-motion";
|
|||||||
import {
|
import {
|
||||||
offerDetailOptions,
|
offerDetailOptions,
|
||||||
offerCustomersOptions,
|
offerCustomersOptions,
|
||||||
offerTemplatesOptions,
|
scopeTemplatesOptions,
|
||||||
offerNextNumberOptions,
|
offerNextNumberOptions,
|
||||||
|
itemTemplatesOptions,
|
||||||
|
type ItemTemplate,
|
||||||
|
type OfferDetailData,
|
||||||
|
type OfferItemData,
|
||||||
|
type OfferSectionData,
|
||||||
|
type OfferLockInfo,
|
||||||
|
type OfferOrderInfo,
|
||||||
|
type ScopeTemplate,
|
||||||
|
type Customer,
|
||||||
} from "../lib/queries/offers";
|
} from "../lib/queries/offers";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -48,9 +57,10 @@ import useModalLock from "../hooks/useModalLock";
|
|||||||
import useDebounce from "../hooks/useDebounce";
|
import useDebounce from "../hooks/useDebounce";
|
||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
import { formatCurrency } from "../utils/formatters";
|
import { formatCurrency } from "../utils/formatters";
|
||||||
import { Skeleton } from "boneyard-js/react";
|
import {
|
||||||
import OfferDetailFixture from "../fixtures/OfferDetailFixture";
|
companySettingsOptions,
|
||||||
import { companySettingsOptions } from "../lib/queries/settings";
|
type CompanySettingsData,
|
||||||
|
} from "../lib/queries/settings";
|
||||||
|
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
const DRAFT_KEY = "boha_offer_draft";
|
const DRAFT_KEY = "boha_offer_draft";
|
||||||
@@ -83,20 +93,6 @@ interface OfferForm {
|
|||||||
language: string;
|
language: string;
|
||||||
vat_rate: number;
|
vat_rate: number;
|
||||||
apply_vat: boolean;
|
apply_vat: boolean;
|
||||||
exchange_rate: string;
|
|
||||||
scope_title: string;
|
|
||||||
scope_description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Customer {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
city?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OrderInfo {
|
|
||||||
id: number;
|
|
||||||
order_number: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyForm: OfferForm = {
|
const emptyForm: OfferForm = {
|
||||||
@@ -110,9 +106,6 @@ const emptyForm: OfferForm = {
|
|||||||
language: "EN",
|
language: "EN",
|
||||||
vat_rate: 21,
|
vat_rate: 21,
|
||||||
apply_vat: false,
|
apply_vat: false,
|
||||||
exchange_rate: "",
|
|
||||||
scope_title: "",
|
|
||||||
scope_description: "",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyScopeSection = (): ScopeSection => ({
|
const emptyScopeSection = (): ScopeSection => ({
|
||||||
@@ -206,7 +199,7 @@ function SortableItemRow({
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={item.quantity}
|
value={item.quantity}
|
||||||
onChange={(e) => onUpdate("quantity", e.target.value)}
|
onChange={(e) => onUpdate("quantity", parseInt(e.target.value, 10))}
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
step="1"
|
step="1"
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
@@ -225,7 +218,7 @@ function SortableItemRow({
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={item.unit_price}
|
value={item.unit_price}
|
||||||
onChange={(e) => onUpdate("unit_price", e.target.value)}
|
onChange={(e) => onUpdate("unit_price", parseFloat(e.target.value))}
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
@@ -318,34 +311,16 @@ export default function OfferDetail() {
|
|||||||
...offerCustomersOptions(),
|
...offerCustomersOptions(),
|
||||||
enabled: !isEdit,
|
enabled: !isEdit,
|
||||||
});
|
});
|
||||||
const { data: templatesData } = useQuery({
|
const { data: templatesData } = useQuery(scopeTemplatesOptions());
|
||||||
...offerTemplatesOptions(),
|
const { data: itemTemplates } = useQuery(itemTemplatesOptions());
|
||||||
enabled: !isEdit,
|
|
||||||
});
|
|
||||||
const { data: nextNumberData } = useQuery({
|
const { data: nextNumberData } = useQuery({
|
||||||
...offerNextNumberOptions(),
|
...offerNextNumberOptions(),
|
||||||
enabled: !isEdit,
|
enabled: !isEdit,
|
||||||
});
|
});
|
||||||
// Derive typed arrays from query data
|
const customers: Customer[] = Array.isArray(customersData)
|
||||||
const customers = (
|
|
||||||
Array.isArray(customersData)
|
|
||||||
? customersData
|
? customersData
|
||||||
: (customersData as Record<string, unknown>)?.customers
|
: [];
|
||||||
? ((customersData as Record<string, unknown>).customers as Customer[])
|
const scopeTemplates = templatesData ?? [];
|
||||||
: []
|
|
||||||
) as Customer[];
|
|
||||||
const scopeTemplates = (
|
|
||||||
Array.isArray(templatesData) ? templatesData : []
|
|
||||||
) as Array<{
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
scope_template_sections?: Array<{
|
|
||||||
title?: string;
|
|
||||||
title_cz?: string;
|
|
||||||
content?: string;
|
|
||||||
}>;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
const loading = isEdit && offerQuery.isLoading;
|
const loading = isEdit && offerQuery.isLoading;
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -369,13 +344,6 @@ export default function OfferDetail() {
|
|||||||
language: (draft.form.language as string) || emptyForm.language,
|
language: (draft.form.language as string) || emptyForm.language,
|
||||||
vat_rate: (draft.form.vat_rate as number) ?? emptyForm.vat_rate,
|
vat_rate: (draft.form.vat_rate as number) ?? emptyForm.vat_rate,
|
||||||
apply_vat: (draft.form.apply_vat as boolean) ?? emptyForm.apply_vat,
|
apply_vat: (draft.form.apply_vat as boolean) ?? emptyForm.apply_vat,
|
||||||
exchange_rate:
|
|
||||||
(draft.form.exchange_rate as string) || emptyForm.exchange_rate,
|
|
||||||
scope_title:
|
|
||||||
(draft.form.scope_title as string) || emptyForm.scope_title,
|
|
||||||
scope_description:
|
|
||||||
(draft.form.scope_description as string) ||
|
|
||||||
emptyForm.scope_description,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return emptyForm;
|
return emptyForm;
|
||||||
@@ -396,7 +364,7 @@ export default function OfferDetail() {
|
|||||||
});
|
});
|
||||||
const [customerSearch, setCustomerSearch] = useState("");
|
const [customerSearch, setCustomerSearch] = useState("");
|
||||||
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
|
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
|
||||||
const [orderInfo, setOrderInfo] = useState<OrderInfo | null>(null);
|
const [orderInfo, setOrderInfo] = useState<OfferOrderInfo | null>(null);
|
||||||
const [offerStatus, setOfferStatus] = useState<string>("");
|
const [offerStatus, setOfferStatus] = useState<string>("");
|
||||||
|
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||||
@@ -409,14 +377,7 @@ export default function OfferDetail() {
|
|||||||
const [orderAttachment, setOrderAttachment] = useState<File | null>(null);
|
const [orderAttachment, setOrderAttachment] = useState<File | null>(null);
|
||||||
const [pdfLoading, setPdfLoading] = useState(false);
|
const [pdfLoading, setPdfLoading] = useState(false);
|
||||||
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||||
const companySettings = useQuery(companySettingsOptions()).data as unknown as
|
const { data: companySettings } = useQuery(companySettingsOptions());
|
||||||
| {
|
|
||||||
default_currency: string;
|
|
||||||
default_vat_rate: number;
|
|
||||||
available_currencies: string[];
|
|
||||||
available_vat_rates: number[];
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (companySettings && !isEdit) {
|
if (companySettings && !isEdit) {
|
||||||
@@ -452,40 +413,32 @@ export default function OfferDetail() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isInvalidated = offerStatus === "invalidated";
|
const isInvalidated = offerStatus === "invalidated";
|
||||||
|
const isCompleted = orderInfo?.status === "dokoncena";
|
||||||
const isLockedByOther = !!lockedBy;
|
const isLockedByOther = !!lockedBy;
|
||||||
const isExpiredNotInvalidated =
|
const readOnly = isInvalidated || isLockedByOther || isCompleted;
|
||||||
isEdit &&
|
const canInvalidate = isEdit && !isInvalidated && !isCompleted && !orderInfo;
|
||||||
!isInvalidated &&
|
|
||||||
!orderInfo &&
|
|
||||||
form.valid_until &&
|
|
||||||
new Date(form.valid_until) < new Date(new Date().toDateString());
|
|
||||||
|
|
||||||
// Sync offer detail data to form state on first load (edit mode)
|
// Sync offer detail data to form state on first load (edit mode)
|
||||||
const formInitializedRef = useRef(false);
|
const formInitializedRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!offerQuery.data || formInitializedRef.current) return;
|
if (!offerQuery.data || formInitializedRef.current) return;
|
||||||
const d = offerQuery.data as Record<string, unknown>;
|
const d = offerQuery.data;
|
||||||
const formData: OfferForm = {
|
const formData: OfferForm = {
|
||||||
quotation_number: (d.quotation_number as string) || "",
|
quotation_number: d.quotation_number || "",
|
||||||
project_code: (d.project_code as string) || "",
|
project_code: d.project_code || "",
|
||||||
customer_id: (d.customer_id as number | null) ?? null,
|
customer_id: d.customer_id ?? null,
|
||||||
customer_name: (d.customer_name as string) || "",
|
customer_name: d.customer_name || "",
|
||||||
created_at: d.created_at ? String(d.created_at).substring(0, 10) : "",
|
created_at: d.created_at ? d.created_at.substring(0, 10) : "",
|
||||||
valid_until: d.valid_until ? String(d.valid_until).substring(0, 10) : "",
|
valid_until: d.valid_until ? d.valid_until.substring(0, 10) : "",
|
||||||
currency:
|
currency: d.currency || companySettings?.default_currency || "CZK",
|
||||||
(d.currency as string) || companySettings?.default_currency || "CZK",
|
language: d.language || "EN",
|
||||||
language: (d.language as string) || "EN",
|
vat_rate: d.vat_rate ?? companySettings?.default_vat_rate ?? 21,
|
||||||
vat_rate:
|
|
||||||
(d.vat_rate as number) ?? companySettings?.default_vat_rate ?? 21,
|
|
||||||
apply_vat: !!d.apply_vat,
|
apply_vat: !!d.apply_vat,
|
||||||
exchange_rate: (d.exchange_rate as string) || "",
|
|
||||||
scope_title: (d.scope_title as string) || "",
|
|
||||||
scope_description: (d.scope_description as string) || "",
|
|
||||||
};
|
};
|
||||||
setForm(formData);
|
setForm(formData);
|
||||||
const mappedItems =
|
const mappedItems =
|
||||||
Array.isArray(d.items) && d.items.length
|
Array.isArray(d.items) && d.items.length
|
||||||
? (d.items as any[]).map((it: any) => ({
|
? d.items.map((it) => ({
|
||||||
...it,
|
...it,
|
||||||
_key: `item-${++itemKeyCounter.current}`,
|
_key: `item-${++itemKeyCounter.current}`,
|
||||||
}))
|
}))
|
||||||
@@ -493,7 +446,7 @@ export default function OfferDetail() {
|
|||||||
setItems(mappedItems);
|
setItems(mappedItems);
|
||||||
const mappedSections =
|
const mappedSections =
|
||||||
Array.isArray(d.sections) && d.sections.length
|
Array.isArray(d.sections) && d.sections.length
|
||||||
? (d.sections as any[]).map((s: any) => ({
|
? d.sections.map((s) => ({
|
||||||
title: s.title || "",
|
title: s.title || "",
|
||||||
title_cz: s.title_cz || "",
|
title_cz: s.title_cz || "",
|
||||||
content: s.content || "",
|
content: s.content || "",
|
||||||
@@ -505,14 +458,15 @@ export default function OfferDetail() {
|
|||||||
items: mappedItems,
|
items: mappedItems,
|
||||||
sections: mappedSections,
|
sections: mappedSections,
|
||||||
});
|
});
|
||||||
setOfferStatus((d.status as string) || "");
|
setOfferStatus(d.status || "");
|
||||||
setOrderInfo((d.order as OrderInfo) ?? null);
|
setOrderInfo(d.order ?? null);
|
||||||
setLockedBy((d.locked_by as typeof lockedBy) ?? null);
|
setLockedBy(d.locked_by ?? null);
|
||||||
|
|
||||||
// Try to acquire lock if not locked by someone else and not invalidated
|
// Try to acquire lock if not locked by someone else, not invalidated, and not completed
|
||||||
if (
|
if (
|
||||||
!d.locked_by &&
|
!d.locked_by &&
|
||||||
d.status !== "invalidated" &&
|
d.status !== "invalidated" &&
|
||||||
|
d.order?.status !== "dokoncena" &&
|
||||||
hasPermission("offers.edit")
|
hasPermission("offers.edit")
|
||||||
) {
|
) {
|
||||||
apiFetch(`${API_BASE}/offers/${id}/lock`, { method: "POST" }).catch(
|
apiFetch(`${API_BASE}/offers/${id}/lock`, { method: "POST" }).catch(
|
||||||
@@ -533,10 +487,7 @@ export default function OfferDetail() {
|
|||||||
// Sync next-number data to form (create mode)
|
// Sync next-number data to form (create mode)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isEdit || !nextNumberData) return;
|
if (isEdit || !nextNumberData) return;
|
||||||
const num =
|
const num = nextNumberData.next_number || nextNumberData.number || "";
|
||||||
((nextNumberData as Record<string, unknown>).next_number as string) ||
|
|
||||||
((nextNumberData as Record<string, unknown>).number as string) ||
|
|
||||||
"";
|
|
||||||
if (num) {
|
if (num) {
|
||||||
setForm((prev) => ({ ...prev, quotation_number: num }));
|
setForm((prev) => ({ ...prev, quotation_number: num }));
|
||||||
}
|
}
|
||||||
@@ -544,7 +495,8 @@ export default function OfferDetail() {
|
|||||||
|
|
||||||
// Heartbeat to keep lock alive + cleanup on unmount
|
// Heartbeat to keep lock alive + cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEdit || !id || isLockedByOther || isInvalidated) return;
|
if (!isEdit || !id || isLockedByOther || isInvalidated || isCompleted)
|
||||||
|
return;
|
||||||
|
|
||||||
heartbeatRef.current = setInterval(() => {
|
heartbeatRef.current = setInterval(() => {
|
||||||
apiFetch(`${API_BASE}/offers/${id}/heartbeat`, { method: "POST" }).catch(
|
apiFetch(`${API_BASE}/offers/${id}/heartbeat`, { method: "POST" }).catch(
|
||||||
@@ -614,7 +566,6 @@ export default function OfferDetail() {
|
|||||||
language: data.form.language ?? "EN",
|
language: data.form.language ?? "EN",
|
||||||
vat_rate: data.form.vat_rate ?? 21,
|
vat_rate: data.form.vat_rate ?? 21,
|
||||||
apply_vat: data.form.apply_vat ?? false,
|
apply_vat: data.form.apply_vat ?? false,
|
||||||
exchange_rate: data.form.exchange_rate ?? "",
|
|
||||||
},
|
},
|
||||||
items: data.items,
|
items: data.items,
|
||||||
sections: data.sections,
|
sections: data.sections,
|
||||||
@@ -719,6 +670,10 @@ export default function OfferDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!isEdit && result.data?.id) {
|
if (!isEdit && result.data?.id) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||||
navigate(`/offers/${result.data.id}`);
|
navigate(`/offers/${result.data.id}`);
|
||||||
}
|
}
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
@@ -730,6 +685,7 @@ export default function OfferDetail() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error || "Nepodařilo se uložit nabídku");
|
alert.error(result.error || "Nepodařilo se uložit nabídku");
|
||||||
@@ -772,9 +728,10 @@ export default function OfferDetail() {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
setShowOrderModal(false);
|
setShowOrderModal(false);
|
||||||
alert.success(result.message || "Objednávka byla vytvořena");
|
alert.success(result.message || "Objednávka byla vytvořena");
|
||||||
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
await queryClient.invalidateQueries({ queryKey: ["offers"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
await queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
await queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||||
navigate(`/orders/${result.data.order_id}`);
|
navigate(`/orders/${result.data.order_id}`);
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error || "Nepodařilo se vytvořit objednávku");
|
alert.error(result.error || "Nepodařilo se vytvořit objednávku");
|
||||||
@@ -800,6 +757,7 @@ export default function OfferDetail() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error || "Nepodařilo se zneplatnit nabídku");
|
alert.error(result.error || "Nepodařilo se zneplatnit nabídku");
|
||||||
}
|
}
|
||||||
@@ -819,9 +777,10 @@ export default function OfferDetail() {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
alert.success(result.message || "Nabídka byla smazána");
|
alert.success(result.message || "Nabídka byla smazána");
|
||||||
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
await queryClient.invalidateQueries({ queryKey: ["offers"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
await queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
await queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||||
navigate("/offers");
|
navigate("/offers");
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error || "Nepodařilo se smazat nabídku");
|
alert.error(result.error || "Nepodařilo se smazat nabídku");
|
||||||
@@ -864,17 +823,19 @@ export default function OfferDetail() {
|
|||||||
|
|
||||||
const getRequiredPerm = () => {
|
const getRequiredPerm = () => {
|
||||||
if (!isEdit) return "offers.create";
|
if (!isEdit) return "offers.create";
|
||||||
return isInvalidated ? "offers.view" : "offers.edit";
|
return isInvalidated || isCompleted ? "offers.view" : "offers.edit";
|
||||||
};
|
};
|
||||||
const requiredPerm = getRequiredPerm();
|
const requiredPerm = getRequiredPerm();
|
||||||
if (!hasPermission(requiredPerm)) return <Forbidden />;
|
if (!hasPermission(requiredPerm)) return <Forbidden />;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="admin-loading">
|
||||||
|
<div className="admin-spinner" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Skeleton
|
|
||||||
name="offer-detail"
|
|
||||||
loading={loading}
|
|
||||||
fixture={<OfferDetailFixture />}
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -915,6 +876,17 @@ export default function OfferDetail() {
|
|||||||
Zneplatněna
|
Zneplatněna
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{isCompleted && (
|
||||||
|
<span
|
||||||
|
className="admin-badge admin-badge-success text-xs"
|
||||||
|
style={{
|
||||||
|
marginLeft: "0.75rem",
|
||||||
|
verticalAlign: "middle",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Dokončeno
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -949,7 +921,7 @@ export default function OfferDetail() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{isEdit &&
|
{isEdit &&
|
||||||
!isInvalidated &&
|
!readOnly &&
|
||||||
hasPermission("orders.create") &&
|
hasPermission("orders.create") &&
|
||||||
!orderInfo && (
|
!orderInfo && (
|
||||||
<button
|
<button
|
||||||
@@ -971,7 +943,7 @@ export default function OfferDetail() {
|
|||||||
Objednávka {orderInfo.order_number}
|
Objednávka {orderInfo.order_number}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{isExpiredNotInvalidated && hasPermission("offers.edit") && (
|
{canInvalidate && hasPermission("offers.edit") && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setInvalidateConfirm(true)}
|
onClick={() => setInvalidateConfirm(true)}
|
||||||
className="admin-btn admin-btn-secondary"
|
className="admin-btn admin-btn-secondary"
|
||||||
@@ -979,7 +951,7 @@ export default function OfferDetail() {
|
|||||||
Zneplatnit
|
Zneplatnit
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{!isInvalidated && !isLockedByOther && (
|
{!readOnly && (
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
className="admin-btn admin-btn-primary"
|
className="admin-btn admin-btn-primary"
|
||||||
@@ -1042,7 +1014,7 @@ export default function OfferDetail() {
|
|||||||
|
|
||||||
{/* Quotation Form */}
|
{/* Quotation Form */}
|
||||||
<motion.div
|
<motion.div
|
||||||
className={`admin-editor-section${isInvalidated || isLockedByOther ? " offers-readonly" : ""}`}
|
className={`admin-editor-section${readOnly ? " offers-readonly" : ""}`}
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
transition={{ duration: 0.25, delay: 0.06 }}
|
||||||
@@ -1069,14 +1041,14 @@ export default function OfferDetail() {
|
|||||||
onChange={(e) => updateForm("project_code", e.target.value)}
|
onChange={(e) => updateForm("project_code", e.target.value)}
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
placeholder="Volitelný kód projektu"
|
placeholder="Volitelný kód projektu"
|
||||||
readOnly={isInvalidated || isLockedByOther}
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Zákazník" error={errors.customer_id} required>
|
<FormField label="Zákazník" error={errors.customer_id} required>
|
||||||
{form.customer_id ? (
|
{form.customer_id ? (
|
||||||
<div className="admin-customer-selected">
|
<div className="admin-customer-selected">
|
||||||
<span>{form.customer_name}</span>
|
<span>{form.customer_name}</span>
|
||||||
{!isInvalidated && !isLockedByOther && (
|
{!readOnly && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={clearCustomer}
|
onClick={clearCustomer}
|
||||||
@@ -1112,7 +1084,7 @@ export default function OfferDetail() {
|
|||||||
onFocus={() => setShowCustomerDropdown(true)}
|
onFocus={() => setShowCustomerDropdown(true)}
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
placeholder="Hledat zákazníka..."
|
placeholder="Hledat zákazníka..."
|
||||||
readOnly={isInvalidated || isLockedByOther}
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
{showCustomerDropdown && !isInvalidated && (
|
{showCustomerDropdown && !isInvalidated && (
|
||||||
<div className="admin-customer-dropdown">
|
<div className="admin-customer-dropdown">
|
||||||
@@ -1145,7 +1117,7 @@ export default function OfferDetail() {
|
|||||||
error={errors.created_at}
|
error={errors.created_at}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
{isInvalidated || isLockedByOther ? (
|
{readOnly ? (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={form.created_at}
|
value={form.created_at}
|
||||||
@@ -1163,12 +1135,8 @@ export default function OfferDetail() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField
|
<FormField label="Platnost do" error={errors.valid_until} required>
|
||||||
label="Platnost do"
|
{readOnly ? (
|
||||||
error={errors.valid_until}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
{isInvalidated || isLockedByOther ? (
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={form.valid_until}
|
value={form.valid_until}
|
||||||
@@ -1197,7 +1165,7 @@ export default function OfferDetail() {
|
|||||||
value={form.currency}
|
value={form.currency}
|
||||||
onChange={(e) => updateForm("currency", e.target.value)}
|
onChange={(e) => updateForm("currency", e.target.value)}
|
||||||
className="admin-form-select"
|
className="admin-form-select"
|
||||||
disabled={isInvalidated || isLockedByOther}
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
{(
|
{(
|
||||||
companySettings?.available_currencies || [
|
companySettings?.available_currencies || [
|
||||||
@@ -1218,7 +1186,7 @@ export default function OfferDetail() {
|
|||||||
value={form.language}
|
value={form.language}
|
||||||
onChange={(e) => updateForm("language", e.target.value)}
|
onChange={(e) => updateForm("language", e.target.value)}
|
||||||
className="admin-form-select"
|
className="admin-form-select"
|
||||||
disabled={isInvalidated || isLockedByOther}
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
<option value="EN">English</option>
|
<option value="EN">English</option>
|
||||||
<option value="CZ">Čeština</option>
|
<option value="CZ">Čeština</option>
|
||||||
@@ -1235,12 +1203,10 @@ export default function OfferDetail() {
|
|||||||
updateForm("vat_rate", parseFloat(e.target.value) || 0)
|
updateForm("vat_rate", parseFloat(e.target.value) || 0)
|
||||||
}
|
}
|
||||||
className="admin-form-select flex-1"
|
className="admin-form-select flex-1"
|
||||||
disabled={isInvalidated || isLockedByOther}
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
{(
|
{(
|
||||||
companySettings?.available_vat_rates || [
|
companySettings?.available_vat_rates || [0, 10, 12, 15, 21]
|
||||||
0, 10, 12, 15, 21,
|
|
||||||
]
|
|
||||||
).map((r) => (
|
).map((r) => (
|
||||||
<option key={r} value={r}>
|
<option key={r} value={r}>
|
||||||
{r}%
|
{r}%
|
||||||
@@ -1251,26 +1217,13 @@ export default function OfferDetail() {
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={form.apply_vat}
|
checked={form.apply_vat}
|
||||||
onChange={(e) =>
|
onChange={(e) => updateForm("apply_vat", e.target.checked)}
|
||||||
updateForm("apply_vat", e.target.checked)
|
disabled={readOnly}
|
||||||
}
|
|
||||||
disabled={isInvalidated || isLockedByOther}
|
|
||||||
/>
|
/>
|
||||||
<span>Účtovat DPH</span>
|
<span>Účtovat DPH</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Směnný kurz">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={form.exchange_rate}
|
|
||||||
onChange={(e) => updateForm("exchange_rate", e.target.value)}
|
|
||||||
className="admin-form-input"
|
|
||||||
placeholder="Volitelný"
|
|
||||||
step="0.0001"
|
|
||||||
readOnly={isInvalidated || isLockedByOther}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -1285,13 +1238,60 @@ export default function OfferDetail() {
|
|||||||
<div className="admin-card-body">
|
<div className="admin-card-body">
|
||||||
<div className="admin-card-header flex-between">
|
<div className="admin-card-header flex-between">
|
||||||
<h3 className="admin-card-title">Položky</h3>
|
<h3 className="admin-card-title">Položky</h3>
|
||||||
{!isInvalidated && !isLockedByOther && (
|
{!readOnly && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.5rem",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{itemTemplates && itemTemplates.length > 0 && (
|
||||||
|
<select
|
||||||
|
className="admin-form-select"
|
||||||
|
style={{ width: "auto", minWidth: "160px" }}
|
||||||
|
defaultValue=""
|
||||||
|
onChange={(e) => {
|
||||||
|
const templateId = Number(e.target.value);
|
||||||
|
if (!templateId) return;
|
||||||
|
const template = itemTemplates.find(
|
||||||
|
(t: ItemTemplate) => t.id === templateId,
|
||||||
|
);
|
||||||
|
if (template) {
|
||||||
|
setItems((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
_key: `item-${++itemKeyCounter.current}`,
|
||||||
|
description: template.name || "",
|
||||||
|
item_description: template.description || "",
|
||||||
|
quantity: 1,
|
||||||
|
unit: "ks",
|
||||||
|
unit_price: template.default_price || 0,
|
||||||
|
is_included_in_total: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
alert.success(
|
||||||
|
`Načtena šablona položky "${template.name}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Ze šablony...</option>
|
||||||
|
{itemTemplates.map((t: ItemTemplate) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={addItem}
|
onClick={addItem}
|
||||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||||
>
|
>
|
||||||
+ Přidat položku
|
+ Přidat položku
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{errors.items && (
|
{errors.items && (
|
||||||
@@ -1327,23 +1327,15 @@ export default function OfferDetail() {
|
|||||||
<table className="admin-table">
|
<table className="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{!isInvalidated && !isLockedByOther && (
|
{!readOnly && <th style={{ width: "2rem" }} />}
|
||||||
<th style={{ width: "2rem" }} />
|
|
||||||
)}
|
|
||||||
<th style={{ width: "2.5rem", textAlign: "center" }}>
|
<th style={{ width: "2.5rem", textAlign: "center" }}>
|
||||||
#
|
#
|
||||||
</th>
|
</th>
|
||||||
<th className="offers-col-desc">Popis</th>
|
<th className="offers-col-desc">Popis</th>
|
||||||
<th
|
<th className="offers-col-qty" style={{ width: "5rem" }}>
|
||||||
className="offers-col-qty"
|
|
||||||
style={{ width: "5rem" }}
|
|
||||||
>
|
|
||||||
Množství
|
Množství
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th className="offers-col-unit" style={{ width: "5rem" }}>
|
||||||
className="offers-col-unit"
|
|
||||||
style={{ width: "5rem" }}
|
|
||||||
>
|
|
||||||
Jednotka
|
Jednotka
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
@@ -1364,7 +1356,7 @@ export default function OfferDetail() {
|
|||||||
>
|
>
|
||||||
Celkem
|
Celkem
|
||||||
</th>
|
</th>
|
||||||
{!isInvalidated && !isLockedByOther && (
|
{!readOnly && (
|
||||||
<th
|
<th
|
||||||
className="offers-col-del"
|
className="offers-col-del"
|
||||||
style={{ width: "3rem" }}
|
style={{ width: "3rem" }}
|
||||||
@@ -1379,7 +1371,7 @@ export default function OfferDetail() {
|
|||||||
item={item}
|
item={item}
|
||||||
index={index}
|
index={index}
|
||||||
currency={form.currency}
|
currency={form.currency}
|
||||||
readOnly={isInvalidated || isLockedByOther}
|
readOnly={readOnly}
|
||||||
canDelete={items.length > 1}
|
canDelete={items.length > 1}
|
||||||
onUpdate={(field, value) =>
|
onUpdate={(field, value) =>
|
||||||
updateItem(index, field, value)
|
updateItem(index, field, value)
|
||||||
@@ -1423,7 +1415,7 @@ export default function OfferDetail() {
|
|||||||
<div className="admin-card-body">
|
<div className="admin-card-body">
|
||||||
<div className="admin-card-header flex-between">
|
<div className="admin-card-header flex-between">
|
||||||
<h3 className="admin-card-title">Rozsah projektu</h3>
|
<h3 className="admin-card-title">Rozsah projektu</h3>
|
||||||
{!isInvalidated && !isLockedByOther && (
|
{!readOnly && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -1444,19 +1436,12 @@ export default function OfferDetail() {
|
|||||||
);
|
);
|
||||||
if (template?.scope_template_sections?.length) {
|
if (template?.scope_template_sections?.length) {
|
||||||
const newSections =
|
const newSections =
|
||||||
template.scope_template_sections.map((s: any) => ({
|
template.scope_template_sections.map((s) => ({
|
||||||
title: s.title || "",
|
title: s.title || "",
|
||||||
title_cz: s.title_cz || "",
|
title_cz: s.title_cz || "",
|
||||||
content: s.content || "",
|
content: s.content || "",
|
||||||
}));
|
}));
|
||||||
setSections((prev) => [...prev, ...newSections]);
|
setSections((prev) => [...prev, ...newSections]);
|
||||||
if (template.description) {
|
|
||||||
setForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
scope_description:
|
|
||||||
template.description || prev.scope_description,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
alert.success(`Načtena šablona "${template.name}"`);
|
alert.success(`Načtena šablona "${template.name}"`);
|
||||||
}
|
}
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
@@ -1527,9 +1512,7 @@ export default function OfferDetail() {
|
|||||||
{(form.language === "CZ"
|
{(form.language === "CZ"
|
||||||
? section.title_cz
|
? section.title_cz
|
||||||
: section.title) && (
|
: section.title) && (
|
||||||
<span
|
<span style={{ fontWeight: 400, marginLeft: "0.5rem" }}>
|
||||||
style={{ fontWeight: 400, marginLeft: "0.5rem" }}
|
|
||||||
>
|
|
||||||
—{" "}
|
—{" "}
|
||||||
{form.language === "CZ"
|
{form.language === "CZ"
|
||||||
? section.title_cz || section.title
|
? section.title_cz || section.title
|
||||||
@@ -1537,7 +1520,7 @@ export default function OfferDetail() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{!isInvalidated && !isLockedByOther && (
|
{!readOnly && (
|
||||||
<div style={{ display: "flex", gap: "0.25rem" }}>
|
<div style={{ display: "flex", gap: "0.25rem" }}>
|
||||||
{idx > 0 && (
|
{idx > 0 && (
|
||||||
<button
|
<button
|
||||||
@@ -1639,7 +1622,7 @@ export default function OfferDetail() {
|
|||||||
}
|
}
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
placeholder="Název sekce (anglicky)"
|
placeholder="Název sekce (anglicky)"
|
||||||
readOnly={isInvalidated || isLockedByOther}
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField
|
<FormField
|
||||||
@@ -1666,7 +1649,7 @@ export default function OfferDetail() {
|
|||||||
}
|
}
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
placeholder="Název sekce (česky)"
|
placeholder="Název sekce (česky)"
|
||||||
readOnly={isInvalidated || isLockedByOther}
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
@@ -1684,7 +1667,7 @@ export default function OfferDetail() {
|
|||||||
}
|
}
|
||||||
placeholder="Obsah sekce..."
|
placeholder="Obsah sekce..."
|
||||||
minHeight="120px"
|
minHeight="120px"
|
||||||
readOnly={isInvalidated || isLockedByOther}
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1840,6 +1823,5 @@ export default function OfferDetail() {
|
|||||||
loading={deleting}
|
loading={deleting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Skeleton>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,16 +5,14 @@ import { Link, useNavigate } from "react-router-dom";
|
|||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import ConfirmModal from "../components/ConfirmModal";
|
import ConfirmModal from "../components/ConfirmModal";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { Skeleton } from "boneyard-js/react";
|
|
||||||
import OffersFixture from "../fixtures/OffersFixture";
|
|
||||||
|
|
||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
|
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
|
||||||
import SortIcon from "../components/SortIcon";
|
import SortIcon from "../components/SortIcon";
|
||||||
import useTableSort from "../hooks/useTableSort";
|
import useTableSort from "../hooks/useTableSort";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient, useQuery } from "@tanstack/react-query";
|
||||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||||
import { offerListOptions } from "../lib/queries/offers";
|
import { offerListOptions, offerCustomersOptions } from "../lib/queries/offers";
|
||||||
import useModalLock from "../hooks/useModalLock";
|
import useModalLock from "../hooks/useModalLock";
|
||||||
import Pagination from "../components/Pagination";
|
import Pagination from "../components/Pagination";
|
||||||
import FormField from "../components/FormField";
|
import FormField from "../components/FormField";
|
||||||
@@ -22,6 +20,19 @@ import FormField from "../components/FormField";
|
|||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
const DRAFT_KEY = "boha_offer_draft";
|
const DRAFT_KEY = "boha_offer_draft";
|
||||||
|
|
||||||
|
const STATUS_FILTERS = [
|
||||||
|
{ value: "", label: "Vše" },
|
||||||
|
{ value: "active", label: "Aktivní" },
|
||||||
|
{ value: "ordered", label: "Objednaná" },
|
||||||
|
{ value: "invalidated", label: "Zneplatněná" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ORDER_FILTERS = [
|
||||||
|
{ value: "", label: "Vše" },
|
||||||
|
{ value: "yes", label: "S objednávkou" },
|
||||||
|
{ value: "no", label: "Bez objednávky" },
|
||||||
|
];
|
||||||
|
|
||||||
interface Quotation {
|
interface Quotation {
|
||||||
id: number;
|
id: number;
|
||||||
quotation_number: string;
|
quotation_number: string;
|
||||||
@@ -33,6 +44,7 @@ interface Quotation {
|
|||||||
total: number;
|
total: number;
|
||||||
status: string;
|
status: string;
|
||||||
order_id?: number;
|
order_id?: number;
|
||||||
|
order_status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Draft {
|
interface Draft {
|
||||||
@@ -56,6 +68,11 @@ export default function Offers() {
|
|||||||
useTableSort("quotation_number");
|
useTableSort("quotation_number");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [statusFilter, setStatusFilter] = useState("");
|
||||||
|
const [customerFilter, setCustomerFilter] = useState<number | "">("");
|
||||||
|
const [orderFilter, setOrderFilter] = useState("");
|
||||||
|
|
||||||
|
const { data: customers } = useQuery(offerCustomersOptions());
|
||||||
|
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<{
|
const [deleteConfirm, setDeleteConfirm] = useState<{
|
||||||
show: boolean;
|
show: boolean;
|
||||||
@@ -106,7 +123,15 @@ export default function Offers() {
|
|||||||
isPending,
|
isPending,
|
||||||
isFetching,
|
isFetching,
|
||||||
} = usePaginatedQuery<Quotation>(
|
} = usePaginatedQuery<Quotation>(
|
||||||
offerListOptions({ search, sort, order, page }),
|
offerListOptions({
|
||||||
|
search,
|
||||||
|
sort,
|
||||||
|
order,
|
||||||
|
page,
|
||||||
|
status: statusFilter || undefined,
|
||||||
|
customer_id: customerFilter || undefined,
|
||||||
|
has_order: orderFilter || undefined,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const discardDraft = () => {
|
const discardDraft = () => {
|
||||||
@@ -118,8 +143,13 @@ export default function Offers() {
|
|||||||
setDraft(null);
|
setDraft(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRowClass = (invalidated: boolean, expired: boolean) => {
|
const getRowClass = (
|
||||||
|
invalidated: boolean,
|
||||||
|
expired: boolean,
|
||||||
|
completed: boolean,
|
||||||
|
) => {
|
||||||
if (invalidated) return "offers-invalidated-row";
|
if (invalidated) return "offers-invalidated-row";
|
||||||
|
if (completed) return "offers-completed-row";
|
||||||
if (expired) return "offers-expired-row";
|
if (expired) return "offers-expired-row";
|
||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
@@ -140,6 +170,9 @@ export default function Offers() {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
alert.success(result.message || "Nabídka byla duplikována");
|
alert.success(result.message || "Nabídka byla duplikována");
|
||||||
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error || "Nepodařilo se duplikovat nabídku");
|
alert.error(result.error || "Nepodařilo se duplikovat nabídku");
|
||||||
}
|
}
|
||||||
@@ -168,10 +201,11 @@ export default function Offers() {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
setOrderModal({ show: false, quotation: null });
|
setOrderModal({ show: false, quotation: null });
|
||||||
alert.success(result.message || "Objednávka byla vytvořena");
|
alert.success(result.message || "Objednávka byla vytvořena");
|
||||||
navigate(`/orders/${result.data.order_id}`);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||||
|
navigate(`/orders/${result.data.order_id}`);
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error || "Nepodařilo se vytvořit objednávku");
|
alert.error(result.error || "Nepodařilo se vytvořit objednávku");
|
||||||
}
|
}
|
||||||
@@ -199,6 +233,7 @@ export default function Offers() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error || "Nepodařilo se smazat nabídku");
|
alert.error(result.error || "Nepodařilo se smazat nabídku");
|
||||||
}
|
}
|
||||||
@@ -226,6 +261,7 @@ export default function Offers() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error || "Nepodařilo se zneplatnit nabídku");
|
alert.error(result.error || "Nepodařilo se zneplatnit nabídku");
|
||||||
}
|
}
|
||||||
@@ -267,8 +303,15 @@ export default function Offers() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return (
|
||||||
|
<div className="admin-loading">
|
||||||
|
<div className="admin-spinner" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Skeleton name="offers" loading={isPending} fixture={<OffersFixture />}>
|
|
||||||
<div>
|
<div>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-page-header"
|
className="admin-page-header"
|
||||||
@@ -289,7 +332,7 @@ export default function Offers() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-page-actions">
|
<div className="admin-page-actions">
|
||||||
{hasPermission("settings.manage") && (
|
{hasPermission("settings.templates") && (
|
||||||
<Link
|
<Link
|
||||||
to="/offers/templates"
|
to="/offers/templates"
|
||||||
className="admin-btn admin-btn-secondary"
|
className="admin-btn admin-btn-secondary"
|
||||||
@@ -327,11 +370,32 @@ export default function Offers() {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.25, delay: 0.1 }}
|
||||||
|
>
|
||||||
|
<div className="admin-tabs mb-6">
|
||||||
|
{STATUS_FILTERS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.value}
|
||||||
|
className={`admin-tab ${statusFilter === f.value ? "active" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
setStatusFilter(f.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-card"
|
className="admin-card"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
transition={{ duration: 0.25, delay: 0.15 }}
|
||||||
style={{ opacity: isFetching ? 0.6 : 1, transition: "opacity 0.2s" }}
|
style={{ opacity: isFetching ? 0.6 : 1, transition: "opacity 0.2s" }}
|
||||||
>
|
>
|
||||||
<div className="admin-card-body">
|
<div className="admin-card-body">
|
||||||
@@ -348,6 +412,54 @@ export default function Offers() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-filters mb-4">
|
||||||
|
<div className="admin-filter-group">
|
||||||
|
<label className="admin-filter-label">Zákazník</label>
|
||||||
|
<select
|
||||||
|
value={customerFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCustomerFilter(
|
||||||
|
e.target.value ? Number(e.target.value) : "",
|
||||||
|
);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="admin-form-select"
|
||||||
|
>
|
||||||
|
<option value="">Všichni</option>
|
||||||
|
{customers?.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="admin-filter-group">
|
||||||
|
<label className="admin-filter-label">Objednávka</label>
|
||||||
|
<select
|
||||||
|
value={orderFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setOrderFilter(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="admin-form-select"
|
||||||
|
>
|
||||||
|
{ORDER_FILTERS.map((f) => (
|
||||||
|
<option key={f.value} value={f.value}>
|
||||||
|
{f.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={`${statusFilter}-${customerFilter}-${orderFilter}-${search}-${page}-${quotations.length}`}
|
||||||
|
initial={{ opacity: 0, y: 6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
>
|
||||||
{quotations.length === 0 && !draft ? (
|
{quotations.length === 0 && !draft ? (
|
||||||
<div className="admin-empty-state">
|
<div className="admin-empty-state">
|
||||||
<div className="admin-empty-icon">
|
<div className="admin-empty-icon">
|
||||||
@@ -520,16 +632,24 @@ export default function Offers() {
|
|||||||
)}
|
)}
|
||||||
{(quotations as Quotation[]).map((q) => {
|
{(quotations as Quotation[]).map((q) => {
|
||||||
const isInvalidated = q.status === "invalidated";
|
const isInvalidated = q.status === "invalidated";
|
||||||
|
const isCompleted =
|
||||||
|
!isInvalidated && q.order_status === "dokoncena";
|
||||||
const isExpired =
|
const isExpired =
|
||||||
!isInvalidated &&
|
!isInvalidated &&
|
||||||
|
!isCompleted &&
|
||||||
!q.order_id &&
|
!q.order_id &&
|
||||||
q.valid_until &&
|
q.valid_until &&
|
||||||
new Date(q.valid_until) <
|
new Date(q.valid_until) <
|
||||||
new Date(new Date().toDateString());
|
new Date(new Date().toDateString());
|
||||||
|
const readOnly = isInvalidated || isCompleted;
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={q.id}
|
key={q.id}
|
||||||
className={getRowClass(isInvalidated, !!isExpired)}
|
className={getRowClass(
|
||||||
|
isInvalidated,
|
||||||
|
!!isExpired,
|
||||||
|
isCompleted,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<td>
|
<td>
|
||||||
<Link
|
<Link
|
||||||
@@ -560,12 +680,10 @@ export default function Offers() {
|
|||||||
<Link
|
<Link
|
||||||
to={`/offers/${q.id}`}
|
to={`/offers/${q.id}`}
|
||||||
className="admin-btn-icon"
|
className="admin-btn-icon"
|
||||||
title={isInvalidated ? "Zobrazit" : "Upravit"}
|
title={readOnly ? "Zobrazit" : "Upravit"}
|
||||||
aria-label={
|
aria-label={readOnly ? "Zobrazit" : "Upravit"}
|
||||||
isInvalidated ? "Zobrazit" : "Upravit"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{isInvalidated ? (
|
{readOnly ? (
|
||||||
<svg
|
<svg
|
||||||
width="18"
|
width="18"
|
||||||
height="18"
|
height="18"
|
||||||
@@ -591,7 +709,7 @@ export default function Offers() {
|
|||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
{!isInvalidated &&
|
{!readOnly &&
|
||||||
hasPermission("offers.create") && (
|
hasPermission("offers.create") && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDuplicate(q)}
|
onClick={() => handleDuplicate(q)}
|
||||||
@@ -619,7 +737,7 @@ export default function Offers() {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{!isInvalidated && q.order_id ? (
|
{!readOnly && q.order_id ? (
|
||||||
<Link
|
<Link
|
||||||
to={`/orders/${q.order_id}`}
|
to={`/orders/${q.order_id}`}
|
||||||
className="admin-btn-icon accent"
|
className="admin-btn-icon accent"
|
||||||
@@ -650,7 +768,7 @@ export default function Offers() {
|
|||||||
</svg>
|
</svg>
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
!isInvalidated &&
|
!readOnly &&
|
||||||
hasPermission("orders.create") && (
|
hasPermission("orders.create") && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -685,15 +803,26 @@ export default function Offers() {
|
|||||||
>
|
>
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
<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" />
|
<polyline points="14 2 14 8 20 8" />
|
||||||
<line x1="12" y1="11" x2="12" y2="17" />
|
<line
|
||||||
<line x1="9" y1="14" x2="15" y2="14" />
|
x1="12"
|
||||||
|
y1="11"
|
||||||
|
x2="12"
|
||||||
|
y2="17"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="9"
|
||||||
|
y1="14"
|
||||||
|
x2="15"
|
||||||
|
y2="14"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{isExpired &&
|
{!isInvalidated &&
|
||||||
!isInvalidated &&
|
!isCompleted &&
|
||||||
|
!q.order_id &&
|
||||||
hasPermission("offers.edit") && (
|
hasPermission("offers.edit") && (
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -800,6 +929,8 @@ export default function Offers() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
<Pagination pagination={pagination} onPageChange={setPage} />
|
<Pagination pagination={pagination} onPageChange={setPage} />
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -980,6 +1111,5 @@ export default function Offers() {
|
|||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
</Skeleton>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import ConfirmModal from "../components/ConfirmModal";
|
|||||||
import FormField from "../components/FormField";
|
import FormField from "../components/FormField";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import useModalLock from "../hooks/useModalLock";
|
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 { Skeleton } from "boneyard-js/react";
|
||||||
import OffersCustomersFixture from "../fixtures/OffersCustomersFixture";
|
import OffersCustomersFixture from "../fixtures/OffersCustomersFixture";
|
||||||
|
|
||||||
@@ -31,27 +35,6 @@ const CUSTOMER_FIELD_LABELS: Record<string, string> = {
|
|||||||
vat_id: "DIČ",
|
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 {
|
interface CustomerForm {
|
||||||
name: string;
|
name: string;
|
||||||
street: string;
|
street: string;
|
||||||
@@ -66,9 +49,7 @@ export default function OffersCustomers() {
|
|||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: customers = [], isPending } = useQuery(
|
const { data: customers = [], isPending } = useQuery(offerCustomersOptions());
|
||||||
offerCustomersOptions(),
|
|
||||||
) as { data: Customer[]; isPending: boolean };
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
@@ -232,6 +213,7 @@ export default function OffersCustomers() {
|
|||||||
: "Zákazník byl vytvořen"),
|
: "Zákazník byl vytvořen"),
|
||||||
);
|
);
|
||||||
queryClient.invalidateQueries({ queryKey: ["offer-customers"] });
|
queryClient.invalidateQueries({ queryKey: ["offer-customers"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error || "Nepodařilo se uložit zákazníka");
|
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 });
|
setDeleteConfirm({ show: false, customer: null });
|
||||||
alert.success(result.message || "Zákazník byl smazán");
|
alert.success(result.message || "Zákazník byl smazán");
|
||||||
queryClient.invalidateQueries({ queryKey: ["offer-customers"] });
|
queryClient.invalidateQueries({ queryKey: ["offer-customers"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error || "Nepodařilo se smazat zákazníka");
|
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
|
const filteredCustomers = search
|
||||||
? customers.filter(
|
? customers.filter(
|
||||||
@@ -300,7 +283,7 @@ export default function OffersCustomers() {
|
|||||||
<h1 className="admin-page-title">Zákazníci</h1>
|
<h1 className="admin-page-title">Zákazníci</h1>
|
||||||
<p className="admin-page-subtitle">Správa zákazníků pro nabídky</p>
|
<p className="admin-page-subtitle">Správa zákazníků pro nabídky</p>
|
||||||
</div>
|
</div>
|
||||||
{hasPermission("offers.create") && (
|
{hasPermission("customers.create") && (
|
||||||
<button
|
<button
|
||||||
onClick={openCreateModal}
|
onClick={openCreateModal}
|
||||||
className="admin-btn admin-btn-primary"
|
className="admin-btn admin-btn-primary"
|
||||||
@@ -345,7 +328,7 @@ export default function OffersCustomers() {
|
|||||||
? "Žádní zákazníci odpovídající hledání."
|
? "Žádní zákazníci odpovídající hledání."
|
||||||
: "Zatím nejsou žádní zákazníci."}
|
: "Zatím nejsou žádní zákazníci."}
|
||||||
</p>
|
</p>
|
||||||
{!search && hasPermission("offers.create") && (
|
{!search && hasPermission("customers.create") && (
|
||||||
<button
|
<button
|
||||||
onClick={openCreateModal}
|
onClick={openCreateModal}
|
||||||
className="admin-btn admin-btn-primary"
|
className="admin-btn admin-btn-primary"
|
||||||
@@ -398,7 +381,7 @@ export default function OffersCustomers() {
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="admin-table-actions">
|
<div className="admin-table-actions">
|
||||||
{hasPermission("offers.edit") && (
|
{hasPermission("customers.edit") && (
|
||||||
<button
|
<button
|
||||||
onClick={() => openEditModal(customer)}
|
onClick={() => openEditModal(customer)}
|
||||||
className="admin-btn-icon"
|
className="admin-btn-icon"
|
||||||
@@ -418,7 +401,7 @@ export default function OffersCustomers() {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{hasPermission("offers.delete") && (
|
{hasPermission("customers.delete") && (
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setDeleteConfirm({ show: true, customer })
|
setDeleteConfirm({ show: true, customer })
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ import FormField from "../components/FormField";
|
|||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import RichEditor from "../components/RichEditor";
|
import RichEditor from "../components/RichEditor";
|
||||||
import useModalLock from "../hooks/useModalLock";
|
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 { Skeleton } from "boneyard-js/react";
|
||||||
import OffersTemplatesFixture from "../fixtures/OffersTemplatesFixture";
|
import OffersTemplatesFixture from "../fixtures/OffersTemplatesFixture";
|
||||||
|
|
||||||
@@ -16,27 +22,6 @@ import apiFetch from "../utils/api";
|
|||||||
|
|
||||||
const API_BASE = "/api/admin";
|
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 {
|
interface ItemForm {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -53,7 +38,7 @@ export default function OffersTemplates() {
|
|||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const [activeTab, setActiveTab] = useState<"items" | "scopes">("items");
|
const [activeTab, setActiveTab] = useState<"items" | "scopes">("items");
|
||||||
|
|
||||||
if (!hasPermission("settings.manage")) return <Forbidden />;
|
if (!hasPermission("settings.templates")) return <Forbidden />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -96,9 +81,7 @@ export default function OffersTemplates() {
|
|||||||
function ItemTemplatesTab() {
|
function ItemTemplatesTab() {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: templates = [], isPending } = useQuery(
|
const { data: templates = [], isPending } = useQuery(itemTemplatesOptions());
|
||||||
offerTemplatesOptions("items"),
|
|
||||||
) as { data: ItemTemplate[]; isPending: boolean };
|
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [editingTemplate, setEditingTemplate] = useState<ItemTemplate | null>(
|
const [editingTemplate, setEditingTemplate] = useState<ItemTemplate | null>(
|
||||||
null,
|
null,
|
||||||
@@ -157,6 +140,7 @@ function ItemTemplatesTab() {
|
|||||||
await new Promise((r) => setTimeout(r, 300));
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
alert.success(result.message);
|
alert.success(result.message);
|
||||||
queryClient.invalidateQueries({ queryKey: ["offer-templates"] });
|
queryClient.invalidateQueries({ queryKey: ["offer-templates"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error);
|
alert.error(result.error);
|
||||||
}
|
}
|
||||||
@@ -180,6 +164,7 @@ function ItemTemplatesTab() {
|
|||||||
setDeleteConfirm({ show: false, template: null });
|
setDeleteConfirm({ show: false, template: null });
|
||||||
alert.success(result.message);
|
alert.success(result.message);
|
||||||
queryClient.invalidateQueries({ queryKey: ["offer-templates"] });
|
queryClient.invalidateQueries({ queryKey: ["offer-templates"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error);
|
alert.error(result.error);
|
||||||
}
|
}
|
||||||
@@ -365,7 +350,7 @@ function ItemTemplatesTab() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setForm((p) => ({
|
setForm((p) => ({
|
||||||
...p,
|
...p,
|
||||||
default_price: e.target.value,
|
default_price: parseFloat(e.target.value),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
@@ -435,9 +420,7 @@ function ItemTemplatesTab() {
|
|||||||
function ScopeTemplatesTab() {
|
function ScopeTemplatesTab() {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: templates = [], isPending } = useQuery(
|
const { data: templates = [], isPending } = useQuery(scopeTemplatesOptions());
|
||||||
offerTemplatesOptions(),
|
|
||||||
) as { data: ScopeTemplate[]; isPending: boolean };
|
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [editingTemplate, setEditingTemplate] = useState<ScopeTemplate | null>(
|
const [editingTemplate, setEditingTemplate] = useState<ScopeTemplate | null>(
|
||||||
null,
|
null,
|
||||||
@@ -574,6 +557,7 @@ function ScopeTemplatesTab() {
|
|||||||
await new Promise((r) => setTimeout(r, 300));
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
alert.success(result.message);
|
alert.success(result.message);
|
||||||
queryClient.invalidateQueries({ queryKey: ["offer-templates"] });
|
queryClient.invalidateQueries({ queryKey: ["offer-templates"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error);
|
alert.error(result.error);
|
||||||
}
|
}
|
||||||
@@ -597,6 +581,7 @@ function ScopeTemplatesTab() {
|
|||||||
setDeleteConfirm({ show: false, template: null });
|
setDeleteConfirm({ show: false, template: null });
|
||||||
alert.success(result.message);
|
alert.success(result.message);
|
||||||
queryClient.invalidateQueries({ queryKey: ["offer-templates"] });
|
queryClient.invalidateQueries({ queryKey: ["offer-templates"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error);
|
alert.error(result.error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { useState, useEffect, useMemo, useRef, type ReactNode } from "react";
|
import { useState, useEffect, useMemo, useRef, type ReactNode } from "react";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { useParams, useNavigate, Link } from "react-router-dom";
|
import { useParams, useNavigate, Link } from "react-router-dom";
|
||||||
@@ -42,60 +49,6 @@ const TRANSITION_CLASSES: Record<string, string> = {
|
|||||||
dokoncena: "admin-btn admin-btn-primary",
|
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() {
|
export default function OrderDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
@@ -104,7 +57,7 @@ export default function OrderDetail() {
|
|||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const orderQuery = useQuery(orderDetailOptions(id));
|
const orderQuery = useQuery(orderDetailOptions(id));
|
||||||
const order = orderQuery.data as OrderData | undefined;
|
const order = orderQuery.data;
|
||||||
const loading = orderQuery.isPending;
|
const loading = orderQuery.isPending;
|
||||||
|
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
@@ -132,7 +85,7 @@ export default function OrderDetail() {
|
|||||||
// Sync order data to local form state on first load
|
// Sync order data to local form state on first load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (orderQuery.data && !formInitializedRef.current) {
|
if (orderQuery.data && !formInitializedRef.current) {
|
||||||
const orderData = orderQuery.data as OrderData;
|
const orderData = orderQuery.data!;
|
||||||
setNotes(orderData.notes || "");
|
setNotes(orderData.notes || "");
|
||||||
initialNotesRef.current = orderData.notes || "";
|
initialNotesRef.current = orderData.notes || "";
|
||||||
formInitializedRef.current = true;
|
formInitializedRef.current = true;
|
||||||
@@ -199,9 +152,10 @@ export default function OrderDetail() {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
alert.success(result.message || "Stav byl změněn");
|
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: ["offers"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error || "Nepodařilo se změnit stav");
|
alert.error(result.error || "Nepodařilo se změnit stav");
|
||||||
}
|
}
|
||||||
@@ -224,9 +178,10 @@ export default function OrderDetail() {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
alert.success("Poznámky byly uloženy");
|
alert.success("Poznámky byly uloženy");
|
||||||
initialNotesRef.current = notes;
|
initialNotesRef.current = notes;
|
||||||
queryClient.invalidateQueries({ queryKey: ["orders", id] });
|
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error || "Nepodařilo se uložit poznámky");
|
alert.error(result.error || "Nepodařilo se uložit poznámky");
|
||||||
}
|
}
|
||||||
@@ -315,9 +270,10 @@ export default function OrderDetail() {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
alert.success(result.message || "Objednávka byla smazána");
|
alert.success(result.message || "Objednávka byla smazána");
|
||||||
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
await queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
await queryClient.invalidateQueries({ queryKey: ["offers"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
await queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||||
navigate("/orders");
|
navigate("/orders");
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error || "Nepodařilo se smazat objednávku");
|
alert.error(result.error || "Nepodařilo se smazat objednávku");
|
||||||
@@ -416,6 +372,7 @@ export default function OrderDetail() {
|
|||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
{hasPermission("orders.export") && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowConfirmationModal(true)}
|
onClick={() => setShowConfirmationModal(true)}
|
||||||
className="admin-btn admin-btn-secondary"
|
className="admin-btn admin-btn-secondary"
|
||||||
@@ -434,6 +391,7 @@ export default function OrderDetail() {
|
|||||||
</svg>
|
</svg>
|
||||||
Potvrzení objednávky
|
Potvrzení objednávky
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
{hasPermission("orders.edit") &&
|
{hasPermission("orders.edit") &&
|
||||||
order.valid_transitions?.filter((s) => s !== "stornovana")
|
order.valid_transitions?.filter((s) => s !== "stornovana")
|
||||||
.length! > 0 &&
|
.length! > 0 &&
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export default function Orders() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error || "Nepodařilo se smazat objednávku");
|
alert.error(result.error || "Nepodařilo se smazat objednávku");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,12 @@ import { useAuth } from "../context/AuthContext";
|
|||||||
import { useParams, useNavigate, Link } from "react-router-dom";
|
import { useParams, useNavigate, Link } from "react-router-dom";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
import { projectDetailOptions } from "../lib/queries/projects";
|
import {
|
||||||
import { userListOptions } from "../lib/queries/users";
|
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 Forbidden from "../components/Forbidden";
|
||||||
import { Skeleton } from "boneyard-js/react";
|
import { Skeleton } from "boneyard-js/react";
|
||||||
import ProjectDetailFixture from "../fixtures/ProjectDetailFixture";
|
import ProjectDetailFixture from "../fixtures/ProjectDetailFixture";
|
||||||
@@ -35,36 +39,11 @@ function formatNoteDate(dateStr: string) {
|
|||||||
return `${day}. ${month}. ${year} ${hours}:${mins}`;
|
return `${day}. ${month}. ${year} ${hours}:${mins}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Note {
|
|
||||||
id: number;
|
|
||||||
content: string;
|
|
||||||
user_name: string;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
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 {
|
interface ProjectForm {
|
||||||
name: string;
|
name: string;
|
||||||
status: string;
|
status: string;
|
||||||
@@ -99,22 +78,15 @@ export default function ProjectDetail() {
|
|||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const projectQuery = useQuery(projectDetailOptions(id));
|
const projectQuery = useQuery(projectDetailOptions(id));
|
||||||
const project = projectQuery.data as
|
const project = projectQuery.data;
|
||||||
| (ProjectData & { project_notes?: Note[] })
|
|
||||||
| undefined;
|
|
||||||
const isPending = projectQuery.isPending;
|
const isPending = projectQuery.isPending;
|
||||||
const notes: Note[] = project?.project_notes || [];
|
const notes: ProjectNote[] = project?.project_notes || [];
|
||||||
|
|
||||||
const { data: usersData } = useQuery(userListOptions());
|
const { data: usersData } = useQuery(userListOptions("projects.view"));
|
||||||
const rawUsers = ((usersData as Record<string, unknown>)?.items ??
|
const users: User[] = (usersData ?? []).map((u: ApiUser) => ({
|
||||||
usersData ??
|
|
||||||
[]) as any[];
|
|
||||||
const users: User[] = Array.isArray(rawUsers)
|
|
||||||
? rawUsers.map((u: any) => ({
|
|
||||||
id: u.id,
|
id: u.id,
|
||||||
name: `${u.first_name || ""} ${u.last_name || ""}`.trim() || u.username,
|
name: `${u.first_name || ""} ${u.last_name || ""}`.trim() || u.username,
|
||||||
}))
|
}));
|
||||||
: [];
|
|
||||||
|
|
||||||
// Reset form sync when navigating to a different project
|
// Reset form sync when navigating to a different project
|
||||||
const formInitialized = useRef(false);
|
const formInitialized = useRef(false);
|
||||||
@@ -174,6 +146,8 @@ export default function ProjectDetail() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error || "Nepodařilo se uložit projekt");
|
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: ["projects"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||||
navigate("/projects");
|
navigate("/projects");
|
||||||
setTimeout(() => alert.success("Projekt byl smazán"), 300);
|
setTimeout(() => alert.success("Projekt byl smazán"), 300);
|
||||||
} else {
|
} else {
|
||||||
@@ -223,7 +199,11 @@ export default function ProjectDetail() {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
setNewNote("");
|
setNewNote("");
|
||||||
alert.success("Poznámka byla přidána");
|
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 {
|
} else {
|
||||||
alert.error(result.error || "Nepodařilo se přidat poznámku");
|
alert.error(result.error || "Nepodařilo se přidat poznámku");
|
||||||
}
|
}
|
||||||
@@ -246,7 +226,11 @@ export default function ProjectDetail() {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
alert.success("Poznámka byla smazána");
|
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 {
|
} else {
|
||||||
alert.error(result.error || "Nepodařilo se smazat poznámku");
|
alert.error(result.error || "Nepodařilo se smazat poznámku");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ export default function Projects() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||||
} else {
|
} else {
|
||||||
alert.error(data.error || "Nepodařilo se smazat projekt");
|
alert.error(data.error || "Nepodařilo se smazat projekt");
|
||||||
}
|
}
|
||||||
@@ -162,8 +164,7 @@ export default function Projects() {
|
|||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Vytvořte první projekt tlačítkem výše nebo automaticky při
|
Projekt se vytvoří automaticky při vytvoření objednávky.
|
||||||
vytvoření objednávky.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -291,7 +292,7 @@ export default function Projects() {
|
|||||||
</svg>
|
</svg>
|
||||||
</Link>
|
</Link>
|
||||||
{!p.order_id &&
|
{!p.order_id &&
|
||||||
hasPermission("projects.create") && (
|
hasPermission("projects.delete") && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setDeleteTarget(p)}
|
onClick={() => setDeleteTarget(p)}
|
||||||
className="admin-btn-icon danger"
|
className="admin-btn-icon danger"
|
||||||
|
|||||||
@@ -11,11 +11,17 @@ import SortIcon from "../components/SortIcon";
|
|||||||
import useTableSort from "../hooks/useTableSort";
|
import useTableSort from "../hooks/useTableSort";
|
||||||
import useModalLock from "../hooks/useModalLock";
|
import useModalLock from "../hooks/useModalLock";
|
||||||
import AdminDatePicker from "../components/AdminDatePicker";
|
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 { supplierListOptions } from "../lib/queries/common";
|
||||||
import {
|
import {
|
||||||
receivedInvoiceListOptions,
|
receivedInvoiceListOptions,
|
||||||
receivedInvoiceStatsOptions,
|
receivedInvoiceStatsOptions,
|
||||||
|
type ReceivedInvoice,
|
||||||
|
type ReceivedStats,
|
||||||
|
type CurrencyAmount,
|
||||||
} from "../lib/queries/invoices";
|
} from "../lib/queries/invoices";
|
||||||
import { Skeleton } from "boneyard-js/react";
|
import { Skeleton } from "boneyard-js/react";
|
||||||
import ReceivedInvoicesFixture from "../fixtures/ReceivedInvoicesFixture";
|
import ReceivedInvoicesFixture from "../fixtures/ReceivedInvoicesFixture";
|
||||||
@@ -48,37 +54,6 @@ const MONTH_NAMES = [
|
|||||||
"prosinec",
|
"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 {
|
interface UploadMeta {
|
||||||
supplier_name: string;
|
supplier_name: string;
|
||||||
invoice_number: string;
|
invoice_number: string;
|
||||||
@@ -133,14 +108,9 @@ function formatCzkWithDetail(
|
|||||||
return { value: formatMultiCurrency(amounts), detail: null };
|
return { value: formatMultiCurrency(amounts), detail: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CompanySettings {
|
function emptyMeta(
|
||||||
default_currency: string;
|
settings: CompanySettingsData | null | undefined,
|
||||||
default_vat_rate: number;
|
): UploadMeta {
|
||||||
available_currencies: string[];
|
|
||||||
available_vat_rates: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyMeta(settings: CompanySettings | null | undefined): UploadMeta {
|
|
||||||
return {
|
return {
|
||||||
supplier_name: "",
|
supplier_name: "",
|
||||||
invoice_number: "",
|
invoice_number: "",
|
||||||
@@ -182,9 +152,7 @@ export default function ReceivedInvoices({
|
|||||||
const prevYear = useRef(statsYear);
|
const prevYear = useRef(statsYear);
|
||||||
|
|
||||||
const { data: supplierNames = [] } = useQuery(supplierListOptions());
|
const { data: supplierNames = [] } = useQuery(supplierListOptions());
|
||||||
const companySettings = useQuery(companySettingsOptions()).data as unknown as
|
const companySettings = useQuery(companySettingsOptions()).data;
|
||||||
| CompanySettings
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
// List query — auto-refetches when filters change
|
// List query — auto-refetches when filters change
|
||||||
const listQuery = useQuery(
|
const listQuery = useQuery(
|
||||||
@@ -203,11 +171,11 @@ export default function ReceivedInvoices({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Derive list data from query (paginatedJsonQuery returns { data, pagination })
|
// 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;
|
if (listQuery.data || statsQuery.data) hasLoadedOnce.current = true;
|
||||||
|
|
||||||
// Derive stats from query
|
// 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
|
// Trigger slide animation when stats data changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -345,8 +313,10 @@ export default function ReceivedInvoices({
|
|||||||
setUploadMeta([]);
|
setUploadMeta([]);
|
||||||
setUploadErrors({});
|
setUploadErrors({});
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["invoices", "received"],
|
queryKey: ["invoices"],
|
||||||
});
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["suppliers"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
|
||||||
} else {
|
} else {
|
||||||
alert.error(data.error || "Chyba při nahrávání");
|
alert.error(data.error || "Chyba při nahrávání");
|
||||||
}
|
}
|
||||||
@@ -416,8 +386,10 @@ export default function ReceivedInvoices({
|
|||||||
setEditOpen(false);
|
setEditOpen(false);
|
||||||
setEditInvoice(null);
|
setEditInvoice(null);
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["invoices", "received"],
|
queryKey: ["invoices"],
|
||||||
});
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["suppliers"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
|
||||||
} else {
|
} else {
|
||||||
alert.error(data.error || "Chyba při ukládání");
|
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");
|
alert.success(data.message || "Faktura byla smazána");
|
||||||
setDeleteConfirm({ show: false, invoice: null });
|
setDeleteConfirm({ show: false, invoice: null });
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["invoices", "received"],
|
queryKey: ["invoices"],
|
||||||
});
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["suppliers"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
|
||||||
} else {
|
} else {
|
||||||
alert.error(data.error || "Chyba při mazání");
|
alert.error(data.error || "Chyba při mazání");
|
||||||
}
|
}
|
||||||
@@ -491,8 +465,10 @@ export default function ReceivedInvoices({
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
alert.success("Faktura označena jako uhrazená");
|
alert.success("Faktura označena jako uhrazená");
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["invoices", "received"],
|
queryKey: ["invoices"],
|
||||||
});
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["suppliers"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
|
||||||
} else {
|
} else {
|
||||||
alert.error(data.error || "Nepodařilo se změnit stav");
|
alert.error(data.error || "Nepodařilo se změnit stav");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,10 +47,12 @@ interface SystemSettingsData {
|
|||||||
const MODULE_LABELS: Record<string, string> = {
|
const MODULE_LABELS: Record<string, string> = {
|
||||||
attendance: "Docházka",
|
attendance: "Docházka",
|
||||||
trips: "Kniha jízd",
|
trips: "Kniha jízd",
|
||||||
|
vehicles: "Vozidla",
|
||||||
offers: "Nabídky",
|
offers: "Nabídky",
|
||||||
orders: "Objednávky",
|
orders: "Objednávky",
|
||||||
projects: "Projekty",
|
projects: "Projekty",
|
||||||
invoices: "Faktury",
|
invoices: "Faktury",
|
||||||
|
customers: "Zákazníci",
|
||||||
users: "Uživatelé",
|
users: "Uživatelé",
|
||||||
settings: "Nastavení",
|
settings: "Nastavení",
|
||||||
};
|
};
|
||||||
@@ -69,6 +71,7 @@ interface Role {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
permissions: Permission[];
|
permissions: Permission[];
|
||||||
role_permissions?: unknown[];
|
role_permissions?: unknown[];
|
||||||
|
user_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RoleForm {
|
interface RoleForm {
|
||||||
@@ -107,38 +110,44 @@ export default function Settings() {
|
|||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
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({
|
const { data: rolesData, isPending: rolesLoading } = useQuery({
|
||||||
queryKey: ["roles"],
|
queryKey: ["settings", "roles"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const [rolesRes, permsRes, usersRes] = await Promise.all([
|
const [rolesRes, permsRes] = await Promise.all([
|
||||||
apiFetch(`${API_BASE}/roles`),
|
apiFetch(`${API_BASE}/roles`),
|
||||||
apiFetch(`${API_BASE}/roles/permissions`),
|
apiFetch(`${API_BASE}/roles/permissions`),
|
||||||
apiFetch(`${API_BASE}/users`),
|
|
||||||
]);
|
]);
|
||||||
const rolesResult = await rolesRes.json();
|
const rolesResult = await rolesRes.json();
|
||||||
const permsResult = await permsRes.json();
|
const permsResult = await permsRes.json();
|
||||||
const usersResult = await usersRes.json();
|
|
||||||
if (!rolesResult.success)
|
if (!rolesResult.success)
|
||||||
throw new Error(rolesResult.error || "Nepodařilo se načíst role");
|
throw new Error(rolesResult.error || "Nepodařilo se načíst role");
|
||||||
if (!permsResult.success)
|
if (!permsResult.success)
|
||||||
throw new Error(permsResult.error || "Nepodařilo se načíst oprávnění");
|
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 {
|
return {
|
||||||
roles: Array.isArray(rolesResult.data) ? rolesResult.data : [],
|
roles: Array.isArray(rolesResult.data) ? rolesResult.data : [],
|
||||||
permissions: Array.isArray(permsResult.data) ? permsResult.data : [],
|
permissions: Array.isArray(permsResult.data) ? permsResult.data : [],
|
||||||
users: Array.isArray(usersResult.data) ? usersResult.data : [],
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
enabled: canManage,
|
enabled: canManageRoles,
|
||||||
});
|
});
|
||||||
|
|
||||||
const roles = rolesData?.roles ?? [];
|
const roles = rolesData?.roles ?? [];
|
||||||
const users = rolesData?.users ?? [];
|
|
||||||
|
|
||||||
// Group permissions by module
|
// Group permissions by module
|
||||||
const permissionGroups = useMemo<Record<string, Permission[]>>(() => {
|
const permissionGroups = useMemo<Record<string, Permission[]>>(() => {
|
||||||
const perms: Permission[] = rolesData?.permissions ?? [];
|
const perms: Permission[] = rolesData?.permissions ?? [];
|
||||||
@@ -156,27 +165,32 @@ export default function Settings() {
|
|||||||
useQuery(require2FAOptions());
|
useQuery(require2FAOptions());
|
||||||
const require2FA = totpData?.require_2fa ?? false;
|
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 [searchParams, setSearchParams] = useSearchParams();
|
||||||
const tabParam = searchParams.get("tab");
|
const tabParam = searchParams.get("tab");
|
||||||
|
const defaultTab = availableTabs[0] ?? "roles";
|
||||||
const activeTab = (
|
const activeTab = (
|
||||||
tabParam === "system"
|
tabParam === "roles" && availableTabs.includes("roles")
|
||||||
|
? "roles"
|
||||||
|
: tabParam === "system" && availableTabs.includes("system")
|
||||||
? "system"
|
? "system"
|
||||||
: tabParam === "firma"
|
: tabParam === "firma" && availableTabs.includes("firma")
|
||||||
? "firma"
|
? "firma"
|
||||||
: "security"
|
: defaultTab
|
||||||
) as "security" | "system" | "firma";
|
) as "roles" | "system" | "firma";
|
||||||
const setActiveTab = (tab: "security" | "system" | "firma") =>
|
const setActiveTab = (tab: "roles" | "system" | "firma") =>
|
||||||
setSearchParams({ tab }, { replace: true });
|
setSearchParams({ tab }, { replace: true });
|
||||||
|
|
||||||
const { data: sysSettingsData, isPending: sysSettingsLoading } = useQuery({
|
const { data: sysSettingsData, isPending: sysSettingsLoading } = useQuery({
|
||||||
...companySettingsOptions(),
|
...companySettingsOptions(),
|
||||||
enabled: canManage && (activeTab === "system" || activeTab === "security"),
|
enabled:
|
||||||
|
(canManageRoles || canManageSystem) &&
|
||||||
|
(activeTab === "system" || activeTab === "roles"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: systemInfo } = useQuery({
|
const { data: systemInfo } = useQuery({
|
||||||
...systemInfoOptions(),
|
...systemInfoOptions(),
|
||||||
enabled: canManage && activeTab === "system",
|
enabled: canManageSystem && activeTab === "system",
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Local state ──
|
// ── Local state ──
|
||||||
@@ -205,39 +219,39 @@ export default function Settings() {
|
|||||||
// ── Populate sysForm from query data ──
|
// ── Populate sysForm from query data ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sysSettingsData || sysFormInitialized) return;
|
if (!sysSettingsData || sysFormInitialized) return;
|
||||||
const d = sysSettingsData as Record<string, unknown>;
|
|
||||||
setSysForm({
|
setSysForm({
|
||||||
break_threshold_hours: (d.break_threshold_hours as number) ?? 6,
|
break_threshold_hours: sysSettingsData.break_threshold_hours ?? 6,
|
||||||
break_duration_short: (d.break_duration_short as number) ?? 15,
|
break_duration_short: sysSettingsData.break_duration_short ?? 15,
|
||||||
break_duration_long: (d.break_duration_long as number) ?? 30,
|
break_duration_long: sysSettingsData.break_duration_long ?? 30,
|
||||||
clock_rounding_minutes: (d.clock_rounding_minutes as number) ?? 15,
|
clock_rounding_minutes: sysSettingsData.clock_rounding_minutes ?? 15,
|
||||||
invoice_alert_email: (d.invoice_alert_email as string) || "",
|
invoice_alert_email: sysSettingsData.invoice_alert_email || "",
|
||||||
leave_notify_email: (d.leave_notify_email as string) || "",
|
leave_notify_email: sysSettingsData.leave_notify_email || "",
|
||||||
smtp_from: (d.smtp_from as string) || "",
|
smtp_from: sysSettingsData.smtp_from || "",
|
||||||
smtp_from_name: (d.smtp_from_name as string) || "",
|
smtp_from_name: sysSettingsData.smtp_from_name || "",
|
||||||
max_login_attempts: (d.max_login_attempts as number) ?? 5,
|
max_login_attempts: sysSettingsData.max_login_attempts ?? 5,
|
||||||
lockout_minutes: (d.lockout_minutes as number) ?? 15,
|
lockout_minutes: sysSettingsData.lockout_minutes ?? 15,
|
||||||
max_requests_per_minute: (d.max_requests_per_minute as number) ?? 300,
|
max_requests_per_minute: sysSettingsData.max_requests_per_minute ?? 300,
|
||||||
default_currency: (d.default_currency as string) || "CZK",
|
default_currency: sysSettingsData.default_currency || "CZK",
|
||||||
default_vat_rate: (d.default_vat_rate as number) ?? 21,
|
default_vat_rate: sysSettingsData.default_vat_rate ?? 21,
|
||||||
available_vat_rates:
|
available_vat_rates:
|
||||||
Array.isArray(d.available_vat_rates) && d.available_vat_rates.length > 0
|
Array.isArray(sysSettingsData.available_vat_rates) &&
|
||||||
? (d.available_vat_rates as number[])
|
sysSettingsData.available_vat_rates.length > 0
|
||||||
|
? sysSettingsData.available_vat_rates
|
||||||
: [0, 10, 12, 15, 21],
|
: [0, 10, 12, 15, 21],
|
||||||
available_currencies:
|
available_currencies:
|
||||||
Array.isArray(d.available_currencies) &&
|
Array.isArray(sysSettingsData.available_currencies) &&
|
||||||
d.available_currencies.length > 0
|
sysSettingsData.available_currencies.length > 0
|
||||||
? (d.available_currencies as string[])
|
? sysSettingsData.available_currencies
|
||||||
: ["CZK", "EUR", "USD", "GBP"],
|
: ["CZK", "EUR", "USD", "GBP"],
|
||||||
quotation_prefix: (d.quotation_prefix as string) || "NA",
|
quotation_prefix: sysSettingsData.quotation_prefix || "NA",
|
||||||
order_type_code: (d.order_type_code as string) || "71",
|
order_type_code: sysSettingsData.order_type_code || "71",
|
||||||
invoice_type_code: (d.invoice_type_code as string) || "81",
|
invoice_type_code: sysSettingsData.invoice_type_code || "81",
|
||||||
offer_number_pattern:
|
offer_number_pattern:
|
||||||
(d.offer_number_pattern as string) || "{YYYY}/{PREFIX}/{NNN}",
|
sysSettingsData.offer_number_pattern || "{YYYY}/{PREFIX}/{NNN}",
|
||||||
order_number_pattern:
|
order_number_pattern:
|
||||||
(d.order_number_pattern as string) || "{YY}{CODE}{NNNN}",
|
sysSettingsData.order_number_pattern || "{YY}{CODE}{NNNN}",
|
||||||
invoice_number_pattern:
|
invoice_number_pattern:
|
||||||
(d.invoice_number_pattern as string) || "{YY}{CODE}{NNNN}",
|
sysSettingsData.invoice_number_pattern || "{YY}{CODE}{NNNN}",
|
||||||
});
|
});
|
||||||
setSysFormInitialized(true);
|
setSysFormInitialized(true);
|
||||||
}, [sysSettingsData, sysFormInitialized]);
|
}, [sysSettingsData, sysFormInitialized]);
|
||||||
@@ -245,7 +259,7 @@ export default function Settings() {
|
|||||||
useModalLock(showModal);
|
useModalLock(showModal);
|
||||||
|
|
||||||
// ── Early return after all hooks ──
|
// ── Early return after all hooks ──
|
||||||
if (!canManage) {
|
if (!canAccessSettings) {
|
||||||
return <Navigate to="/" replace />;
|
return <Navigate to="/" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +275,11 @@ export default function Settings() {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
alert.success(result.message || "Systémová nastavení byla uložena");
|
alert.success(result.message || "Systémová nastavení byla uložena");
|
||||||
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
|
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 {
|
} else {
|
||||||
alert.error(result.error || "Nepodařilo se uložit nastavení");
|
alert.error(result.error || "Nepodařilo se uložit nastavení");
|
||||||
}
|
}
|
||||||
@@ -282,7 +301,9 @@ export default function Settings() {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
alert.success(result.message || "2FA nastavení uloženo");
|
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 {
|
} else {
|
||||||
alert.error(result.error || "Nepodařilo se uložit nastavení");
|
alert.error(result.error || "Nepodařilo se uložit nastavení");
|
||||||
}
|
}
|
||||||
@@ -401,7 +422,9 @@ export default function Settings() {
|
|||||||
result.message ||
|
result.message ||
|
||||||
(editingRole ? "Role byla aktualizována" : "Role byla vytvořena"),
|
(editingRole ? "Role byla aktualizována" : "Role byla vytvořena"),
|
||||||
);
|
);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["settings", "roles"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["roles"] });
|
queryClient.invalidateQueries({ queryKey: ["roles"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error || "Nepodařilo se uložit roli");
|
alert.error(result.error || "Nepodařilo se uložit roli");
|
||||||
}
|
}
|
||||||
@@ -429,7 +452,9 @@ export default function Settings() {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
setDeleteConfirm({ show: false, role: null });
|
setDeleteConfirm({ show: false, role: null });
|
||||||
alert.success(result.message || "Role byla smazána");
|
alert.success(result.message || "Role byla smazána");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["settings", "roles"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["roles"] });
|
queryClient.invalidateQueries({ queryKey: ["roles"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error || "Nepodařilo se smazat roli");
|
alert.error(result.error || "Nepodařilo se smazat roli");
|
||||||
}
|
}
|
||||||
@@ -440,7 +465,7 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (rolesLoading) {
|
if (canManageRoles && rolesLoading) {
|
||||||
return (
|
return (
|
||||||
<Skeleton
|
<Skeleton
|
||||||
name="settings"
|
name="settings"
|
||||||
@@ -503,36 +528,42 @@ export default function Settings() {
|
|||||||
? "Systémová nastavení"
|
? "Systémová nastavení"
|
||||||
: activeTab === "firma"
|
: activeTab === "firma"
|
||||||
? "Informace o firmě"
|
? "Informace o firmě"
|
||||||
: "Zabezpečení a správa rolí"}
|
: "Role"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{canManage && (
|
{availableTabs.length > 0 && (
|
||||||
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "1.5rem" }}>
|
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "1.5rem" }}>
|
||||||
|
{availableTabs.includes("roles") && (
|
||||||
<button
|
<button
|
||||||
className={`admin-btn admin-btn-sm ${activeTab === "security" ? "admin-btn-primary" : "admin-btn-secondary"}`}
|
className={`admin-btn admin-btn-sm ${activeTab === "roles" ? "admin-btn-primary" : "admin-btn-secondary"}`}
|
||||||
onClick={() => setActiveTab("security")}
|
onClick={() => setActiveTab("roles")}
|
||||||
>
|
>
|
||||||
Zabezpečení a role
|
Role
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{availableTabs.includes("system") && (
|
||||||
<button
|
<button
|
||||||
className={`admin-btn admin-btn-sm ${activeTab === "system" ? "admin-btn-primary" : "admin-btn-secondary"}`}
|
className={`admin-btn admin-btn-sm ${activeTab === "system" ? "admin-btn-primary" : "admin-btn-secondary"}`}
|
||||||
onClick={() => setActiveTab("system")}
|
onClick={() => setActiveTab("system")}
|
||||||
>
|
>
|
||||||
Systémová nastavení
|
Systémová nastavení
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{availableTabs.includes("firma") && (
|
||||||
<button
|
<button
|
||||||
className={`admin-btn admin-btn-sm ${activeTab === "firma" ? "admin-btn-primary" : "admin-btn-secondary"}`}
|
className={`admin-btn admin-btn-sm ${activeTab === "firma" ? "admin-btn-primary" : "admin-btn-secondary"}`}
|
||||||
onClick={() => setActiveTab("firma")}
|
onClick={() => setActiveTab("firma")}
|
||||||
>
|
>
|
||||||
Firma
|
Firma
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Security Settings */}
|
{/* Security Settings */}
|
||||||
{activeTab === "security" && canManage && (
|
{activeTab === "system" && canManageSystem && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-card"
|
className="admin-card"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
@@ -616,7 +647,7 @@ export default function Settings() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Login Security */}
|
{/* Login Security */}
|
||||||
{activeTab === "security" && canManage && (
|
{activeTab === "system" && canManageSystem && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-card"
|
className="admin-card"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
@@ -673,7 +704,7 @@ export default function Settings() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Roles Table */}
|
{/* Roles Table */}
|
||||||
{activeTab === "security" && canManage && (
|
{activeTab === "roles" && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-card"
|
className="admin-card"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
@@ -752,11 +783,7 @@ export default function Settings() {
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span className="admin-badge admin-badge-secondary">
|
<span className="admin-badge admin-badge-secondary">
|
||||||
{
|
{role.user_count ?? 0}
|
||||||
users.filter(
|
|
||||||
(u: { role_id: number }) => u.role_id === role.id,
|
|
||||||
).length
|
|
||||||
}
|
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -786,27 +813,16 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
className="admin-btn-icon danger"
|
className="admin-btn-icon danger"
|
||||||
title={
|
title={
|
||||||
users.filter(
|
(role.user_count ?? 0) > 0
|
||||||
(u: { role_id: number }) =>
|
|
||||||
u.role_id === role.id,
|
|
||||||
).length > 0
|
|
||||||
? "Nelze smazat roli s přiřazenými uživateli"
|
? "Nelze smazat roli s přiřazenými uživateli"
|
||||||
: "Smazat"
|
: "Smazat"
|
||||||
}
|
}
|
||||||
aria-label={
|
aria-label={
|
||||||
users.filter(
|
(role.user_count ?? 0) > 0
|
||||||
(u: { role_id: number }) =>
|
|
||||||
u.role_id === role.id,
|
|
||||||
).length > 0
|
|
||||||
? "Nelze smazat roli s přiřazenými uživateli"
|
? "Nelze smazat roli s přiřazenými uživateli"
|
||||||
: "Smazat"
|
: "Smazat"
|
||||||
}
|
}
|
||||||
disabled={
|
disabled={(role.user_count ?? 0) > 0}
|
||||||
users.filter(
|
|
||||||
(u: { role_id: number }) =>
|
|
||||||
u.role_id === role.id,
|
|
||||||
).length > 0
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
width="16"
|
width="16"
|
||||||
@@ -833,7 +849,7 @@ export default function Settings() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* System Settings Tab */}
|
{/* System Settings Tab */}
|
||||||
{activeTab === "system" && canManage && (
|
{activeTab === "system" && (
|
||||||
<>
|
<>
|
||||||
{sysSettingsLoading && !sysFormInitialized ? (
|
{sysSettingsLoading && !sysFormInitialized ? (
|
||||||
<Skeleton
|
<Skeleton
|
||||||
@@ -1039,260 +1055,7 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Section 5: Číslování dokladů */}
|
{/* Section 5: Informace o aplikaci */}
|
||||||
<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 */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-card"
|
className="admin-card"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
@@ -1602,7 +1365,7 @@ export default function Settings() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Firma tab */}
|
{/* Firma tab */}
|
||||||
{activeTab === "firma" && canManage && <CompanySettings embedded />}
|
{activeTab === "firma" && <CompanySettings embedded />}
|
||||||
|
|
||||||
{/* Create/Edit Modal */}
|
{/* Create/Edit Modal */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
|
|||||||
@@ -13,32 +13,16 @@ import Forbidden from "../components/Forbidden";
|
|||||||
import { formatDate } from "../utils/attendanceHelpers";
|
import { formatDate } from "../utils/attendanceHelpers";
|
||||||
import { formatKm } from "../utils/formatters";
|
import { formatKm } from "../utils/formatters";
|
||||||
import apiFetch from "../utils/api";
|
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 { Skeleton } from "boneyard-js/react";
|
||||||
import TripsFixture from "../fixtures/TripsFixture";
|
import TripsFixture from "../fixtures/TripsFixture";
|
||||||
const API_BASE = "/api/admin";
|
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 {
|
interface TripForm {
|
||||||
vehicle_id: string;
|
vehicle_id: string;
|
||||||
trip_date: string;
|
trip_date: string;
|
||||||
@@ -60,15 +44,12 @@ export default function Trips() {
|
|||||||
);
|
);
|
||||||
const { data: vehiclesData } = useQuery(tripVehiclesOptions());
|
const { data: vehiclesData } = useQuery(tripVehiclesOptions());
|
||||||
|
|
||||||
const trips = (tripsData ?? []) as Record<string, unknown>[] as Trip[];
|
const trips = tripsData ?? [];
|
||||||
const vehicles = (vehiclesData ?? []) as Record<
|
const vehicles = vehiclesData ?? [];
|
||||||
string,
|
|
||||||
unknown
|
|
||||||
>[] as Vehicle[];
|
|
||||||
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [showModal, setShowModal] = 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<{
|
const [deleteConfirm, setDeleteConfirm] = useState<{
|
||||||
show: boolean;
|
show: boolean;
|
||||||
tripId: number | null;
|
tripId: number | null;
|
||||||
@@ -130,7 +111,7 @@ export default function Trips() {
|
|||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEditModal = (trip: Trip) => {
|
const openEditModal = (trip: BackendTrip) => {
|
||||||
setEditingTrip(trip);
|
setEditingTrip(trip);
|
||||||
setForm({
|
setForm({
|
||||||
vehicle_id: String(trip.vehicle_id),
|
vehicle_id: String(trip.vehicle_id),
|
||||||
|
|||||||
@@ -10,8 +10,14 @@ import {
|
|||||||
tripListOptions,
|
tripListOptions,
|
||||||
tripVehiclesOptions,
|
tripVehiclesOptions,
|
||||||
tripUsersOptions,
|
tripUsersOptions,
|
||||||
|
type BackendTrip,
|
||||||
|
type TripVehicle,
|
||||||
|
type TripUser,
|
||||||
} from "../lib/queries/trips";
|
} from "../lib/queries/trips";
|
||||||
import { companySettingsOptions } from "../lib/queries/settings";
|
import {
|
||||||
|
companySettingsOptions,
|
||||||
|
type CompanySettingsData,
|
||||||
|
} from "../lib/queries/settings";
|
||||||
|
|
||||||
import AdminDatePicker from "../components/AdminDatePicker";
|
import AdminDatePicker from "../components/AdminDatePicker";
|
||||||
import FormField from "../components/FormField";
|
import FormField from "../components/FormField";
|
||||||
@@ -23,17 +29,6 @@ import { Skeleton } from "boneyard-js/react";
|
|||||||
import TripsAdminFixture from "../fixtures/TripsAdminFixture";
|
import TripsAdminFixture from "../fixtures/TripsAdminFixture";
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
interface Vehicle {
|
|
||||||
id: number | string;
|
|
||||||
spz: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UserShort {
|
|
||||||
id: number | string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Trip {
|
interface Trip {
|
||||||
id: number;
|
id: number;
|
||||||
vehicle_id: number | string;
|
vehicle_id: number | string;
|
||||||
@@ -49,22 +44,6 @@ interface Trip {
|
|||||||
driver_name: string;
|
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 {
|
interface EditForm {
|
||||||
vehicle_id: string;
|
vehicle_id: string;
|
||||||
trip_date: string;
|
trip_date: string;
|
||||||
@@ -127,15 +106,13 @@ export default function TripsAdmin() {
|
|||||||
}>({ show: false, trip: null });
|
}>({ show: false, trip: null });
|
||||||
|
|
||||||
const { data: vehiclesData = [] } = useQuery(tripVehiclesOptions());
|
const { data: vehiclesData = [] } = useQuery(tripVehiclesOptions());
|
||||||
const vehicles = vehiclesData as Vehicle[];
|
const vehicles = vehiclesData;
|
||||||
|
|
||||||
const { data: tripUsersData = [] } = useQuery(tripUsersOptions());
|
const { data: tripUsersData = [] } = useQuery(tripUsersOptions());
|
||||||
const tripUsers = tripUsersData as UserShort[];
|
const tripUsers = tripUsersData;
|
||||||
|
|
||||||
const { data: companySettings } = useQuery(companySettingsOptions());
|
const { data: companySettings } = useQuery(companySettingsOptions());
|
||||||
const companyName =
|
const companyName = companySettings?.company_name ?? "";
|
||||||
((companySettings as Record<string, unknown> | undefined)
|
|
||||||
?.company_name as string) ?? "";
|
|
||||||
|
|
||||||
const { data: tripsData, isPending } = useQuery(
|
const { data: tripsData, isPending } = useQuery(
|
||||||
tripListOptions({
|
tripListOptions({
|
||||||
@@ -146,11 +123,11 @@ export default function TripsAdmin() {
|
|||||||
perPage: 100,
|
perPage: 100,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const trips = ((tripsData ?? []) as BackendTrip[]).map(mapTrip);
|
const trips = (tripsData ?? []).map(mapTrip);
|
||||||
|
|
||||||
useModalLock(showEditModal);
|
useModalLock(showEditModal);
|
||||||
|
|
||||||
if (!hasPermission("trips.admin")) return <Forbidden />;
|
if (!hasPermission("trips.manage")) return <Forbidden />;
|
||||||
|
|
||||||
const openEditModal = (trip: Trip) => {
|
const openEditModal = (trip: Trip) => {
|
||||||
setEditingTrip(trip);
|
setEditingTrip(trip);
|
||||||
|
|||||||
@@ -8,16 +8,15 @@ import Forbidden from "../components/Forbidden";
|
|||||||
import { formatDate } from "../utils/attendanceHelpers";
|
import { formatDate } from "../utils/attendanceHelpers";
|
||||||
import { formatKm } from "../utils/formatters";
|
import { formatKm } from "../utils/formatters";
|
||||||
import FormField from "../components/FormField";
|
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 { Skeleton } from "boneyard-js/react";
|
||||||
import TripsHistoryFixture from "../fixtures/TripsHistoryFixture";
|
import TripsHistoryFixture from "../fixtures/TripsHistoryFixture";
|
||||||
|
|
||||||
interface Vehicle {
|
|
||||||
id: number | string;
|
|
||||||
spz: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Trip {
|
interface Trip {
|
||||||
id: number;
|
id: number;
|
||||||
trip_date: string;
|
trip_date: string;
|
||||||
@@ -42,7 +41,7 @@ export default function TripsHistory() {
|
|||||||
const [vehicleId, setVehicleId] = useState("");
|
const [vehicleId, setVehicleId] = useState("");
|
||||||
|
|
||||||
const { data: vehiclesData = [] } = useQuery(tripVehiclesOptions());
|
const { data: vehiclesData = [] } = useQuery(tripVehiclesOptions());
|
||||||
const vehicles = vehiclesData as Vehicle[];
|
const vehicles = vehiclesData;
|
||||||
|
|
||||||
const { data: tripsData, isPending } = useQuery(
|
const { data: tripsData, isPending } = useQuery(
|
||||||
tripHistoryOptions({
|
tripHistoryOptions({
|
||||||
@@ -51,14 +50,21 @@ export default function TripsHistory() {
|
|||||||
userId: user?.id,
|
userId: user?.id,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const trips = ((tripsData ?? []) as Record<string, unknown>[]).map((t) => ({
|
const trips = (tripsData ?? []).map((t) => ({
|
||||||
...t,
|
id: t.id,
|
||||||
spz: (t.vehicles as Record<string, string>)?.spz || "",
|
trip_date: t.trip_date,
|
||||||
|
spz: t.vehicles?.spz ?? "",
|
||||||
driver_name: t.users
|
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),
|
route_from: t.route_from,
|
||||||
})) as Trip[];
|
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(
|
const totals = trips.reduce(
|
||||||
(acc, t) => ({
|
(acc, t) => ({
|
||||||
|
|||||||
@@ -8,29 +8,17 @@ import ConfirmModal from "../components/ConfirmModal";
|
|||||||
import FormField from "../components/FormField";
|
import FormField from "../components/FormField";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import useModalLock from "../hooks/useModalLock";
|
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 UsersFixture from "../fixtures/UsersFixture";
|
||||||
|
|
||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
const API_BASE = "/api/admin";
|
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 {
|
interface FormData {
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -46,10 +34,8 @@ export default function Users() {
|
|||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: usersData, isPending } = useQuery(userListOptions());
|
const { data: usersData, isPending } = useQuery(userListOptions());
|
||||||
const users = ((usersData as Record<string, unknown>)?.items ??
|
const users = usersData ?? [];
|
||||||
usersData ??
|
const { data: roles = [] } = useQuery(roleListOptions());
|
||||||
[]) as User[];
|
|
||||||
const { data: roles = [] } = useQuery(roleListOptions()) as { data: Role[] };
|
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||||
const [deleteModal, setDeleteModal] = useState<{
|
const [deleteModal, setDeleteModal] = useState<{
|
||||||
@@ -144,6 +130,11 @@ export default function Users() {
|
|||||||
wasEditing ? "Uživatel byl upraven" : "Uživatel byl vytvořen",
|
wasEditing ? "Uživatel byl upraven" : "Uživatel byl vytvořen",
|
||||||
);
|
);
|
||||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
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 {
|
} else {
|
||||||
alert.error(data.error || "Nepodařilo se uložit uživatele");
|
alert.error(data.error || "Nepodařilo se uložit uživatele");
|
||||||
}
|
}
|
||||||
@@ -177,6 +168,11 @@ export default function Users() {
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
closeDeleteModal();
|
closeDeleteModal();
|
||||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
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");
|
alert.success("Uživatel byl smazán");
|
||||||
} else {
|
} else {
|
||||||
alert.error(data.error || "Nepodařilo se smazat uživatele");
|
alert.error(data.error || "Nepodařilo se smazat uživatele");
|
||||||
@@ -202,6 +198,11 @@ export default function Users() {
|
|||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
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(
|
alert.success(
|
||||||
user.is_active
|
user.is_active
|
||||||
? "Uživatel byl deaktivován"
|
? "Uživatel byl deaktivován"
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export default function Vehicles() {
|
|||||||
|
|
||||||
useModalLock(showModal);
|
useModalLock(showModal);
|
||||||
|
|
||||||
if (!hasPermission("trips.vehicles")) return <Forbidden />;
|
if (!hasPermission("vehicles.manage")) return <Forbidden />;
|
||||||
|
|
||||||
const openCreateModal = () => {
|
const openCreateModal = () => {
|
||||||
setEditingVehicle(null);
|
setEditingVehicle(null);
|
||||||
@@ -117,6 +117,7 @@ export default function Vehicles() {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
queryClient.invalidateQueries({ queryKey: ["vehicles"] });
|
queryClient.invalidateQueries({ queryKey: ["vehicles"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["trips"] });
|
||||||
alert.success(result.message);
|
alert.success(result.message);
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error);
|
alert.error(result.error);
|
||||||
@@ -142,6 +143,7 @@ export default function Vehicles() {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
setDeleteConfirm({ show: false, vehicle: null });
|
setDeleteConfirm({ show: false, vehicle: null });
|
||||||
queryClient.invalidateQueries({ queryKey: ["vehicles"] });
|
queryClient.invalidateQueries({ queryKey: ["vehicles"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["trips"] });
|
||||||
alert.success(result.message);
|
alert.success(result.message);
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error);
|
alert.error(result.error);
|
||||||
@@ -163,6 +165,7 @@ export default function Vehicles() {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
queryClient.invalidateQueries({ queryKey: ["vehicles"] });
|
queryClient.invalidateQueries({ queryKey: ["vehicles"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["trips"] });
|
||||||
alert.success(
|
alert.success(
|
||||||
vehicle.is_active
|
vehicle.is_active
|
||||||
? "Vozidlo bylo deaktivováno"
|
? "Vozidlo bylo deaktivováno"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export default async function attendanceRoutes(
|
|||||||
|
|
||||||
// --- action=balances: leave balance overview for all users ---
|
// --- action=balances: leave balance overview for all users ---
|
||||||
if (action === "balances") {
|
if (action === "balances") {
|
||||||
if (!authData.permissions.includes("attendance.admin")) {
|
if (!authData.permissions.includes("attendance.balances")) {
|
||||||
return error(reply, "Nedostatečná oprávnění", 403);
|
return error(reply, "Nedostatečná oprávnění", 403);
|
||||||
}
|
}
|
||||||
const yr = Number(query.year) || new Date().getFullYear();
|
const yr = Number(query.year) || new Date().getFullYear();
|
||||||
@@ -110,7 +110,7 @@ export default async function attendanceRoutes(
|
|||||||
|
|
||||||
// --- action=workfund: monthly work fund overview ---
|
// --- action=workfund: monthly work fund overview ---
|
||||||
if (action === "workfund") {
|
if (action === "workfund") {
|
||||||
if (!authData.permissions.includes("attendance.admin")) {
|
if (!authData.permissions.includes("attendance.manage")) {
|
||||||
return error(reply, "Nedostatečná oprávnění", 403);
|
return error(reply, "Nedostatečná oprávnění", 403);
|
||||||
}
|
}
|
||||||
const yr = Number(query.year) || new Date().getFullYear();
|
const yr = Number(query.year) || new Date().getFullYear();
|
||||||
@@ -120,7 +120,7 @@ export default async function attendanceRoutes(
|
|||||||
|
|
||||||
// --- action=project_report: monthly project hours ---
|
// --- action=project_report: monthly project hours ---
|
||||||
if (action === "project_report") {
|
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);
|
return error(reply, "Nedostatečná oprávnění", 403);
|
||||||
}
|
}
|
||||||
const yr = Number(query.year) || new Date().getFullYear();
|
const yr = Number(query.year) || new Date().getFullYear();
|
||||||
@@ -130,7 +130,7 @@ export default async function attendanceRoutes(
|
|||||||
|
|
||||||
// --- action=print: attendance print data for admin ---
|
// --- action=print: attendance print data for admin ---
|
||||||
if (action === "print") {
|
if (action === "print") {
|
||||||
if (!authData.permissions.includes("attendance.admin")) {
|
if (!authData.permissions.includes("attendance.manage")) {
|
||||||
return error(reply, "Nedostatečná oprávnění", 403);
|
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 ---
|
// --- action=attendance_users: users with attendance.record permission ---
|
||||||
if (action === "attendance_users") {
|
if (action === "attendance_users") {
|
||||||
if (
|
if (
|
||||||
!authData.permissions.includes("attendance.admin") &&
|
!authData.permissions.includes("attendance.manage") &&
|
||||||
!authData.permissions.includes("attendance.view") &&
|
|
||||||
!authData.permissions.includes("attendance.record")
|
!authData.permissions.includes("attendance.record")
|
||||||
) {
|
) {
|
||||||
return error(reply, "Nedostatečná oprávnění", 403);
|
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 ---
|
// --- action=project_logs: get project logs for a specific attendance record ---
|
||||||
if (action === "project_logs") {
|
if (action === "project_logs") {
|
||||||
if (
|
if (!authData.permissions.includes("attendance.record")) {
|
||||||
!authData.permissions.includes("attendance.view") &&
|
|
||||||
!authData.permissions.includes("attendance.record")
|
|
||||||
) {
|
|
||||||
return error(reply, "Nedostatečná oprávnění", 403);
|
return error(reply, "Nedostatečná oprávnění", 403);
|
||||||
}
|
}
|
||||||
const attendanceId = Number(query.attendance_id);
|
const attendanceId = Number(query.attendance_id);
|
||||||
@@ -200,7 +196,7 @@ export default async function attendanceRoutes(
|
|||||||
if (!id) return error(reply, "Missing id", 400);
|
if (!id) return error(reply, "Missing id", 400);
|
||||||
const record = await attendanceService.getLocationRecord(id);
|
const record = await attendanceService.getLocationRecord(id);
|
||||||
if (!record) return error(reply, "Záznam nenalezen", 404);
|
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) {
|
if (record.user_id !== authData.userId && !isAdmin) {
|
||||||
return error(reply, "Nedostatečná oprávnění", 403);
|
return error(reply, "Nedostatečná oprávnění", 403);
|
||||||
}
|
}
|
||||||
@@ -209,7 +205,7 @@ export default async function attendanceRoutes(
|
|||||||
|
|
||||||
// --- Default: paginated records list ---
|
// --- Default: paginated records list ---
|
||||||
const { page, limit, skip, order } = parsePagination(query);
|
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 userId = query.user_id ? Number(query.user_id) : undefined;
|
||||||
|
|
||||||
const result = await attendanceService.listAttendance({
|
const result = await attendanceService.listAttendance({
|
||||||
@@ -285,7 +281,7 @@ export default async function attendanceRoutes(
|
|||||||
|
|
||||||
// --- action=bulk_attendance: bulk fill month ---
|
// --- action=bulk_attendance: bulk fill month ---
|
||||||
if (postQuery.action === "bulk_attendance") {
|
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);
|
return error(reply, "Nedostatečná oprávnění", 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,7 +324,7 @@ export default async function attendanceRoutes(
|
|||||||
if (
|
if (
|
||||||
leaveBody.user_id != null &&
|
leaveBody.user_id != null &&
|
||||||
leaveBody.user_id !== authData.userId &&
|
leaveBody.user_id !== authData.userId &&
|
||||||
!authData.permissions.includes("attendance.admin")
|
!authData.permissions.includes("attendance.manage")
|
||||||
) {
|
) {
|
||||||
return error(reply, "Nedostatečná oprávnění", 403);
|
return error(reply, "Nedostatečná oprávnění", 403);
|
||||||
}
|
}
|
||||||
@@ -384,7 +380,7 @@ export default async function attendanceRoutes(
|
|||||||
if (
|
if (
|
||||||
body.user_id != null &&
|
body.user_id != null &&
|
||||||
body.user_id !== authData.userId &&
|
body.user_id !== authData.userId &&
|
||||||
!authData.permissions.includes("attendance.admin")
|
!authData.permissions.includes("attendance.manage")
|
||||||
) {
|
) {
|
||||||
return error(reply, "Nedostatečná oprávnění", 403);
|
return error(reply, "Nedostatečná oprávnění", 403);
|
||||||
}
|
}
|
||||||
@@ -429,7 +425,7 @@ export default async function attendanceRoutes(
|
|||||||
// PUT /api/admin/attendance/:id
|
// PUT /api/admin/attendance/:id
|
||||||
fastify.put<{ Params: { id: string } }>(
|
fastify.put<{ Params: { id: string } }>(
|
||||||
"/:id",
|
"/:id",
|
||||||
{ preHandler: requirePermission("attendance.edit") },
|
{ preHandler: requirePermission("attendance.manage") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseId(request.params.id, reply);
|
const id = parseId(request.params.id, reply);
|
||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
@@ -438,7 +434,7 @@ export default async function attendanceRoutes(
|
|||||||
const body = parsed.data;
|
const body = parsed.data;
|
||||||
|
|
||||||
const authData = request.authData!;
|
const authData = request.authData!;
|
||||||
const isAdmin = authData.permissions.includes("attendance.admin");
|
const isAdmin = authData.permissions.includes("attendance.manage");
|
||||||
|
|
||||||
const result = await attendanceService.updateAttendance(
|
const result = await attendanceService.updateAttendance(
|
||||||
id,
|
id,
|
||||||
@@ -481,7 +477,7 @@ export default async function attendanceRoutes(
|
|||||||
// DELETE /api/admin/attendance/:id
|
// DELETE /api/admin/attendance/:id
|
||||||
fastify.delete<{ Params: { id: string } }>(
|
fastify.delete<{ Params: { id: string } }>(
|
||||||
"/:id",
|
"/:id",
|
||||||
{ preHandler: requirePermission("attendance.admin") },
|
{ preHandler: requirePermission("attendance.manage") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseId(request.params.id, reply);
|
const id = parseId(request.params.id, reply);
|
||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default async function bankAccountsRoutes(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
fastify.get(
|
fastify.get(
|
||||||
"/",
|
"/",
|
||||||
{ preHandler: requirePermission("settings.manage") },
|
{ preHandler: requirePermission("settings.banking") },
|
||||||
async (_request, reply) => {
|
async (_request, reply) => {
|
||||||
const accounts = await prisma.bank_accounts.findMany({
|
const accounts = await prisma.bank_accounts.findMany({
|
||||||
orderBy: { position: "asc" },
|
orderBy: { position: "asc" },
|
||||||
@@ -25,7 +25,7 @@ export default async function bankAccountsRoutes(
|
|||||||
|
|
||||||
fastify.post(
|
fastify.post(
|
||||||
"/",
|
"/",
|
||||||
{ preHandler: requirePermission("settings.manage") },
|
{ preHandler: requirePermission("settings.banking") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const parsed = parseBody(CreateBankAccountSchema, request.body);
|
const parsed = parseBody(CreateBankAccountSchema, request.body);
|
||||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||||
@@ -59,7 +59,7 @@ export default async function bankAccountsRoutes(
|
|||||||
|
|
||||||
fastify.put<{ Params: { id: string } }>(
|
fastify.put<{ Params: { id: string } }>(
|
||||||
"/:id",
|
"/:id",
|
||||||
{ preHandler: requirePermission("settings.manage") },
|
{ preHandler: requirePermission("settings.banking") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseId(request.params.id, reply);
|
const id = parseId(request.params.id, reply);
|
||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
@@ -126,7 +126,7 @@ export default async function bankAccountsRoutes(
|
|||||||
|
|
||||||
fastify.delete<{ Params: { id: string } }>(
|
fastify.delete<{ Params: { id: string } }>(
|
||||||
"/:id",
|
"/:id",
|
||||||
{ preHandler: requirePermission("settings.manage") },
|
{ preHandler: requirePermission("settings.banking") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseId(request.params.id, reply);
|
const id = parseId(request.params.id, reply);
|
||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import prisma from "../../config/database";
|
|||||||
import {
|
import {
|
||||||
requireAuth,
|
requireAuth,
|
||||||
requirePermission,
|
requirePermission,
|
||||||
|
requireAnyPermission,
|
||||||
optionalAuth,
|
optionalAuth,
|
||||||
} from "../../middleware/auth";
|
} from "../../middleware/auth";
|
||||||
import { logAudit } from "../../services/audit";
|
import { logAudit } from "../../services/audit";
|
||||||
@@ -99,7 +100,7 @@ export default async function companySettingsRoutes(
|
|||||||
// POST /api/admin/company-settings/logo?variant=light|dark
|
// POST /api/admin/company-settings/logo?variant=light|dark
|
||||||
fastify.post(
|
fastify.post(
|
||||||
"/logo",
|
"/logo",
|
||||||
{ preHandler: requirePermission("settings.manage") },
|
{ preHandler: requirePermission("settings.company") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const query = request.query as Record<string, string>;
|
const query = request.query as Record<string, string>;
|
||||||
const variant = query.variant === "dark" ? "dark" : "light";
|
const variant = query.variant === "dark" ? "dark" : "light";
|
||||||
@@ -333,7 +334,7 @@ export default async function companySettingsRoutes(
|
|||||||
// GET /api/admin/company-settings/system-info
|
// GET /api/admin/company-settings/system-info
|
||||||
fastify.get(
|
fastify.get(
|
||||||
"/system-info",
|
"/system-info",
|
||||||
{ preHandler: requirePermission("settings.manage") },
|
{ preHandler: requirePermission("settings.system") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
const pkg = require("../../../package.json") as { version: string };
|
const pkg = require("../../../package.json") as { version: string };
|
||||||
@@ -404,7 +405,7 @@ export default async function companySettingsRoutes(
|
|||||||
|
|
||||||
fastify.put(
|
fastify.put(
|
||||||
"/",
|
"/",
|
||||||
{ preHandler: requirePermission("settings.manage") },
|
{ preHandler: requireAnyPermission("settings.company", "settings.system") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const parsed = parseBody(UpdateCompanySettingsSchema, request.body);
|
const parsed = parseBody(UpdateCompanySettingsSchema, request.body);
|
||||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export default async function customersRoutes(
|
|||||||
|
|
||||||
fastify.post(
|
fastify.post(
|
||||||
"/",
|
"/",
|
||||||
{ preHandler: requirePermission("customers.manage") },
|
{ preHandler: requirePermission("customers.create") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const parsed = parseBody(CreateCustomerSchema, request.body);
|
const parsed = parseBody(CreateCustomerSchema, request.body);
|
||||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||||
@@ -164,7 +164,7 @@ export default async function customersRoutes(
|
|||||||
|
|
||||||
fastify.put<{ Params: { id: string } }>(
|
fastify.put<{ Params: { id: string } }>(
|
||||||
"/:id",
|
"/:id",
|
||||||
{ preHandler: requirePermission("customers.manage") },
|
{ preHandler: requirePermission("customers.edit") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseId(request.params.id, reply);
|
const id = parseId(request.params.id, reply);
|
||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
@@ -239,7 +239,7 @@ export default async function customersRoutes(
|
|||||||
|
|
||||||
fastify.delete<{ Params: { id: string } }>(
|
fastify.delete<{ Params: { id: string } }>(
|
||||||
"/:id",
|
"/:id",
|
||||||
{ preHandler: requirePermission("customers.manage") },
|
{ preHandler: requirePermission("customers.delete") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseId(request.params.id, reply);
|
const id = parseId(request.params.id, reply);
|
||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ export default async function dashboardRoutes(
|
|||||||
result.my_shift = { has_ongoing: myShift !== null };
|
result.my_shift = { has_ongoing: myShift !== null };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attendance admin — only for attendance.admin
|
// Attendance admin — only for attendance.manage
|
||||||
if (has("attendance.admin")) {
|
if (has("attendance.manage")) {
|
||||||
const [todayAttendance, onLeaveToday, usersCount] = await Promise.all([
|
const [todayAttendance, onLeaveToday, usersCount] = await Promise.all([
|
||||||
prisma.attendance.findMany({
|
prisma.attendance.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -168,6 +168,24 @@ function buildAddressLines(
|
|||||||
|
|
||||||
/* ── Translations ────────────────────────────────────────────────── */
|
/* ── 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>> = {
|
const translations: Record<string, Record<string, string>> = {
|
||||||
cs: {
|
cs: {
|
||||||
title: "Faktura",
|
title: "Faktura",
|
||||||
@@ -181,6 +199,7 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
var_symbol: "Variabilní s.:",
|
var_symbol: "Variabilní s.:",
|
||||||
const_symbol: "Konstantní s.:",
|
const_symbol: "Konstantní s.:",
|
||||||
order_no: "Objednávka č.:",
|
order_no: "Objednávka č.:",
|
||||||
|
order_date: "Objednávka ze dne:",
|
||||||
issue_date: "Datum vystavení:",
|
issue_date: "Datum vystavení:",
|
||||||
due_date: "Datum splatnosti:",
|
due_date: "Datum splatnosti:",
|
||||||
tax_date: "Datum uskutečnění plnění:",
|
tax_date: "Datum uskutečnění plnění:",
|
||||||
@@ -213,6 +232,7 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
stamp: "Razítko:",
|
stamp: "Razítko:",
|
||||||
ico: "IČ: ",
|
ico: "IČ: ",
|
||||||
dic: "DIČ: ",
|
dic: "DIČ: ",
|
||||||
|
cnb_rate: "Přepočet kurzem ČNB ke dni",
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
title: "Invoice",
|
title: "Invoice",
|
||||||
@@ -226,6 +246,7 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
var_symbol: "Variable symbol:",
|
var_symbol: "Variable symbol:",
|
||||||
const_symbol: "Constant symbol:",
|
const_symbol: "Constant symbol:",
|
||||||
order_no: "Order No.:",
|
order_no: "Order No.:",
|
||||||
|
order_date: "Order date:",
|
||||||
issue_date: "Issue date:",
|
issue_date: "Issue date:",
|
||||||
due_date: "Due date:",
|
due_date: "Due date:",
|
||||||
tax_date: "Tax point date:",
|
tax_date: "Tax point date:",
|
||||||
@@ -257,6 +278,7 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
stamp: "Stamp:",
|
stamp: "Stamp:",
|
||||||
ico: "Reg. No.: ",
|
ico: "Reg. No.: ",
|
||||||
dic: "Tax ID: ",
|
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 lineVat = applyVat ? (lineSubtotal * vatRate) / 100 : 0;
|
||||||
const lineTotal = lineSubtotal + lineVat;
|
const lineTotal = lineSubtotal + lineVat;
|
||||||
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
|
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
|
||||||
|
const subDesc = item.item_description || "";
|
||||||
|
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td class="row-num">${i + 1}</td>
|
<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="center">${formatNum(qty, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
|
||||||
<td class="right">${formatNum(unitPrice)}</td>
|
<td class="right">${formatNum(unitPrice)}</td>
|
||||||
<td class="right">${formatNum(lineSubtotal)}</td>
|
<td class="right">${formatNum(lineSubtotal)}</td>
|
||||||
@@ -693,6 +716,11 @@ export default async function invoicesPdfRoutes(
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1a1a1a;
|
color: #1a1a1a;
|
||||||
}
|
}
|
||||||
|
.item-subdesc {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #646464;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
table.items tbody td.total-cell { font-weight: 700; }
|
table.items tbody td.total-cell { font-weight: 700; }
|
||||||
|
|
||||||
/* Soucet + total - styl z nabidek */
|
/* 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.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.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.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>
|
<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">${lang === "cs" ? "Objednávka č.:" : "Order no.:"}</span> <span class="val">${orderNumber}</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">${lang === "cs" ? "Objednávka ze dne:" : "Order date:"}</span> <span class="val">${escapeHtml(orderDate)}</span></div>` : ""}
|
${orderDate ? `<div class="info-row"><span class="lbl">${escapeHtml(t.order_date)}</span> <span class="val">${escapeHtml(orderDate)}</span></div>` : ""}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -1002,7 +1030,7 @@ ${indentCSS}
|
|||||||
? `<tfoot>
|
? `<tfoot>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="4" style="font-size:0.7em; color:#666; padding-top:6px; text-align:left;">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>`
|
</tfoot>`
|
||||||
|
|||||||
@@ -186,7 +186,6 @@ function buildAddressLines(
|
|||||||
|
|
||||||
const TRANSLATIONS: Record<string, Record<string, string>> = {
|
const TRANSLATIONS: Record<string, Record<string, string>> = {
|
||||||
title: { EN: "PRICE QUOTATION", CZ: "CENOV\u00C1 NAB\u00CDDKA" },
|
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" },
|
valid_until: { EN: "Valid until", CZ: "Platnost do" },
|
||||||
customer: { EN: "Customer", CZ: "Z\u00E1kazn\u00EDk" },
|
customer: { EN: "Customer", CZ: "Z\u00E1kazn\u00EDk" },
|
||||||
supplier: { EN: "Supplier", CZ: "Dodavatel" },
|
supplier: { EN: "Supplier", CZ: "Dodavatel" },
|
||||||
@@ -199,7 +198,6 @@ const TRANSLATIONS: Record<string, Record<string, string>> = {
|
|||||||
subtotal: { EN: "Subtotal", CZ: "Mezisou\u010Det" },
|
subtotal: { EN: "Subtotal", CZ: "Mezisou\u010Det" },
|
||||||
vat: { EN: "VAT", CZ: "DPH" },
|
vat: { EN: "VAT", CZ: "DPH" },
|
||||||
total_to_pay: { EN: "Total to pay", CZ: "Celkem k \u00FAhrad\u011B" },
|
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" },
|
ico: { EN: "ID", CZ: "I\u010CO" },
|
||||||
dic: { EN: "VAT ID", CZ: "DI\u010C" },
|
dic: { EN: "VAT ID", CZ: "DI\u010C" },
|
||||||
page: { EN: "Page", CZ: "Strana" },
|
page: { EN: "Page", CZ: "Strana" },
|
||||||
@@ -262,8 +260,6 @@ export default async function offersPdfRoutes(
|
|||||||
const vatRate = Number(quotation.vat_rate) || 21;
|
const vatRate = Number(quotation.vat_rate) || 21;
|
||||||
const vatAmount = applyVat ? subtotal * (vatRate / 100) : 0;
|
const vatAmount = applyVat ? subtotal * (vatRate / 100) : 0;
|
||||||
const totalToPay = subtotal + vatAmount;
|
const totalToPay = subtotal + vatAmount;
|
||||||
const exchangeRate = Number(quotation.exchange_rate) || 0;
|
|
||||||
|
|
||||||
let hasScopeContent = false;
|
let hasScopeContent = false;
|
||||||
for (const s of quotation.scope_sections) {
|
for (const s of quotation.scope_sections) {
|
||||||
if ((s.content || "").trim() || (s.title || "").trim()) {
|
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="label">${escapeHtml(t("total_to_pay"))}</span>
|
||||||
<span class="value">${formatCurrency(totalToPay, currency)}</span>
|
<span class="value">${formatCurrency(totalToPay, currency)}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
if (exchangeRate > 0) {
|
|
||||||
totalsHtml += `<div class="exchange-rate">${escapeHtml(t("exchange_rate"))}: ${formatNum(exchangeRate, 4)}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const quotationNumber = escapeHtml(quotation.quotation_number);
|
const quotationNumber = escapeHtml(quotation.quotation_number);
|
||||||
|
|
||||||
let scopeHtml = "";
|
let scopeHtml = "";
|
||||||
@@ -578,12 +570,6 @@ ${indentCSS}
|
|||||||
border-bottom: 2.5pt solid #de3a3a;
|
border-bottom: 2.5pt solid #de3a3a;
|
||||||
padding-bottom: 1mm;
|
padding-bottom: 1mm;
|
||||||
}
|
}
|
||||||
.totals .exchange-rate {
|
|
||||||
text-align: right;
|
|
||||||
font-size: 7.5pt;
|
|
||||||
color: #969696;
|
|
||||||
margin-top: 3mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Scope sections ---- */
|
/* ---- Scope sections ---- */
|
||||||
.scope-page {
|
.scope-page {
|
||||||
@@ -632,14 +618,6 @@ ${indentCSS}
|
|||||||
border: none;
|
border: none;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
.logo-header {
|
|
||||||
text-align: right;
|
|
||||||
padding-bottom: 4mm;
|
|
||||||
}
|
|
||||||
.first-content {
|
|
||||||
margin-top: -26mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Page break helpers ---- */
|
/* ---- Page break helpers ---- */
|
||||||
table.page-layout thead { display: table-header-group; }
|
table.page-layout thead { display: table-header-group; }
|
||||||
table.items tbody tr { break-inside: avoid; }
|
table.items tbody tr { break-inside: avoid; }
|
||||||
@@ -696,30 +674,16 @@ ${indentCSS}
|
|||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.first-content {
|
|
||||||
margin-top: 0 !important;
|
|
||||||
}
|
|
||||||
.logo-header {
|
|
||||||
text-align: right;
|
|
||||||
padding-bottom: 0;
|
|
||||||
margin-bottom: -18mm;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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">
|
<div class="quotation-page">
|
||||||
<table class="page-layout">
|
<table class="page-layout">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><td>
|
<tr><td>
|
||||||
<div class="logo-header">${logoImg}</div>
|
|
||||||
</td></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>
|
|
||||||
<div class="first-content">
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div class="left">
|
<div class="left">
|
||||||
<div class="page-title">${escapeHtml(t("title"))}</div>
|
<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>` : ""}
|
${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 class="valid-until">${escapeHtml(t("valid_until"))}: ${escapeHtml(formatDate(quotation.valid_until))}</div>
|
||||||
</div>
|
</div>
|
||||||
|
${logoImg ? `<div class="right">${logoImg}</div>` : ""}
|
||||||
</div>
|
</div>
|
||||||
<hr class="separator" />
|
<hr class="separator" />
|
||||||
|
</td></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>
|
||||||
<div class="addresses">
|
<div class="addresses">
|
||||||
<div class="address-block left">
|
<div class="address-block left">
|
||||||
<div class="address-label">${escapeHtml(t("customer"))}</div>
|
<div class="address-label">${escapeHtml(t("customer"))}</div>
|
||||||
@@ -763,7 +731,6 @@ ${indentCSS}
|
|||||||
${totalsHtml}
|
${totalsHtml}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</td></tr>
|
</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ export default async function profileRoutes(
|
|||||||
const body = parsed.data;
|
const body = parsed.data;
|
||||||
const userId = request.authData!.userId;
|
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> = {};
|
const data: Record<string, unknown> = {};
|
||||||
if (body.email) {
|
if (body.email) {
|
||||||
data.email = String(body.email).trim();
|
data.email = String(body.email).trim();
|
||||||
|
|||||||
@@ -156,7 +156,6 @@ export default async function projectFilesRoutes(
|
|||||||
"/upload",
|
"/upload",
|
||||||
{
|
{
|
||||||
preHandler: requirePermission("projects.files"),
|
preHandler: requirePermission("projects.files"),
|
||||||
bodyLimit: config.nas.maxUploadSize,
|
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const parsedQuery = parseProjectFilesQuery(request.query);
|
const parsedQuery = parseProjectFilesQuery(request.query);
|
||||||
@@ -183,13 +182,25 @@ export default async function projectFilesRoutes(
|
|||||||
const rawFileName = file.filename;
|
const rawFileName = file.filename;
|
||||||
const fileName = rawFileName.replace(/[\/\\:*?"<>|]/g, "_");
|
const fileName = rawFileName.replace(/[\/\\:*?"<>|]/g, "_");
|
||||||
|
|
||||||
|
const buffer = await file.toBuffer();
|
||||||
const err = await fm.uploadFile(
|
const err = await fm.uploadFile(
|
||||||
project.project_number,
|
project.project_number,
|
||||||
subPath,
|
subPath,
|
||||||
file.file,
|
buffer,
|
||||||
fileName,
|
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({
|
await logAudit({
|
||||||
request,
|
request,
|
||||||
|
|||||||
@@ -12,20 +12,31 @@ export default async function rolesRoutes(
|
|||||||
// GET /api/admin/roles
|
// GET /api/admin/roles
|
||||||
fastify.get(
|
fastify.get(
|
||||||
"/",
|
"/",
|
||||||
{ preHandler: requirePermission("settings.manage") },
|
{ preHandler: requirePermission("settings.roles") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const roles = await prisma.roles.findMany({
|
const [roles, userCounts] = await Promise.all([
|
||||||
|
prisma.roles.findMany({
|
||||||
include: {
|
include: {
|
||||||
role_permissions: {
|
role_permissions: {
|
||||||
include: { permissions: true },
|
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) => ({
|
const data = roles.map((r) => ({
|
||||||
...r,
|
...r,
|
||||||
permissions: r.role_permissions.map((rp) => rp.permissions),
|
permissions: r.role_permissions.map((rp) => rp.permissions),
|
||||||
|
user_count: countMap.get(r.id) || 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return success(reply, data);
|
return success(reply, data);
|
||||||
@@ -35,10 +46,10 @@ export default async function rolesRoutes(
|
|||||||
// GET /api/admin/roles/permissions
|
// GET /api/admin/roles/permissions
|
||||||
fastify.get(
|
fastify.get(
|
||||||
"/permissions",
|
"/permissions",
|
||||||
{ preHandler: requirePermission("settings.manage") },
|
{ preHandler: requirePermission("settings.roles") },
|
||||||
async (_request, reply) => {
|
async (_request, reply) => {
|
||||||
const permissions = await prisma.permissions.findMany({
|
const permissions = await prisma.permissions.findMany({
|
||||||
orderBy: { module: "asc" },
|
orderBy: [{ module: "asc" }, { id: "asc" }],
|
||||||
});
|
});
|
||||||
return success(reply, permissions);
|
return success(reply, permissions);
|
||||||
},
|
},
|
||||||
@@ -47,7 +58,7 @@ export default async function rolesRoutes(
|
|||||||
// POST /api/admin/roles
|
// POST /api/admin/roles
|
||||||
fastify.post(
|
fastify.post(
|
||||||
"/",
|
"/",
|
||||||
{ preHandler: requirePermission("settings.manage") },
|
{ preHandler: requirePermission("settings.roles") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const parsed = parseBody(CreateRoleSchema, request.body);
|
const parsed = parseBody(CreateRoleSchema, request.body);
|
||||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||||
@@ -90,7 +101,7 @@ export default async function rolesRoutes(
|
|||||||
// PUT /api/admin/roles/:id
|
// PUT /api/admin/roles/:id
|
||||||
fastify.put<{ Params: { id: string } }>(
|
fastify.put<{ Params: { id: string } }>(
|
||||||
"/:id",
|
"/:id",
|
||||||
{ preHandler: requirePermission("settings.manage") },
|
{ preHandler: requirePermission("settings.roles") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseId(request.params.id, reply);
|
const id = parseId(request.params.id, reply);
|
||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
@@ -142,7 +153,7 @@ export default async function rolesRoutes(
|
|||||||
// DELETE /api/admin/roles/:id
|
// DELETE /api/admin/roles/:id
|
||||||
fastify.delete<{ Params: { id: string } }>(
|
fastify.delete<{ Params: { id: string } }>(
|
||||||
"/:id",
|
"/:id",
|
||||||
{ preHandler: requirePermission("settings.manage") },
|
{ preHandler: requirePermission("settings.roles") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseId(request.params.id, reply);
|
const id = parseId(request.params.id, reply);
|
||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default async function scopeTemplatesRoutes(
|
|||||||
// Legacy ?action= dispatcher for item templates
|
// Legacy ?action= dispatcher for item templates
|
||||||
fastify.get(
|
fastify.get(
|
||||||
"/",
|
"/",
|
||||||
{ preHandler: requirePermission("settings.manage") },
|
{ preHandler: requirePermission("settings.templates") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const query = request.query as Record<string, unknown>;
|
const query = request.query as Record<string, unknown>;
|
||||||
const action = query.action ? String(query.action) : null;
|
const action = query.action ? String(query.action) : null;
|
||||||
@@ -53,7 +53,7 @@ export default async function scopeTemplatesRoutes(
|
|||||||
// Item template CRUD via ?action=item
|
// Item template CRUD via ?action=item
|
||||||
fastify.post(
|
fastify.post(
|
||||||
"/",
|
"/",
|
||||||
{ preHandler: requirePermission("settings.manage") },
|
{ preHandler: requirePermission("settings.templates") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const query = request.query as Record<string, unknown>;
|
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
|
// Item template delete via DELETE ?action=item&id=X
|
||||||
fastify.delete(
|
fastify.delete(
|
||||||
"/",
|
"/",
|
||||||
{ preHandler: requirePermission("settings.manage") },
|
{ preHandler: requirePermission("settings.templates") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const query = request.query as Record<string, unknown>;
|
const query = request.query as Record<string, unknown>;
|
||||||
|
|
||||||
@@ -140,7 +140,7 @@ export default async function scopeTemplatesRoutes(
|
|||||||
|
|
||||||
fastify.get<{ Params: { id: string } }>(
|
fastify.get<{ Params: { id: string } }>(
|
||||||
"/:id",
|
"/:id",
|
||||||
{ preHandler: requirePermission("settings.manage") },
|
{ preHandler: requirePermission("settings.templates") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseId(request.params.id, reply);
|
const id = parseId(request.params.id, reply);
|
||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
@@ -161,7 +161,7 @@ export default async function scopeTemplatesRoutes(
|
|||||||
|
|
||||||
fastify.put<{ Params: { id: string } }>(
|
fastify.put<{ Params: { id: string } }>(
|
||||||
"/:id",
|
"/:id",
|
||||||
{ preHandler: requirePermission("settings.manage") },
|
{ preHandler: requirePermission("settings.templates") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseId(request.params.id, reply);
|
const id = parseId(request.params.id, reply);
|
||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
@@ -210,7 +210,7 @@ export default async function scopeTemplatesRoutes(
|
|||||||
|
|
||||||
fastify.delete<{ Params: { id: string } }>(
|
fastify.delete<{ Params: { id: string } }>(
|
||||||
"/:id",
|
"/:id",
|
||||||
{ preHandler: requirePermission("settings.manage") },
|
{ preHandler: requirePermission("settings.templates") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseId(request.params.id, reply);
|
const id = parseId(request.params.id, reply);
|
||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ export default async function totpRoutes(
|
|||||||
// GET - check if 2FA is required company-wide
|
// GET - check if 2FA is required company-wide
|
||||||
fastify.get(
|
fastify.get(
|
||||||
"/required",
|
"/required",
|
||||||
{ preHandler: [requireAuth, requirePermission("settings.manage")] },
|
{ preHandler: requireAuth },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const settings = await prisma.company_settings.findFirst({
|
const settings = await prisma.company_settings.findFirst({
|
||||||
select: { require_2fa: true },
|
select: { require_2fa: true },
|
||||||
@@ -207,7 +207,7 @@ export default async function totpRoutes(
|
|||||||
fastify.post(
|
fastify.post(
|
||||||
"/required",
|
"/required",
|
||||||
{
|
{
|
||||||
preHandler: [requireAuth, requirePermission("settings.manage")],
|
preHandler: [requireAuth, requirePermission("settings.system")],
|
||||||
bodyLimit: 10240,
|
bodyLimit: 10240,
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ export default async function tripsRoutes(
|
|||||||
const query = request.query as Record<string, unknown>;
|
const query = request.query as Record<string, unknown>;
|
||||||
const { page, limit, skip, order } = parsePagination(query);
|
const { page, limit, skip, order } = parsePagination(query);
|
||||||
const authData = request.authData!;
|
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> = {};
|
const where: Record<string, unknown> = {};
|
||||||
if (!isAdmin) where.user_id = authData.userId;
|
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
|
// GET /api/admin/trips/print — print data for trip report
|
||||||
fastify.get(
|
fastify.get(
|
||||||
"/print",
|
"/print",
|
||||||
{ preHandler: requirePermission("trips.admin") },
|
{ preHandler: requirePermission("trips.manage") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const query = request.query as Record<string, unknown>;
|
const query = request.query as Record<string, unknown>;
|
||||||
const filterUserId = query.user_id ? Number(query.user_id) : null;
|
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)
|
// Matches PHP: COALESCE(MAX(end_km), vehicle.initial_km, 0)
|
||||||
fastify.get<{ Params: { vehicleId: string } }>(
|
fastify.get<{ Params: { vehicleId: string } }>(
|
||||||
"/last-km/:vehicleId",
|
"/last-km/:vehicleId",
|
||||||
{ preHandler: requirePermission("trips.view") },
|
{ preHandler: requirePermission("trips.manage") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const vehicleId = parseInt(request.params.vehicleId, 10);
|
const vehicleId = parseInt(request.params.vehicleId, 10);
|
||||||
if (isNaN(vehicleId)) return error(reply, "Neplatné ID vozidla", 400);
|
if (isNaN(vehicleId)) return error(reply, "Neplatné ID vozidla", 400);
|
||||||
@@ -213,6 +218,11 @@ export default async function tripsRoutes(
|
|||||||
const body = parsed.data;
|
const body = parsed.data;
|
||||||
const authData = request.authData!;
|
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) {
|
if (body.end_km < body.start_km) {
|
||||||
return error(reply, "Konečný stav km nesmí být menší než počáteční", 400);
|
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);
|
if (!existing) return error(reply, "Jízda nenalezena", 404);
|
||||||
|
|
||||||
// Ownership check — same as DELETE handler
|
// 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) {
|
if (existing.user_id !== authData.userId && !isAdmin) {
|
||||||
return error(reply, "Nemáte oprávnění upravit tuto jízdu", 403);
|
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);
|
if (!existing) return error(reply, "Jízda nenalezena", 404);
|
||||||
|
|
||||||
// Allow users to delete their own trips, admins can delete any
|
// 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) {
|
if (existing.user_id !== authData.userId && !isAdmin) {
|
||||||
return error(reply, "Nemáte oprávnění smazat tuto jízdu", 403);
|
return error(reply, "Nemáte oprávnění smazat tuto jízdu", 403);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,12 @@ export default async function usersRoutes(
|
|||||||
"/",
|
"/",
|
||||||
{ preHandler: requirePermission("users.view") },
|
{ preHandler: requirePermission("users.view") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const params = parsePagination(request.query as Record<string, unknown>);
|
const query = request.query as Record<string, unknown>;
|
||||||
const result = await listUsers(params);
|
const params = parsePagination(query);
|
||||||
|
const permission = query.permission
|
||||||
|
? String(query.permission)
|
||||||
|
: undefined;
|
||||||
|
const result = await listUsers({ ...params, permission });
|
||||||
|
|
||||||
return paginated(
|
return paginated(
|
||||||
reply,
|
reply,
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default async function vehiclesRoutes(
|
|||||||
|
|
||||||
fastify.post(
|
fastify.post(
|
||||||
"/",
|
"/",
|
||||||
{ preHandler: requirePermission("trips.vehicles") },
|
{ preHandler: requirePermission("vehicles.manage") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const parsed = parseBody(CreateVehicleSchema, request.body);
|
const parsed = parseBody(CreateVehicleSchema, request.body);
|
||||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||||
@@ -75,7 +75,7 @@ export default async function vehiclesRoutes(
|
|||||||
|
|
||||||
fastify.put<{ Params: { id: string } }>(
|
fastify.put<{ Params: { id: string } }>(
|
||||||
"/:id",
|
"/:id",
|
||||||
{ preHandler: requirePermission("trips.vehicles") },
|
{ preHandler: requirePermission("vehicles.manage") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseId(request.params.id, reply);
|
const id = parseId(request.params.id, reply);
|
||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
@@ -124,7 +124,7 @@ export default async function vehiclesRoutes(
|
|||||||
|
|
||||||
fastify.delete<{ Params: { id: string } }>(
|
fastify.delete<{ Params: { id: string } }>(
|
||||||
"/:id",
|
"/:id",
|
||||||
{ preHandler: requirePermission("trips.vehicles") },
|
{ preHandler: requirePermission("vehicles.manage") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseId(request.params.id, reply);
|
const id = parseId(request.params.id, reply);
|
||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
const InvoiceItemSchema = z.object({
|
const InvoiceItemSchema = z.object({
|
||||||
description: z.string().nullish(),
|
description: z.string().nullish(),
|
||||||
|
item_description: z.string().nullish(),
|
||||||
quantity: z
|
quantity: z
|
||||||
.union([z.number(), z.string()])
|
.union([z.number(), z.string()])
|
||||||
.transform((v) => {
|
.transform((v) => {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export const CreateQuotationSchema = z.object({
|
|||||||
.transform((v) => Number(v))
|
.transform((v) => Number(v))
|
||||||
.refine((v) => !Number.isNaN(v), { message: "Musí být platné číslo" })
|
.refine((v) => !Number.isNaN(v), { message: "Musí být platné číslo" })
|
||||||
.nullish(),
|
.nullish(),
|
||||||
|
created_at: z.string().nullish(),
|
||||||
valid_until: z.string().nullish(),
|
valid_until: z.string().nullish(),
|
||||||
currency: z.string().optional().default("CZK"),
|
currency: z.string().optional().default("CZK"),
|
||||||
language: z.string().optional().default("cs"),
|
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())
|
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
|
||||||
.optional()
|
.optional()
|
||||||
.default(true),
|
.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
|
status: z
|
||||||
.enum(["nova", "odeslana", "prijata", "odmitnuta", "dokoncena", "active"])
|
.enum(["active", "ordered", "invalidated"])
|
||||||
.optional()
|
.optional()
|
||||||
.default("nova"),
|
.default("active"),
|
||||||
scope_title: z.string().nullish(),
|
scope_title: z.string().nullish(),
|
||||||
scope_description: z.string().nullish(),
|
scope_description: z.string().nullish(),
|
||||||
items: z.array(QuotationItemSchema).optional(),
|
items: z.array(QuotationItemSchema).optional(),
|
||||||
@@ -82,6 +77,7 @@ export const UpdateQuotationSchema = z.object({
|
|||||||
.transform((v) => Number(v))
|
.transform((v) => Number(v))
|
||||||
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
|
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
|
||||||
.optional(),
|
.optional(),
|
||||||
|
created_at: z.union([z.string(), z.null()]).optional(),
|
||||||
valid_until: z.union([z.string(), z.null()]).optional(),
|
valid_until: z.union([z.string(), z.null()]).optional(),
|
||||||
currency: z.string().optional(),
|
currency: z.string().optional(),
|
||||||
language: z.string().optional(),
|
language: z.string().optional(),
|
||||||
@@ -93,14 +89,7 @@ export const UpdateQuotationSchema = z.object({
|
|||||||
apply_vat: z
|
apply_vat: z
|
||||||
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
|
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
|
||||||
.optional(),
|
.optional(),
|
||||||
exchange_rate: z
|
status: z.enum(["active", "ordered", "invalidated"]).optional(),
|
||||||
.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(),
|
|
||||||
scope_title: z.string().nullish(),
|
scope_title: z.string().nullish(),
|
||||||
scope_description: z.string().nullish(),
|
scope_description: z.string().nullish(),
|
||||||
items: z.array(QuotationItemSchema).optional(),
|
items: z.array(QuotationItemSchema).optional(),
|
||||||
|
|||||||
@@ -1381,7 +1381,7 @@ export async function punchAction(userId: number, data: PunchData) {
|
|||||||
const shiftMs = departureTime.getTime() - ongoing.arrival_time.getTime();
|
const shiftMs = departureTime.getTime() - ongoing.arrival_time.getTime();
|
||||||
const shiftHours = shiftMs / (1000 * 60 * 60);
|
const shiftHours = shiftMs / (1000 * 60 * 60);
|
||||||
if (shiftHours > settings.break_threshold_hours) {
|
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 =
|
const breakMins =
|
||||||
shiftHours > settings.break_threshold_hours * 2
|
shiftHours > settings.break_threshold_hours * 2
|
||||||
? settings.break_duration_long
|
? settings.break_duration_long
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const ALLOWED_SORT_FIELDS = [
|
|||||||
|
|
||||||
interface InvoiceItemInput {
|
interface InvoiceItemInput {
|
||||||
description?: string;
|
description?: string;
|
||||||
|
item_description?: string;
|
||||||
quantity?: number;
|
quantity?: number;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
unit_price?: number;
|
unit_price?: number;
|
||||||
@@ -374,6 +375,7 @@ export async function createInvoice(body: Record<string, any>) {
|
|||||||
data: (body.items as InvoiceItemInput[]).map((item, i) => ({
|
data: (body.items as InvoiceItemInput[]).map((item, i) => ({
|
||||||
invoice_id: invoice.id,
|
invoice_id: invoice.id,
|
||||||
description: item.description ?? null,
|
description: item.description ?? null,
|
||||||
|
item_description: item.item_description ?? null,
|
||||||
quantity: item.quantity ?? 1,
|
quantity: item.quantity ?? 1,
|
||||||
unit: item.unit ?? null,
|
unit: item.unit ?? null,
|
||||||
unit_price: item.unit_price ?? 0,
|
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) => ({
|
data: (body.items as InvoiceItemInput[]).map((item, i) => ({
|
||||||
invoice_id: id,
|
invoice_id: id,
|
||||||
description: item.description ?? null,
|
description: item.description ?? null,
|
||||||
|
item_description: item.item_description ?? null,
|
||||||
quantity: item.quantity ?? 1,
|
quantity: item.quantity ?? 1,
|
||||||
unit: item.unit ?? null,
|
unit: item.unit ?? null,
|
||||||
unit_price: item.unit_price ?? 0,
|
unit_price: item.unit_price ?? 0,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { pipeline } from "stream/promises";
|
|
||||||
import { config } from "../config/env";
|
import { config } from "../config/env";
|
||||||
import { localDateStr, localTimeStr } from "../utils/date";
|
import { localDateStr, localTimeStr } from "../utils/date";
|
||||||
|
|
||||||
@@ -237,7 +236,7 @@ export class NasFileManager {
|
|||||||
if (isLink) {
|
if (isLink) {
|
||||||
try {
|
try {
|
||||||
const target = fs.readlinkSync(fullPath);
|
const target = fs.readlinkSync(fullPath);
|
||||||
item.link_target = target.replace(/\//g, "\\");
|
item.link_target = target;
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -288,14 +287,14 @@ export class NasFileManager {
|
|||||||
path: subPath,
|
path: subPath,
|
||||||
items,
|
items,
|
||||||
breadcrumb,
|
breadcrumb,
|
||||||
full_path: realDirPath.replace(/\//g, "\\"),
|
full_path: realDirPath,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async uploadFile(
|
public async uploadFile(
|
||||||
projectNumber: string,
|
projectNumber: string,
|
||||||
subPath: string,
|
subPath: string,
|
||||||
fileStream: NodeJS.ReadableStream,
|
fileBuffer: Buffer,
|
||||||
fileName: string,
|
fileName: string,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const dirPath = this.resolveProjectPath(projectNumber, subPath);
|
const dirPath = this.resolveProjectPath(projectNumber, subPath);
|
||||||
@@ -304,13 +303,20 @@ export class NasFileManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stat = await fs.promises.stat(dirPath);
|
await fs.promises.mkdir(dirPath, { recursive: true, mode: 0o775 });
|
||||||
if (!stat.isDirectory()) {
|
} catch {
|
||||||
return "Cílová složka neexistuje";
|
return "Cílová složka neexistuje";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let stat: fs.Stats;
|
||||||
|
try {
|
||||||
|
stat = await fs.promises.stat(dirPath);
|
||||||
} catch {
|
} catch {
|
||||||
return "Cílová složka neexistuje";
|
return "Cílová složka neexistuje";
|
||||||
}
|
}
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
return "Cílová složka neexistuje";
|
||||||
|
}
|
||||||
|
|
||||||
const originalName = path.basename(fileName);
|
const originalName = path.basename(fileName);
|
||||||
let safeName = this.sanitizeFilename(originalName);
|
let safeName = this.sanitizeFilename(originalName);
|
||||||
@@ -323,46 +329,27 @@ export class NasFileManager {
|
|||||||
return "Tento typ souboru není povolen";
|
return "Tento typ souboru není povolen";
|
||||||
}
|
}
|
||||||
|
|
||||||
const tempPath = path.join(
|
// MIME validation
|
||||||
require("os").tmpdir(),
|
const typeResult = await FileType.fromBuffer(fileBuffer);
|
||||||
`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 (typeResult) {
|
||||||
if (this.isSuspiciousMime(typeResult.mime)) {
|
if (this.isSuspiciousMime(typeResult.mime)) {
|
||||||
await fs.promises.unlink(tempPath).catch(() => {});
|
|
||||||
return "Obsah souboru neodpovídá jeho příponě";
|
return "Obsah souboru neodpovídá jeho příponě";
|
||||||
}
|
}
|
||||||
const expectedMime = ext ? MIME_MAP[ext] : null;
|
const expectedMime = ext ? MIME_MAP[ext] : null;
|
||||||
if (expectedMime && typeResult.mime !== expectedMime) {
|
if (expectedMime && typeResult.mime !== expectedMime) {
|
||||||
await fs.promises.unlink(tempPath).catch(() => {});
|
|
||||||
return "Obsah souboru neodpovídá jeho příponě";
|
return "Obsah souboru neodpovídá jeho příponě";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// If file-type fails, continue without MIME check
|
|
||||||
}
|
|
||||||
|
|
||||||
let destPath = dirPath + "/" + safeName;
|
let destPath = dirPath + "/" + safeName;
|
||||||
|
|
||||||
// Attempt atomic rename; if destination exists, append counter
|
// If destination exists, append counter
|
||||||
let renamed = false;
|
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
const maxAttempts = 1000;
|
const maxAttempts = 1000;
|
||||||
do {
|
while (attempts < maxAttempts) {
|
||||||
try {
|
try {
|
||||||
await fs.promises.rename(tempPath, destPath);
|
await fs.promises.writeFile(destPath, fileBuffer, { flag: "wx" });
|
||||||
renamed = true;
|
return null;
|
||||||
break;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as NodeJS.ErrnoException;
|
const e = err as NodeJS.ErrnoException;
|
||||||
if (e.code === "EEXIST") {
|
if (e.code === "EEXIST") {
|
||||||
@@ -371,19 +358,14 @@ export class NasFileManager {
|
|||||||
safeName = base + "_" + attempts + (ext ? "." + ext : "");
|
safeName = base + "_" + attempts + (ext ? "." + ext : "");
|
||||||
destPath = dirPath + "/" + safeName;
|
destPath = dirPath + "/" + safeName;
|
||||||
} else {
|
} 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 "Nepodařilo se uložit soubor";
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public downloadFile(
|
public downloadFile(
|
||||||
projectNumber: string,
|
projectNumber: string,
|
||||||
filePath: string,
|
filePath: string,
|
||||||
|
|||||||
@@ -102,7 +102,26 @@ export async function listOffers(params: ListOffersParams) {
|
|||||||
prisma.quotations.count({ where }),
|
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 };
|
return { data: enriched, total, page, limit };
|
||||||
}
|
}
|
||||||
@@ -158,6 +177,9 @@ export async function createOffer(body: Record<string, any>) {
|
|||||||
quotation_number: quotationNumber,
|
quotation_number: quotationNumber,
|
||||||
project_code: body.project_code ? String(body.project_code) : null,
|
project_code: body.project_code ? String(body.project_code) : null,
|
||||||
customer_id: body.customer_id ? Number(body.customer_id) : 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
|
valid_until: body.valid_until
|
||||||
? new Date(String(body.valid_until))
|
? new Date(String(body.valid_until))
|
||||||
: null,
|
: null,
|
||||||
@@ -165,7 +187,6 @@ export async function createOffer(body: Record<string, any>) {
|
|||||||
language: body.language ? String(body.language) : "cs",
|
language: body.language ? String(body.language) : "cs",
|
||||||
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
|
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
|
||||||
apply_vat: body.apply_vat !== false,
|
apply_vat: body.apply_vat !== false,
|
||||||
exchange_rate: body.exchange_rate ? Number(body.exchange_rate) : 1.0,
|
|
||||||
status: body.status ? String(body.status) : "active",
|
status: body.status ? String(body.status) : "active",
|
||||||
scope_title: body.scope_title ? String(body.scope_title) : null,
|
scope_title: body.scope_title ? String(body.scope_title) : null,
|
||||||
scope_description: body.scope_description
|
scope_description: body.scope_description
|
||||||
@@ -221,6 +242,12 @@ export async function updateOffer(id: number, body: Record<string, any>) {
|
|||||||
const data = {
|
const data = {
|
||||||
customer_id:
|
customer_id:
|
||||||
body.customer_id !== undefined ? Number(body.customer_id) : undefined,
|
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:
|
valid_until:
|
||||||
body.valid_until !== undefined
|
body.valid_until !== undefined
|
||||||
? body.valid_until
|
? 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 ||
|
||||||
body.apply_vat === "1"
|
body.apply_vat === "1"
|
||||||
: undefined,
|
: undefined,
|
||||||
exchange_rate:
|
|
||||||
body.exchange_rate !== undefined ? Number(body.exchange_rate) : undefined,
|
|
||||||
status: body.status !== undefined ? String(body.status) : undefined,
|
status: body.status !== undefined ? String(body.status) : undefined,
|
||||||
project_code:
|
project_code:
|
||||||
body.project_code !== undefined
|
body.project_code !== undefined
|
||||||
@@ -335,7 +360,6 @@ export async function duplicateOffer(id: number) {
|
|||||||
language: original.language,
|
language: original.language,
|
||||||
vat_rate: original.vat_rate,
|
vat_rate: original.vat_rate,
|
||||||
apply_vat: original.apply_vat,
|
apply_vat: original.apply_vat,
|
||||||
exchange_rate: original.exchange_rate,
|
|
||||||
status: "active",
|
status: "active",
|
||||||
scope_title: original.scope_title,
|
scope_title: original.scope_title,
|
||||||
scope_description: original.scope_description,
|
scope_description: original.scope_description,
|
||||||
|
|||||||
@@ -206,7 +206,6 @@ export async function createOrderFromQuotation(
|
|||||||
language: quotation.language || "cs",
|
language: quotation.language || "cs",
|
||||||
vat_rate: quotation.vat_rate ?? 21.0,
|
vat_rate: quotation.vat_rate ?? 21.0,
|
||||||
apply_vat: quotation.apply_vat ?? true,
|
apply_vat: quotation.apply_vat ?? true,
|
||||||
exchange_rate: quotation.exchange_rate ?? 1.0,
|
|
||||||
scope_title: quotation.scope_title,
|
scope_title: quotation.scope_title,
|
||||||
scope_description: quotation.scope_description,
|
scope_description: quotation.scope_description,
|
||||||
attachment_data: attachmentBuffer
|
attachment_data: attachmentBuffer
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export interface ListUsersParams {
|
|||||||
sort: string;
|
sort: string;
|
||||||
order: "asc" | "desc";
|
order: "asc" | "desc";
|
||||||
search: string;
|
search: string;
|
||||||
|
permission?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateUserData {
|
export interface CreateUserData {
|
||||||
@@ -56,10 +57,10 @@ export interface UpdateUserData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listUsers(params: ListUsersParams) {
|
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 sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
|
||||||
|
|
||||||
const where = search
|
let where: any = search
|
||||||
? {
|
? {
|
||||||
OR: [
|
OR: [
|
||||||
{ username: { contains: search } },
|
{ 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([
|
const [users, total] = await Promise.all([
|
||||||
prisma.users.findMany({
|
prisma.users.findMany({
|
||||||
where,
|
where,
|
||||||
|
|||||||
Reference in New Issue
Block a user