Compare commits

...

21 Commits

Author SHA1 Message Date
BOHA
5233db2002 v1.8.0: warehouse module (16 commits), docházka mzda model, leave_type=holiday removal, api.ts race fix
Highlights:
- Warehouse module: receipts, issues, reservations, inventory, reports, dashboard,
  master data (categories, suppliers, locations), FIFO service, integration tests
- Docházka: mzda PDF counting model (Odpracováno / Vč. svátků / Přesčas / Svátek /
  So/Ne / Noc) with Czech weekday names and decimal hours
- AttendanceAdmin/AttendanceHistory KPI cards unified to mzda formula with
  fund bar colored by delta, badges for Práce/Dov/Nem/Sv/Nep
- Remove leave_type=holiday entirely (auto-computed from Czech public holidays)
- Allow multiple work shifts per day (overlap detection only)
- Pre-flight refresh in api.ts eliminates spurious 401s on fresh page loads
- Prisma: company_settings gets 6 nullable columns for warehouse numbering
  (PRI/VYD/INV prefixes, default patterns); migration seeds defaults
2026-06-03 23:13:10 +02:00
BOHA
dac45baaa8 test(warehouse): add integration tests
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 14:41:15 +02:00
BOHA
f389c4b3cf feat(warehouse): add reservations, inventory, reports, and dashboard pages
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 14:36:31 +02:00
BOHA
fdc7037e60 feat(warehouse): add issue pages 2026-05-29 14:32:11 +02:00
BOHA
b02f6afe93 feat(warehouse): add receipt pages 2026-05-29 14:29:44 +02:00
BOHA
1b2ab7b4eb feat(warehouse): add items list and detail pages
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 14:27:15 +02:00
BOHA
29975ecad9 feat(warehouse): add categories, suppliers, locations pages 2026-05-29 14:23:31 +02:00
BOHA
e846f15126 feat(warehouse): add sidebar entry and routes 2026-05-29 14:21:35 +02:00
BOHA
86bebf9776 feat(warehouse): add shared warehouse components
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 14:20:42 +02:00
BOHA
7b7c6bde68 feat(warehouse): add TanStack Query options and types 2026-05-29 14:19:38 +02:00
BOHA
935fb235a6 feat(warehouse): register warehouse routes in server
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 14:18:16 +02:00
BOHA
d2d1cdbb92 feat(warehouse): add receipt, issue, reservation, inventory, and report routes
- Receipts: CRUD + confirm/cancel + multipart attachment upload/delete
- Issues: CRUD + confirm/cancel with auto-FIFO batch selection
- Reservations: list + create + cancel
- Inventory sessions: CRUD + confirm with auto system_qty calculation
- Reports: stock-status, project-consumption, movement-log, below-minimum
- Add confirm/cancel/upload audit actions and warehouse_receipt_attachment entity type

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 14:17:33 +02:00
BOHA
5f08b0e086 feat(warehouse): add master data API routes
Categories, suppliers, locations, and items CRUD endpoints with
pagination, search, computed stock fields, audit logging, and
conflict validation (409 for references, unit change with batches).
2026-05-29 14:12:53 +02:00
BOHA
637499a8ec feat(warehouse): add warehouse service with FIFO and business logic
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 14:11:22 +02:00
BOHA
f413bae348 feat(warehouse): add Zod validation schemas 2026-05-29 14:09:39 +02:00
BOHA
2b1de583fd feat(warehouse): add entity types, permissions, and number sequences 2026-05-29 14:08:35 +02:00
BOHA
5500cdb118 feat(warehouse): add Prisma schema for warehouse module
Add 13 warehouse models (sklad_categories, sklad_suppliers, sklad_locations,
sklad_items, sklad_batches, sklad_item_locations, sklad_receipts,
sklad_receipt_lines, sklad_receipt_attachments, sklad_issues,
sklad_issue_lines, sklad_reservations, sklad_inventory_sessions,
sklad_inventory_lines) and 4 enums (sklad_receipt_status, sklad_issue_status,
sklad_reservation_status, sklad_inventory_status).

Add reverse relations to projects and users models with named relations
to avoid conflicts with existing relations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 14:08:00 +02:00
BOHA
2254a13ad0 v1.7.0: fix offer created_at override, invoice PDF translations, remove has_order filter
- Add created_at to offer create/update schemas so user-selected date is stored instead of always defaulting to now()
- Translate payment method in invoice PDF based on language (Příkazem → Bank transfer, etc.)
- Add order_date and cnb_rate translation keys, replace hardcoded inline strings
- Remove duplicate has_order filter from offers backend route

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:58:51 +02:00
166 changed files with 24898 additions and 27451 deletions

View File

@@ -1,43 +0,0 @@
---
name: compare-php
description: Compare a feature between PHP boha-app and TS boha-app-ts to verify migration parity
---
# Compare PHP vs TS Implementation
Compare a specific feature, component, or endpoint between the original PHP project (D:\cortex\boha-app) and the TypeScript migration (D:\cortex\boha-app-ts).
## Usage
The user will specify what to compare. Examples:
- `/compare-php attendance print`
- `/compare-php invoice PDF generation`
- `/compare-php offer numbering logic`
## Process
1. Search the PHP codebase (D:\cortex\boha-app) for the relevant implementation
2. Search the TS codebase (D:\cortex\boha-app-ts) for the same feature
3. Compare:
- API endpoints and request/response shape
- Business logic and calculations
- Database queries
- Frontend behavior
4. Report differences found — what's missing, what's different, what's extra
## Key directories
**PHP project:**
- API handlers: `D:\cortex\boha-app\api\admin\handlers\`
- API routes: `D:\cortex\boha-app\api\admin\`
- Frontend pages: `D:\cortex\boha-app\src\admin\pages\`
- Frontend components: `D:\cortex\boha-app\src\admin\components\`
- Frontend hooks: `D:\cortex\boha-app\src\admin\hooks\`
- Includes/utils: `D:\cortex\boha-app\api\includes\`
**TS project:**
- API routes: `D:\cortex\boha-app-ts\src\routes\admin\`
- Services: `D:\cortex\boha-app-ts\src\services\`
- Frontend pages: `D:\cortex\boha-app-ts\src\admin\pages\`
- Frontend components: `D:\cortex\boha-app-ts\src\admin\components\`
- Frontend hooks: `D:\cortex\boha-app-ts\src\admin\hooks\`

View File

@@ -87,6 +87,8 @@ npx prisma migrate resolve --applied <migration> # Mark migration as applied
**Do not start the dev server.** The user manages it separately. **Do not start the dev server.** The user manages it separately.
**Before running `prisma migrate dev` or `prisma db push`, ask the user to stop their dev server and wait for confirmation.** Migrations can conflict with an active database connection from the running server.
--- ---
## Environment Variables ## Environment Variables

572
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "1.6.7", "version": "1.8.0",
"description": "", "description": "",
"main": "dist/server.js", "main": "dist/server.js",
"scripts": { "scripts": {
@@ -18,7 +18,6 @@
"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" "seed": "tsx prisma/seed.ts"
}, },
"prisma": { "prisma": {
@@ -42,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",

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,277 @@
-- CreateTable
CREATE TABLE `sklad_categories` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(100) NOT NULL,
`description` TEXT NULL,
`sort_order` INTEGER NOT NULL DEFAULT 0,
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_suppliers` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(255) NOT NULL,
`ico` VARCHAR(20) NULL,
`dic` VARCHAR(20) NULL,
`contact_person` VARCHAR(255) NULL,
`email` VARCHAR(255) NULL,
`phone` VARCHAR(50) NULL,
`address` TEXT NULL,
`notes` TEXT NULL,
`is_active` BOOLEAN NOT NULL DEFAULT true,
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_locations` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`code` VARCHAR(20) NOT NULL,
`name` VARCHAR(100) NOT NULL,
`description` TEXT NULL,
`is_active` BOOLEAN NOT NULL DEFAULT true,
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
UNIQUE INDEX `sklad_locations_code_key`(`code`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_items` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`item_number` VARCHAR(50) NULL,
`name` VARCHAR(255) NOT NULL,
`description` TEXT NULL,
`category_id` INTEGER NULL,
`unit` VARCHAR(20) NOT NULL,
`min_quantity` DECIMAL(12, 3) NULL,
`is_active` BOOLEAN NOT NULL DEFAULT true,
`notes` TEXT NULL,
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
UNIQUE INDEX `sklad_items_item_number_key`(`item_number`),
INDEX `sklad_items_category_id`(`category_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_batches` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`item_id` INTEGER NOT NULL,
`receipt_line_id` INTEGER NOT NULL,
`quantity` DECIMAL(12, 3) NOT NULL,
`original_qty` DECIMAL(12, 3) NOT NULL,
`unit_price` DECIMAL(12, 2) NOT NULL,
`received_at` DATETIME(0) NULL,
`is_consumed` BOOLEAN NOT NULL DEFAULT false,
UNIQUE INDEX `sklad_batches_receipt_line_id_key`(`receipt_line_id`),
INDEX `sklad_batches_fifo`(`item_id`, `is_consumed`, `received_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_item_locations` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`item_id` INTEGER NOT NULL,
`location_id` INTEGER NOT NULL,
`quantity` DECIMAL(12, 3) NOT NULL DEFAULT 0,
UNIQUE INDEX `sklad_item_locations_unique`(`item_id`, `location_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_receipts` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`receipt_number` VARCHAR(50) NULL,
`supplier_id` INTEGER NULL,
`delivery_note_number` VARCHAR(100) NULL,
`delivery_note_date` DATETIME(0) NULL,
`received_by` INTEGER NULL,
`notes` TEXT NULL,
`status` ENUM('DRAFT', 'CONFIRMED', 'CANCELLED') NOT NULL DEFAULT 'DRAFT',
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_receipt_lines` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`receipt_id` INTEGER NOT NULL,
`item_id` INTEGER NOT NULL,
`quantity` DECIMAL(12, 3) NOT NULL,
`unit_price` DECIMAL(12, 2) NOT NULL,
`location_id` INTEGER NULL,
`notes` VARCHAR(255) NULL,
INDEX `sklad_receipt_lines_receipt_id`(`receipt_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_receipt_attachments` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`receipt_id` INTEGER NOT NULL,
`file_name` VARCHAR(255) NOT NULL,
`file_mime` VARCHAR(100) NOT NULL,
`file_size` INTEGER NOT NULL,
`file_path` VARCHAR(500) NOT NULL,
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_issues` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`issue_number` VARCHAR(50) NULL,
`project_id` INTEGER NOT NULL,
`issued_by` INTEGER NULL,
`notes` TEXT NULL,
`status` ENUM('DRAFT', 'CONFIRMED', 'CANCELLED') NOT NULL DEFAULT 'DRAFT',
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_issue_lines` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`issue_id` INTEGER NOT NULL,
`item_id` INTEGER NOT NULL,
`batch_id` INTEGER NOT NULL,
`quantity` DECIMAL(12, 3) NOT NULL,
`location_id` INTEGER NULL,
`reservation_id` INTEGER NULL,
`notes` VARCHAR(255) NULL,
INDEX `sklad_issue_lines_issue_id`(`issue_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_reservations` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`item_id` INTEGER NOT NULL,
`project_id` INTEGER NOT NULL,
`quantity` DECIMAL(12, 3) NOT NULL,
`remaining_qty` DECIMAL(12, 3) NOT NULL,
`reserved_by` INTEGER NULL,
`notes` TEXT NULL,
`status` ENUM('ACTIVE', 'FULFILLED', 'CANCELLED') NOT NULL DEFAULT 'ACTIVE',
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
INDEX `sklad_reservations_item_status`(`item_id`, `status`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_inventory_sessions` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`session_number` VARCHAR(50) NULL,
`notes` TEXT NULL,
`status` ENUM('DRAFT', 'CONFIRMED') NOT NULL DEFAULT 'DRAFT',
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_inventory_lines` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`session_id` INTEGER NOT NULL,
`item_id` INTEGER NOT NULL,
`location_id` INTEGER NULL,
`system_qty` DECIMAL(12, 3) NOT NULL,
`actual_qty` DECIMAL(12, 3) NOT NULL,
`difference` DECIMAL(12, 3) NOT NULL,
`notes` VARCHAR(255) NULL,
INDEX `sklad_inventory_lines_session_id`(`session_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `sklad_items` ADD CONSTRAINT `sklad_items_category_id_fkey` FOREIGN KEY (`category_id`) REFERENCES `sklad_categories`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_batches` ADD CONSTRAINT `sklad_batches_item_id_fkey` FOREIGN KEY (`item_id`) REFERENCES `sklad_items`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_batches` ADD CONSTRAINT `sklad_batches_receipt_line_id_fkey` FOREIGN KEY (`receipt_line_id`) REFERENCES `sklad_receipt_lines`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_item_locations` ADD CONSTRAINT `sklad_item_locations_item_id_fkey` FOREIGN KEY (`item_id`) REFERENCES `sklad_items`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_item_locations` ADD CONSTRAINT `sklad_item_locations_location_id_fkey` FOREIGN KEY (`location_id`) REFERENCES `sklad_locations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_receipts` ADD CONSTRAINT `sklad_receipts_supplier_id_fkey` FOREIGN KEY (`supplier_id`) REFERENCES `sklad_suppliers`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_receipts` ADD CONSTRAINT `sklad_receipts_received_by_fkey` FOREIGN KEY (`received_by`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_receipt_lines` ADD CONSTRAINT `sklad_receipt_lines_receipt_id_fkey` FOREIGN KEY (`receipt_id`) REFERENCES `sklad_receipts`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_receipt_lines` ADD CONSTRAINT `sklad_receipt_lines_item_id_fkey` FOREIGN KEY (`item_id`) REFERENCES `sklad_items`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_receipt_lines` ADD CONSTRAINT `sklad_receipt_lines_location_id_fkey` FOREIGN KEY (`location_id`) REFERENCES `sklad_locations`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_receipt_attachments` ADD CONSTRAINT `sklad_receipt_attachments_receipt_id_fkey` FOREIGN KEY (`receipt_id`) REFERENCES `sklad_receipts`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_issues` ADD CONSTRAINT `sklad_issues_project_id_fkey` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_issues` ADD CONSTRAINT `sklad_issues_issued_by_fkey` FOREIGN KEY (`issued_by`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_issue_lines` ADD CONSTRAINT `sklad_issue_lines_issue_id_fkey` FOREIGN KEY (`issue_id`) REFERENCES `sklad_issues`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_issue_lines` ADD CONSTRAINT `sklad_issue_lines_item_id_fkey` FOREIGN KEY (`item_id`) REFERENCES `sklad_items`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_issue_lines` ADD CONSTRAINT `sklad_issue_lines_batch_id_fkey` FOREIGN KEY (`batch_id`) REFERENCES `sklad_batches`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_issue_lines` ADD CONSTRAINT `sklad_issue_lines_location_id_fkey` FOREIGN KEY (`location_id`) REFERENCES `sklad_locations`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_issue_lines` ADD CONSTRAINT `sklad_issue_lines_reservation_id_fkey` FOREIGN KEY (`reservation_id`) REFERENCES `sklad_reservations`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_reservations` ADD CONSTRAINT `sklad_reservations_item_id_fkey` FOREIGN KEY (`item_id`) REFERENCES `sklad_items`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_reservations` ADD CONSTRAINT `sklad_reservations_project_id_fkey` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_reservations` ADD CONSTRAINT `sklad_reservations_reserved_by_fkey` FOREIGN KEY (`reserved_by`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_inventory_lines` ADD CONSTRAINT `sklad_inventory_lines_session_id_fkey` FOREIGN KEY (`session_id`) REFERENCES `sklad_inventory_sessions`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_inventory_lines` ADD CONSTRAINT `sklad_inventory_lines_item_id_fkey` FOREIGN KEY (`item_id`) REFERENCES `sklad_items`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_inventory_lines` ADD CONSTRAINT `sklad_inventory_lines_location_id_fkey` FOREIGN KEY (`location_id`) REFERENCES `sklad_locations`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,17 @@
-- AlterTable
ALTER TABLE `company_settings` ADD COLUMN `warehouse_inventory_number_pattern` VARCHAR(100) NULL,
ADD COLUMN `warehouse_inventory_prefix` VARCHAR(20) NULL,
ADD COLUMN `warehouse_issue_number_pattern` VARCHAR(100) NULL,
ADD COLUMN `warehouse_issue_prefix` VARCHAR(20) NULL,
ADD COLUMN `warehouse_receipt_number_pattern` VARCHAR(100) NULL,
ADD COLUMN `warehouse_receipt_prefix` VARCHAR(20) NULL;
-- Set backward-compatible defaults for existing installations
UPDATE `company_settings`
SET
`warehouse_receipt_prefix` = 'PRI',
`warehouse_receipt_number_pattern` = '{PREFIX}-{YYYY}-{NNN}',
`warehouse_issue_prefix` = 'VYD',
`warehouse_issue_number_pattern` = '{PREFIX}-{YYYY}-{NNN}',
`warehouse_inventory_prefix` = 'INV',
`warehouse_inventory_number_pattern` = '{PREFIX}-{YYYY}-{NNN}';

View File

@@ -128,6 +128,12 @@ model company_settings {
offer_number_pattern String? @db.VarChar(100) offer_number_pattern String? @db.VarChar(100)
order_number_pattern String? @db.VarChar(100) order_number_pattern String? @db.VarChar(100)
invoice_number_pattern String? @db.VarChar(100) invoice_number_pattern String? @db.VarChar(100)
warehouse_receipt_prefix String? @db.VarChar(20)
warehouse_receipt_number_pattern String? @db.VarChar(100)
warehouse_issue_prefix String? @db.VarChar(20)
warehouse_issue_number_pattern String? @db.VarChar(100)
warehouse_inventory_prefix String? @db.VarChar(20)
warehouse_inventory_number_pattern String? @db.VarChar(100)
} }
model customers { model customers {
@@ -153,8 +159,9 @@ model customers {
model invoice_items { 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)
quantity Decimal? @default(1.000) @db.Decimal(12, 3) item_description String? @db.Text
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)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
@@ -356,6 +363,8 @@ model projects {
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
attendance_project_logs attendance_project_logs[] attendance_project_logs attendance_project_logs[]
project_notes project_notes[] project_notes project_notes[]
sklad_issues sklad_issues[]
sklad_reservations sklad_reservations[]
users users? @relation(fields: [responsible_user_id], references: [id], onUpdate: NoAction, map: "fk_projects_responsible_user") users users? @relation(fields: [responsible_user_id], references: [id], onUpdate: NoAction, map: "fk_projects_responsible_user")
customers customers? @relation(fields: [customer_id], references: [id], onUpdate: NoAction, map: "projects_ibfk_1") customers customers? @relation(fields: [customer_id], references: [id], onUpdate: NoAction, map: "projects_ibfk_1")
quotations quotations? @relation(fields: [quotation_id], references: [id], onUpdate: NoAction, map: "projects_ibfk_2") quotations quotations? @relation(fields: [quotation_id], references: [id], onUpdate: NoAction, map: "projects_ibfk_2")
@@ -377,10 +386,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")
@@ -397,17 +403,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[]
@@ -492,11 +494,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")
@@ -590,6 +588,9 @@ model users {
leave_requests_leave_requests_reviewer_idTousers leave_requests[] @relation("leave_requests_reviewer_idTousers") leave_requests_leave_requests_reviewer_idTousers leave_requests[] @relation("leave_requests_reviewer_idTousers")
projects projects[] projects projects[]
trips trips[] trips trips[]
sklad_receipts_received sklad_receipts[] @relation("SkladReceivedBy")
sklad_issues_issued sklad_issues[] @relation("SkladIssuedBy")
sklad_reservations sklad_reservations[] @relation("SkladReservedBy")
roles roles? @relation(fields: [role_id], references: [id], onUpdate: NoAction, map: "users_ibfk_1") roles roles? @relation(fields: [role_id], references: [id], onUpdate: NoAction, map: "users_ibfk_1")
@@index([is_active], map: "idx_users_is_active") @@index([is_active], map: "idx_users_is_active")
@@ -647,3 +648,272 @@ enum attendance_leave_type {
holiday holiday
unpaid unpaid
} }
enum sklad_receipt_status {
DRAFT
CONFIRMED
CANCELLED
}
enum sklad_issue_status {
DRAFT
CONFIRMED
CANCELLED
}
enum sklad_reservation_status {
ACTIVE
FULFILLED
CANCELLED
}
enum sklad_inventory_status {
DRAFT
CONFIRMED
}
model sklad_categories {
id Int @id @default(autoincrement())
name String @db.VarChar(100)
description String? @db.Text
sort_order Int @default(0)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
items sklad_items[]
@@map("sklad_categories")
}
model sklad_suppliers {
id Int @id @default(autoincrement())
name String @db.VarChar(255)
ico String? @db.VarChar(20)
dic String? @db.VarChar(20)
contact_person String? @db.VarChar(255)
email String? @db.VarChar(255)
phone String? @db.VarChar(50)
address String? @db.Text
notes String? @db.Text
is_active Boolean @default(true)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
receipts sklad_receipts[]
@@map("sklad_suppliers")
}
model sklad_locations {
id Int @id @default(autoincrement())
code String @unique @db.VarChar(20)
name String @db.VarChar(100)
description String? @db.Text
is_active Boolean @default(true)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
items sklad_item_locations[]
receipt_lines sklad_receipt_lines[]
issue_lines sklad_issue_lines[]
inventory_lines sklad_inventory_lines[]
@@map("sklad_locations")
}
model sklad_items {
id Int @id @default(autoincrement())
item_number String? @unique @db.VarChar(50)
name String @db.VarChar(255)
description String? @db.Text
category_id Int?
unit String @db.VarChar(20)
min_quantity Decimal? @db.Decimal(12, 3)
is_active Boolean @default(true)
notes String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
category sklad_categories? @relation(fields: [category_id], references: [id])
batches sklad_batches[]
item_locations sklad_item_locations[]
receipt_lines sklad_receipt_lines[]
issue_lines sklad_issue_lines[]
reservations sklad_reservations[]
inventory_lines sklad_inventory_lines[]
@@index([category_id], map: "sklad_items_category_id")
@@map("sklad_items")
}
model sklad_batches {
id Int @id @default(autoincrement())
item_id Int
receipt_line_id Int @unique
quantity Decimal @db.Decimal(12, 3)
original_qty Decimal @db.Decimal(12, 3)
unit_price Decimal @db.Decimal(12, 2)
received_at DateTime? @db.DateTime(0)
is_consumed Boolean @default(false)
item sklad_items @relation(fields: [item_id], references: [id])
receipt_line sklad_receipt_lines @relation(fields: [receipt_line_id], references: [id])
issue_lines sklad_issue_lines[]
@@index([item_id, is_consumed, received_at], map: "sklad_batches_fifo")
@@map("sklad_batches")
}
model sklad_item_locations {
id Int @id @default(autoincrement())
item_id Int
location_id Int
quantity Decimal @default(0) @db.Decimal(12, 3)
item sklad_items @relation(fields: [item_id], references: [id])
location sklad_locations @relation(fields: [location_id], references: [id])
@@unique([item_id, location_id], map: "sklad_item_locations_unique")
@@map("sklad_item_locations")
}
model sklad_receipts {
id Int @id @default(autoincrement())
receipt_number String? @db.VarChar(50)
supplier_id Int?
delivery_note_number String? @db.VarChar(100)
delivery_note_date DateTime? @db.DateTime(0)
received_by Int?
notes String? @db.Text
status sklad_receipt_status @default(DRAFT)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
supplier sklad_suppliers? @relation(fields: [supplier_id], references: [id])
received_by_user users? @relation("SkladReceivedBy", fields: [received_by], references: [id])
items sklad_receipt_lines[]
attachments sklad_receipt_attachments[]
@@map("sklad_receipts")
}
model sklad_receipt_lines {
id Int @id @default(autoincrement())
receipt_id Int
item_id Int
quantity Decimal @db.Decimal(12, 3)
unit_price Decimal @db.Decimal(12, 2)
location_id Int?
notes String? @db.VarChar(255)
receipt sklad_receipts @relation(fields: [receipt_id], references: [id])
item sklad_items @relation(fields: [item_id], references: [id])
location sklad_locations? @relation(fields: [location_id], references: [id])
batch sklad_batches?
@@index([receipt_id], map: "sklad_receipt_lines_receipt_id")
@@map("sklad_receipt_lines")
}
model sklad_receipt_attachments {
id Int @id @default(autoincrement())
receipt_id Int
file_name String @db.VarChar(255)
file_mime String @db.VarChar(100)
file_size Int
file_path String @db.VarChar(500)
created_at DateTime? @default(now()) @db.DateTime(0)
receipt sklad_receipts @relation(fields: [receipt_id], references: [id])
@@map("sklad_receipt_attachments")
}
model sklad_issues {
id Int @id @default(autoincrement())
issue_number String? @db.VarChar(50)
project_id Int
issued_by Int?
notes String? @db.Text
status sklad_issue_status @default(DRAFT)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
project projects @relation(fields: [project_id], references: [id])
issued_by_user users? @relation("SkladIssuedBy", fields: [issued_by], references: [id])
items sklad_issue_lines[]
@@map("sklad_issues")
}
model sklad_issue_lines {
id Int @id @default(autoincrement())
issue_id Int
item_id Int
batch_id Int
quantity Decimal @db.Decimal(12, 3)
location_id Int?
reservation_id Int?
notes String? @db.VarChar(255)
issue sklad_issues @relation(fields: [issue_id], references: [id])
item sklad_items @relation(fields: [item_id], references: [id])
batch sklad_batches @relation(fields: [batch_id], references: [id])
location sklad_locations? @relation(fields: [location_id], references: [id])
reservation sklad_reservations? @relation(fields: [reservation_id], references: [id])
@@index([issue_id], map: "sklad_issue_lines_issue_id")
@@map("sklad_issue_lines")
}
model sklad_reservations {
id Int @id @default(autoincrement())
item_id Int
project_id Int
quantity Decimal @db.Decimal(12, 3)
remaining_qty Decimal @db.Decimal(12, 3)
reserved_by Int?
notes String? @db.Text
status sklad_reservation_status @default(ACTIVE)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
item sklad_items @relation(fields: [item_id], references: [id])
project projects @relation(fields: [project_id], references: [id])
reserved_by_user users? @relation("SkladReservedBy", fields: [reserved_by], references: [id])
issue_lines sklad_issue_lines[]
@@index([item_id, status], map: "sklad_reservations_item_status")
@@map("sklad_reservations")
}
model sklad_inventory_sessions {
id Int @id @default(autoincrement())
session_number String? @db.VarChar(50)
notes String? @db.Text
status sklad_inventory_status @default(DRAFT)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
items sklad_inventory_lines[]
@@map("sklad_inventory_sessions")
}
model sklad_inventory_lines {
id Int @id @default(autoincrement())
session_id Int
item_id Int
location_id Int?
system_qty Decimal @db.Decimal(12, 3)
actual_qty Decimal @db.Decimal(12, 3)
difference Decimal @db.Decimal(12, 3)
notes String? @db.VarChar(255)
session sklad_inventory_sessions @relation(fields: [session_id], references: [id])
item sklad_items @relation(fields: [item_id], references: [id])
location sklad_locations? @relation(fields: [location_id], references: [id])
@@index([session_id], map: "sklad_inventory_lines_session_id")
@@map("sklad_inventory_lines")
}

View File

@@ -280,6 +280,32 @@ const PERMISSIONS: {
module: "settings", module: "settings",
description: "Prohlížet auditní logy", description: "Prohlížet auditní logy",
}, },
// Sklad
{
name: "warehouse.view",
display_name: "Zobrazit sklad",
module: "warehouse",
description: "Prohlížet stav skladu, položky, reporty a historii pohybů",
},
{
name: "warehouse.operate",
display_name: "Příjem a výdej",
module: "warehouse",
description: "Vytvářet a potvrzovat příjmy, výdeje a rezervace",
},
{
name: "warehouse.manage",
display_name: "Správa skladu",
module: "warehouse",
description: "Spravovat katalog materiálů, dodavatele, lokace a kategorie",
},
{
name: "warehouse.inventory",
display_name: "Inventura",
module: "warehouse",
description: "Vytvářet a potvrzovat inventurní sčítkání",
},
]; ];
async function main() { async function main() {
@@ -407,7 +433,14 @@ async function main() {
// 6. Initialize number_sequences for current year // 6. Initialize number_sequences for current year
const year = new Date().getFullYear(); const year = new Date().getFullYear();
const types = ["quotation", "order", "invoice"]; const types = [
"quotation",
"order",
"invoice",
"warehouse_receipt",
"warehouse_issue",
"warehouse_inventory",
];
for (const type of types) { for (const type of types) {
const existing = await prisma.number_sequences.findFirst({ const existing = await prisma.number_sequences.findFirst({
where: { type, year }, where: { type, year },

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,6 @@ import "./layout.css";
import "./components.css"; import "./components.css";
import "./tables.css"; import "./tables.css";
import "./datepicker.css"; import "./datepicker.css";
import "./bones/registry";
import "./filemanager.css"; import "./filemanager.css";
import "./pagination.css"; import "./pagination.css";
import "./responsive.css"; import "./responsive.css";
@@ -27,6 +26,7 @@ import "./attendance.css";
import "./settings.css"; import "./settings.css";
import "./offers.css"; import "./offers.css";
import "./invoices.css"; import "./invoices.css";
import "./warehouse.css";
const Users = lazy(() => import("./pages/Users")); const Users = lazy(() => import("./pages/Users"));
const Attendance = lazy(() => import("./pages/Attendance")); const Attendance = lazy(() => import("./pages/Attendance"));
@@ -54,6 +54,31 @@ const InvoiceDetail = lazy(() => import("./pages/InvoiceDetail"));
const Settings = lazy(() => import("./pages/Settings")); const Settings = lazy(() => import("./pages/Settings"));
const AuditLog = lazy(() => import("./pages/AuditLog")); const AuditLog = lazy(() => import("./pages/AuditLog"));
const NotFound = lazy(() => import("./pages/NotFound")); const NotFound = lazy(() => import("./pages/NotFound"));
const Warehouse = lazy(() => import("./pages/Warehouse"));
const WarehouseItems = lazy(() => import("./pages/WarehouseItems"));
const WarehouseItemDetail = lazy(() => import("./pages/WarehouseItemDetail"));
const WarehouseReceipts = lazy(() => import("./pages/WarehouseReceipts"));
const WarehouseReceiptDetail = lazy(
() => import("./pages/WarehouseReceiptDetail"),
);
const WarehouseReceiptForm = lazy(() => import("./pages/WarehouseReceiptForm"));
const WarehouseIssues = lazy(() => import("./pages/WarehouseIssues"));
const WarehouseIssueDetail = lazy(() => import("./pages/WarehouseIssueDetail"));
const WarehouseIssueForm = lazy(() => import("./pages/WarehouseIssueForm"));
const WarehouseReservations = lazy(
() => import("./pages/WarehouseReservations"),
);
const WarehouseInventory = lazy(() => import("./pages/WarehouseInventory"));
const WarehouseInventoryDetail = lazy(
() => import("./pages/WarehouseInventoryDetail"),
);
const WarehouseInventoryForm = lazy(
() => import("./pages/WarehouseInventoryForm"),
);
const WarehouseReports = lazy(() => import("./pages/WarehouseReports"));
const WarehouseSuppliers = lazy(() => import("./pages/WarehouseSuppliers"));
const WarehouseLocations = lazy(() => import("./pages/WarehouseLocations"));
const WarehouseCategories = lazy(() => import("./pages/WarehouseCategories"));
export default function AdminApp() { export default function AdminApp() {
return ( return (
@@ -127,6 +152,76 @@ export default function AdminApp() {
<Route path="invoices/:id" element={<InvoiceDetail />} /> <Route path="invoices/:id" element={<InvoiceDetail />} />
<Route path="settings" element={<Settings />} /> <Route path="settings" element={<Settings />} />
<Route path="audit-log" element={<AuditLog />} /> <Route path="audit-log" element={<AuditLog />} />
<Route path="warehouse" element={<Warehouse />} />
<Route path="warehouse/items" element={<WarehouseItems />} />
<Route
path="warehouse/items/:id"
element={<WarehouseItemDetail />}
/>
<Route
path="warehouse/receipts"
element={<WarehouseReceipts />}
/>
<Route
path="warehouse/receipts/new"
element={<WarehouseReceiptForm />}
/>
<Route
path="warehouse/receipts/:id"
element={<WarehouseReceiptDetail />}
/>
<Route
path="warehouse/receipts/:id/edit"
element={<WarehouseReceiptForm />}
/>
<Route
path="warehouse/issues"
element={<WarehouseIssues />}
/>
<Route
path="warehouse/issues/new"
element={<WarehouseIssueForm />}
/>
<Route
path="warehouse/issues/:id"
element={<WarehouseIssueDetail />}
/>
<Route
path="warehouse/issues/:id/edit"
element={<WarehouseIssueForm />}
/>
<Route
path="warehouse/reservations"
element={<WarehouseReservations />}
/>
<Route
path="warehouse/inventory"
element={<WarehouseInventory />}
/>
<Route
path="warehouse/inventory/new"
element={<WarehouseInventoryForm />}
/>
<Route
path="warehouse/inventory/:id"
element={<WarehouseInventoryDetail />}
/>
<Route
path="warehouse/reports"
element={<WarehouseReports />}
/>
<Route
path="warehouse/suppliers"
element={<WarehouseSuppliers />}
/>
<Route
path="warehouse/locations"
element={<WarehouseLocations />}
/>
<Route
path="warehouse/categories"
element={<WarehouseCategories />}
/>
</Route> </Route>
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>

View File

@@ -318,12 +318,14 @@
/* Leave Type Badges */ /* Leave Type Badges */
.attendance-leave-badge { .attendance-leave-badge {
display: inline-block; display: inline-flex;
padding: 0.25rem 0.5rem; align-items: center;
justify-content: center;
padding: 0.25rem 0.55rem;
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 500; font-weight: 500;
margin-right: 0.25rem; line-height: 1.2;
background: var(--bg-tertiary); background: var(--bg-tertiary);
color: var(--text-secondary); color: var(--text-secondary);
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,263 +0,0 @@
{
"breakpoints": {
"375": {
"name": "attendance-create",
"viewportWidth": 351,
"width": 351,
"height": 403,
"bones": [
[
0,
0,
100,
22,
8
],
[
0,
42,
100,
361,
10,
true
],
[
3.7037,
55,
92.5926,
21,
8
],
[
3.7037,
83,
92.5926,
44,
8
],
[
3.7037,
127,
92.5926,
21,
8
],
[
3.7037,
156,
92.5926,
44,
8
],
[
3.7037,
200,
92.5926,
21,
8
],
[
3.7037,
229,
92.5926,
44,
8
],
[
3.7037,
273,
92.5926,
21,
8
],
[
3.7037,
302,
92.5926,
44,
8
],
[
3.7037,
346,
19.2352,
44,
8
]
]
},
"768": {
"name": "attendance-create",
"viewportWidth": 736,
"width": 736,
"height": 420,
"bones": [
[
0,
0,
23.3738,
26,
8
],
[
0,
46,
81.5217,
373,
10,
true
],
[
2.5815,
65,
76.3587,
21,
8
],
[
2.5815,
94,
76.3587,
44,
8
],
[
2.5815,
138,
76.3587,
21,
8
],
[
2.5815,
167,
76.3587,
44,
8
],
[
2.5815,
211,
76.3587,
21,
8
],
[
2.5815,
240,
76.3587,
44,
8
],
[
2.5815,
284,
76.3587,
21,
8
],
[
2.5815,
313,
76.3587,
44,
8
],
[
2.5815,
357,
9.1733,
44,
8
]
]
},
"1280": {
"name": "attendance-create",
"viewportWidth": 996,
"width": 996,
"height": 369,
"bones": [
[
0,
0,
17.2722,
26,
8
],
[
0,
46,
60.241,
323,
10,
true
],
[
1.9076,
65,
56.4257,
19,
8
],
[
1.9076,
93,
56.4257,
36,
8
],
[
1.9076,
129,
56.4257,
19,
8
],
[
1.9076,
156,
56.4257,
36,
8
],
[
1.9076,
192,
56.4257,
19,
8
],
[
1.9076,
219,
56.4257,
36,
8
],
[
1.9076,
255,
56.4257,
19,
8
],
[
1.9076,
282,
56.4257,
36,
8
],
[
1.9076,
318,
6.3771,
32,
8
]
]
}
},
"_hash": "7c4e446bf97f164a0fba87e2dd7df7d1"
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,506 +0,0 @@
{
"breakpoints": {
"375": {
"name": "dash-sessions",
"viewportWidth": 341,
"width": 341,
"height": 304,
"bones": [
[
0,
0,
100,
304,
10,
true
],
[
3.8123,
17,
34.9386,
17,
8
],
[
59.3383,
13,
36.8493,
37,
8
],
[
0.2933,
63,
99.4135,
83,
8,
true
],
[
4.9853,
86,
10.5572,
36,
8,
true
],
[
7.6246,
95,
5.2786,
18,
"50%"
],
[
63.0407,
79,
19.5152,
27,
9999
],
[
19.0616,
110,
21.6734,
19,
8
],
[
43.081,
110,
1.0585,
19,
8
],
[
46.4855,
110,
26.8649,
19,
8
],
[
4.9853,
167,
10.5572,
36,
8,
true
],
[
7.6246,
176,
5.2786,
18,
"50%"
],
[
19.0616,
162,
75.9531,
22,
8
],
[
19.0616,
189,
21.6734,
19,
8
],
[
43.081,
189,
1.0585,
19,
8
],
[
46.4855,
189,
26.8649,
19,
8
],
[
4.9853,
246,
10.5572,
36,
8,
true
],
[
7.6246,
255,
5.2786,
18,
"50%"
],
[
19.0616,
241,
75.9531,
22,
8
],
[
19.0616,
267,
21.6734,
19,
8
],
[
43.081,
267,
1.0585,
19,
8
],
[
46.4855,
267,
26.8649,
19,
8
]
]
},
"768": {
"name": "dash-sessions",
"viewportWidth": 726,
"width": 726,
"height": 319,
"bones": [
[
0,
0,
100,
319,
10,
true
],
[
2.6171,
19,
16.4106,
17,
8
],
[
80.0749,
15,
17.308,
37,
8
],
[
0.1377,
67,
99.7245,
85,
8,
true
],
[
3.4435,
89,
5.5096,
40,
8,
true
],
[
4.9587,
100,
2.4793,
18,
"50%"
],
[
34.5278,
83,
9.1662,
27,
9999
],
[
11.157,
114,
11.0279,
21,
8
],
[
23.2868,
114,
0.5381,
21,
8
],
[
24.9268,
114,
13.6686,
21,
8
],
[
3.4435,
173,
5.5096,
40,
8,
true
],
[
4.9587,
184,
2.4793,
18,
"50%"
],
[
11.157,
168,
85.3994,
26,
8
],
[
11.157,
198,
11.0279,
21,
8
],
[
23.2868,
198,
0.5381,
21,
8
],
[
24.9268,
198,
13.6686,
21,
8
],
[
3.4435,
257,
5.5096,
40,
8,
true
],
[
4.9587,
268,
2.4793,
18,
"50%"
],
[
11.157,
251,
85.3994,
26,
8
],
[
11.157,
281,
11.0279,
21,
8
],
[
23.2868,
281,
0.5381,
21,
8
],
[
24.9268,
281,
13.6686,
21,
8
]
]
},
"1280": {
"name": "dash-sessions",
"viewportWidth": 484,
"width": 484,
"height": 309,
"bones": [
[
0,
0,
100,
309,
10,
true
],
[
3.9256,
15,
24.6158,
17,
8
],
[
72.1785,
15,
23.8959,
29,
8
],
[
0.2066,
59,
99.5868,
83,
8,
true
],
[
5.1653,
80,
8.2645,
40,
8,
true
],
[
7.438,
91,
3.719,
18,
"50%"
],
[
51.7917,
76,
12.9358,
24,
9999
],
[
16.7355,
105,
16.5418,
21,
8
],
[
34.9303,
105,
0.8071,
21,
8
],
[
37.3902,
105,
20.503,
21,
8
],
[
5.1653,
164,
8.2645,
40,
8,
true
],
[
7.438,
175,
3.719,
18,
"50%"
],
[
16.7355,
158,
78.0992,
26,
8
],
[
16.7355,
188,
16.5418,
21,
8
],
[
34.9303,
188,
0.8071,
21,
8
],
[
37.3902,
188,
20.503,
21,
8
],
[
5.1653,
247,
8.2645,
40,
8,
true
],
[
7.438,
258,
3.719,
18,
"50%"
],
[
16.7355,
242,
78.0992,
26,
8
],
[
16.7355,
271,
16.5418,
21,
8
],
[
34.9303,
271,
0.8071,
21,
8
],
[
37.3902,
271,
20.503,
21,
8
]
]
}
},
"_hash": "e057ca7b36a30c5971a4225ec3ad4680"
}

View File

@@ -1,707 +0,0 @@
{
"breakpoints": {
"375": {
"name": "invoice-detail",
"viewportWidth": 351,
"width": 351,
"height": 466,
"bones": [
[
3.4188,
12,
5.698,
20,
"50%"
],
[
14.8148,
9,
30.0881,
22,
8
],
[
0,
52,
30.6713,
44,
8
],
[
34.0901,
52,
31.2455,
44,
8
],
[
68.7545,
52,
31.2455,
44,
8
],
[
0,
112,
100,
152,
10,
true
],
[
3.7037,
125,
44.0171,
21,
8
],
[
3.7037,
154,
44.0171,
26,
8
],
[
52.2792,
125,
44.0171,
21,
8
],
[
52.2792,
154,
44.0171,
27,
9999
],
[
3.7037,
197,
44.0171,
21,
8
],
[
3.7037,
226,
44.0171,
26,
8
],
[
52.2792,
197,
44.0171,
21,
8
],
[
52.2792,
226,
44.0171,
26,
8
],
[
0,
280,
100,
186,
10,
true
],
[
3.7037,
293,
92.5926,
17,
8
],
[
3.7037,
322,
33.3066,
31,
0
],
[
37.0103,
322,
35.1718,
31,
0
],
[
72.1822,
322,
36.9836,
31,
0
],
[
109.1658,
322,
36.9881,
31,
0
],
[
3.7037,
353,
33.3066,
34,
0
],
[
37.0103,
353,
35.1718,
34,
0
],
[
72.1822,
353,
36.9836,
34,
0
],
[
109.1658,
353,
36.9881,
34,
0
],
[
3.7037,
387,
33.3066,
34,
0
],
[
37.0103,
387,
35.1718,
34,
0
],
[
72.1822,
387,
36.9836,
34,
0
],
[
109.1658,
387,
36.9881,
34,
0
],
[
3.7037,
420,
33.3066,
33,
0
],
[
37.0103,
420,
35.1718,
33,
0
],
[
72.1822,
420,
36.9836,
33,
0
],
[
109.1658,
420,
36.9881,
33,
0
]
]
},
"768": {
"name": "invoice-detail",
"viewportWidth": 736,
"width": 736,
"height": 444,
"bones": [
[
1.6304,
12,
2.7174,
20,
"50%"
],
[
7.0652,
7,
17.5378,
26,
8
],
[
62.9692,
0,
9.1733,
44,
8
],
[
73.7729,
0,
13.6379,
44,
8
],
[
89.0413,
0,
10.9587,
44,
8
],
[
0,
60,
100,
164,
10,
true
],
[
2.5815,
79,
46.3315,
21,
8
],
[
2.5815,
108,
46.3315,
26,
8
],
[
51.087,
79,
46.3315,
21,
8
],
[
51.087,
108,
46.3315,
27,
9999
],
[
2.5815,
151,
46.3315,
21,
8
],
[
2.5815,
180,
46.3315,
26,
8
],
[
51.087,
151,
46.3315,
21,
8
],
[
51.087,
180,
46.3315,
26,
8
],
[
0,
240,
100,
204,
10,
true
],
[
2.5815,
259,
94.837,
17,
8
],
[
2.5815,
288,
22.3803,
33,
0
],
[
24.9618,
288,
23.5882,
33,
0
],
[
48.55,
288,
24.431,
33,
0
],
[
72.9811,
288,
24.4374,
33,
0
],
[
2.5815,
321,
22.3803,
35,
0
],
[
24.9618,
321,
23.5882,
35,
0
],
[
48.55,
321,
24.431,
35,
0
],
[
72.9811,
321,
24.4374,
35,
0
],
[
2.5815,
356,
22.3803,
35,
0
],
[
24.9618,
356,
23.5882,
35,
0
],
[
48.55,
356,
24.431,
35,
0
],
[
72.9811,
356,
24.4374,
35,
0
],
[
2.5815,
391,
22.3803,
35,
0
],
[
24.9618,
391,
23.5882,
35,
0
],
[
48.55,
391,
24.431,
35,
0
],
[
72.9811,
391,
24.4374,
35,
0
]
]
},
"1280": {
"name": "invoice-detail",
"viewportWidth": 996,
"width": 996,
"height": 457,
"bones": [
[
0.6024,
7,
2.008,
20,
"50%"
],
[
4.0161,
2,
12.9597,
26,
8
],
[
73.8407,
0,
6.3771,
34,
8
],
[
81.4226,
0,
9.6762,
34,
8
],
[
92.3036,
0,
7.6964,
34,
8
],
[
0,
50,
100,
160,
10,
true
],
[
1.9076,
69,
47.2892,
19,
8
],
[
1.9076,
96,
47.2892,
26,
8
],
[
50.8032,
69,
47.2892,
19,
8
],
[
50.8032,
96,
47.2892,
24,
9999
],
[
1.9076,
138,
47.2892,
19,
8
],
[
1.9076,
165,
47.2892,
26,
8
],
[
50.8032,
138,
47.2892,
19,
8
],
[
50.8032,
165,
47.2892,
26,
8
],
[
0,
226,
100,
232,
10,
true
],
[
1.9076,
245,
96.1847,
17,
8
],
[
1.9076,
273,
22.9606,
38,
0
],
[
24.8682,
273,
24.065,
38,
0
],
[
48.9332,
273,
24.578,
38,
0
],
[
73.5112,
273,
24.5811,
38,
0
],
[
1.9076,
311,
22.9606,
43,
0
],
[
24.8682,
311,
24.065,
43,
0
],
[
48.9332,
311,
24.578,
43,
0
],
[
73.5112,
311,
24.5811,
43,
0
],
[
1.9076,
354,
22.9606,
43,
0
],
[
24.8682,
354,
24.065,
43,
0
],
[
48.9332,
354,
24.578,
43,
0
],
[
73.5112,
354,
24.5811,
43,
0
],
[
1.9076,
396,
22.9606,
42,
0
],
[
24.8682,
396,
24.065,
42,
0
],
[
48.9332,
396,
24.578,
42,
0
],
[
73.5112,
396,
24.5811,
42,
0
]
]
}
},
"_hash": "934452d45a0bef9320dc379fb3f43bb5"
}

View File

@@ -1,599 +0,0 @@
{
"breakpoints": {
"375": {
"name": "leave-approval",
"viewportWidth": 351,
"width": 351,
"height": 294,
"bones": [
[
0,
0,
100,
22,
8
],
[
0,
26,
100,
19,
8
],
[
0,
61,
100,
233,
10,
true
],
[
3.7037,
74,
19.2753,
31,
0
],
[
22.979,
74,
18.7812,
31,
0
],
[
41.7601,
74,
23.0502,
31,
0
],
[
64.8104,
74,
23.0502,
31,
0
],
[
87.8606,
74,
9.4373,
31,
0
],
[
97.2979,
74,
54.4471,
31,
0
],
[
3.7037,
105,
19.2753,
54,
0
],
[
22.979,
105,
18.7812,
54,
0
],
[
41.7601,
105,
23.0502,
54,
0
],
[
64.8104,
105,
23.0502,
54,
0
],
[
87.8606,
105,
9.4373,
54,
0
],
[
97.2979,
105,
54.4471,
54,
0
],
[
3.7037,
159,
19.2753,
54,
0
],
[
22.979,
159,
18.7812,
54,
0
],
[
41.7601,
159,
23.0502,
54,
0
],
[
64.8104,
159,
23.0502,
54,
0
],
[
87.8606,
159,
9.4373,
54,
0
],
[
97.2979,
159,
54.4471,
54,
0
],
[
3.7037,
213,
19.2753,
54,
0
],
[
22.979,
213,
18.7812,
54,
0
],
[
41.7601,
213,
23.0502,
54,
0
],
[
64.8104,
213,
23.0502,
54,
0
],
[
87.8606,
213,
9.4373,
54,
0
],
[
97.2979,
213,
54.4471,
54,
0
]
]
},
"768": {
"name": "leave-approval",
"viewportWidth": 736,
"width": 736,
"height": 299,
"bones": [
[
0,
0,
29.4264,
26,
8
],
[
0,
30,
29.4264,
21,
8
],
[
0,
67,
100,
232,
10,
true
],
[
2.5815,
86,
12.6911,
33,
0
],
[
15.2726,
86,
12.3769,
33,
0
],
[
27.6495,
86,
15.0921,
33,
0
],
[
42.7416,
86,
15.0921,
33,
0
],
[
57.8337,
86,
6.4856,
33,
0
],
[
64.3194,
86,
33.0991,
33,
0
],
[
2.5815,
119,
12.6911,
54,
0
],
[
15.2726,
119,
12.3769,
54,
0
],
[
27.6495,
119,
15.0921,
54,
0
],
[
42.7416,
119,
15.0921,
54,
0
],
[
57.8337,
119,
6.4856,
54,
0
],
[
64.3194,
119,
33.0991,
54,
0
],
[
2.5815,
173,
12.6911,
54,
0
],
[
15.2726,
173,
12.3769,
54,
0
],
[
27.6495,
173,
15.0921,
54,
0
],
[
42.7416,
173,
15.0921,
54,
0
],
[
57.8337,
173,
6.4856,
54,
0
],
[
64.3194,
173,
33.0991,
54,
0
],
[
2.5815,
227,
12.6911,
54,
0
],
[
15.2726,
227,
12.3769,
54,
0
],
[
27.6495,
227,
15.0921,
54,
0
],
[
42.7416,
227,
15.0921,
54,
0
],
[
57.8337,
227,
6.4856,
54,
0
],
[
64.3194,
227,
33.0991,
54,
0
]
]
},
"1280": {
"name": "leave-approval",
"viewportWidth": 996,
"width": 996,
"height": 299,
"bones": [
[
0,
0,
21.7448,
26,
8
],
[
0,
30,
21.7448,
21,
8
],
[
0,
67,
100,
232,
10,
true
],
[
1.9076,
86,
13.8664,
38,
0
],
[
15.774,
86,
13.5589,
38,
0
],
[
29.333,
86,
16.196,
38,
0
],
[
45.529,
86,
16.196,
38,
0
],
[
61.725,
86,
7.8878,
38,
0
],
[
69.6128,
86,
28.4795,
38,
0
],
[
1.9076,
124,
13.8664,
52,
0
],
[
15.774,
124,
13.5589,
52,
0
],
[
29.333,
124,
16.196,
52,
0
],
[
45.529,
124,
16.196,
52,
0
],
[
61.725,
124,
7.8878,
52,
0
],
[
69.6128,
124,
28.4795,
52,
0
],
[
1.9076,
176,
13.8664,
52,
0
],
[
15.774,
176,
13.5589,
52,
0
],
[
29.333,
176,
16.196,
52,
0
],
[
45.529,
176,
16.196,
52,
0
],
[
61.725,
176,
7.8878,
52,
0
],
[
69.6128,
176,
28.4795,
52,
0
],
[
1.9076,
228,
13.8664,
52,
0
],
[
15.774,
228,
13.5589,
52,
0
],
[
29.333,
228,
16.196,
52,
0
],
[
45.529,
228,
16.196,
52,
0
],
[
61.725,
228,
7.8878,
52,
0
],
[
69.6128,
228,
28.4795,
52,
0
]
]
}
},
"_hash": "4b74917f659334073252a738cfa9c4ac"
}

View File

@@ -1,704 +0,0 @@
{
"breakpoints": {
"375": {
"name": "leave-requests",
"viewportWidth": 351,
"width": 351,
"height": 367,
"bones": [
[
0,
0,
100,
22,
8
],
[
0,
26,
100,
19,
8
],
[
0,
53,
100,
44,
8
],
[
0,
113,
100,
254,
10,
true
],
[
3.7037,
126,
21.1806,
31,
0
],
[
24.8843,
126,
20.6375,
31,
0
],
[
45.5217,
126,
25.3294,
31,
0
],
[
70.8511,
126,
25.3294,
31,
0
],
[
96.1806,
126,
10.3677,
31,
0
],
[
106.5483,
126,
20.7977,
31,
0
],
[
127.346,
126,
18.8079,
31,
0
],
[
3.7037,
157,
21.1806,
61,
0
],
[
24.8843,
157,
20.6375,
61,
0
],
[
45.5217,
157,
25.3294,
61,
0
],
[
70.8511,
157,
25.3294,
61,
0
],
[
96.1806,
157,
10.3677,
61,
0
],
[
106.5483,
157,
20.7977,
61,
0
],
[
127.346,
157,
18.8079,
61,
0
],
[
3.7037,
218,
21.1806,
61,
0
],
[
24.8843,
218,
20.6375,
61,
0
],
[
45.5217,
218,
25.3294,
61,
0
],
[
70.8511,
218,
25.3294,
61,
0
],
[
96.1806,
218,
10.3677,
61,
0
],
[
106.5483,
218,
20.7977,
61,
0
],
[
127.346,
218,
18.8079,
61,
0
],
[
3.7037,
279,
21.1806,
61,
0
],
[
24.8843,
279,
20.6375,
61,
0
],
[
45.5217,
279,
25.3294,
61,
0
],
[
70.8511,
279,
25.3294,
61,
0
],
[
96.1806,
279,
10.3677,
61,
0
],
[
106.5483,
279,
20.7977,
61,
0
],
[
127.346,
279,
18.8079,
61,
0
]
]
},
"768": {
"name": "leave-requests",
"viewportWidth": 736,
"width": 736,
"height": 320,
"bones": [
[
0,
0,
27.1888,
26,
8
],
[
0,
30,
27.1888,
21,
8
],
[
83.6914,
4,
16.3086,
44,
8
],
[
0,
67,
100,
253,
10,
true
],
[
2.5815,
86,
14.313,
33,
0
],
[
16.8945,
86,
13.9585,
33,
0
],
[
30.853,
86,
17.0219,
33,
0
],
[
47.8749,
86,
17.0219,
33,
0
],
[
64.8968,
86,
7.3157,
33,
0
],
[
72.2126,
86,
13.2006,
33,
0
],
[
85.4131,
86,
12.0053,
33,
0
],
[
2.5815,
119,
14.313,
61,
0
],
[
16.8945,
119,
13.9585,
61,
0
],
[
30.853,
119,
17.0219,
61,
0
],
[
47.8749,
119,
17.0219,
61,
0
],
[
64.8968,
119,
7.3157,
61,
0
],
[
72.2126,
119,
13.2006,
61,
0
],
[
85.4131,
119,
12.0053,
61,
0
],
[
2.5815,
180,
14.313,
61,
0
],
[
16.8945,
180,
13.9585,
61,
0
],
[
30.853,
180,
17.0219,
61,
0
],
[
47.8749,
180,
17.0219,
61,
0
],
[
64.8968,
180,
7.3157,
61,
0
],
[
72.2126,
180,
13.2006,
61,
0
],
[
85.4131,
180,
12.0053,
61,
0
],
[
2.5815,
241,
14.313,
61,
0
],
[
16.8945,
241,
13.9585,
61,
0
],
[
30.853,
241,
17.0219,
61,
0
],
[
47.8749,
241,
17.0219,
61,
0
],
[
64.8968,
241,
7.3157,
61,
0
],
[
72.2126,
241,
13.2006,
61,
0
],
[
85.4131,
241,
12.0053,
61,
0
]
]
},
"1280": {
"name": "leave-requests",
"viewportWidth": 996,
"width": 996,
"height": 308,
"bones": [
[
0,
0,
20.0913,
26,
8
],
[
0,
30,
20.0913,
21,
8
],
[
88.3503,
10,
11.6497,
32,
8
],
[
0,
67,
100,
241,
10,
true
],
[
1.9076,
86,
14.9787,
38,
0
],
[
16.8863,
86,
14.6461,
38,
0
],
[
31.5324,
86,
17.495,
38,
0
],
[
49.0274,
86,
17.495,
38,
0
],
[
66.5223,
86,
8.52,
38,
0
],
[
75.0424,
86,
12.74,
38,
0
],
[
87.7824,
86,
10.31,
38,
0
],
[
1.9076,
124,
14.9787,
55,
0
],
[
16.8863,
124,
14.6461,
55,
0
],
[
31.5324,
124,
17.495,
55,
0
],
[
49.0274,
124,
17.495,
55,
0
],
[
66.5223,
124,
8.52,
55,
0
],
[
75.0424,
124,
12.74,
55,
0
],
[
87.7824,
124,
10.31,
55,
0
],
[
1.9076,
179,
14.9787,
55,
0
],
[
16.8863,
179,
14.6461,
55,
0
],
[
31.5324,
179,
17.495,
55,
0
],
[
49.0274,
179,
17.495,
55,
0
],
[
66.5223,
179,
8.52,
55,
0
],
[
75.0424,
179,
12.74,
55,
0
],
[
87.7824,
179,
10.31,
55,
0
],
[
1.9076,
234,
14.9787,
55,
0
],
[
16.8863,
234,
14.6461,
55,
0
],
[
31.5324,
234,
17.495,
55,
0
],
[
49.0274,
234,
17.495,
55,
0
],
[
66.5223,
234,
8.52,
55,
0
],
[
75.0424,
234,
12.74,
55,
0
],
[
87.7824,
234,
10.31,
55,
0
]
]
}
},
"_hash": "125231cbf4c6abc4e73bb48732dc9353"
}

View File

@@ -1,620 +0,0 @@
{
"breakpoints": {
"375": {
"name": "offer-detail",
"viewportWidth": 351,
"width": 351,
"height": 483,
"bones": [
[
0,
0,
12.5356,
44,
8
],
[
0,
52,
100,
22,
8
],
[
0,
86,
100,
44,
8
],
[
0,
146,
100,
338,
10,
true
],
[
3.7037,
159,
44.0171,
21,
8
],
[
3.7037,
187,
44.0171,
46,
8
],
[
52.2792,
159,
44.0171,
21,
8
],
[
52.2792,
187,
44.0171,
46,
8
],
[
3.7037,
249,
44.0171,
21,
8
],
[
3.7037,
278,
44.0171,
46,
8
],
[
52.2792,
249,
44.0171,
21,
8
],
[
52.2792,
278,
44.0171,
46,
8
],
[
3.7037,
339,
34.4062,
31,
0
],
[
38.1099,
339,
34.8157,
31,
0
],
[
72.9256,
339,
36.6097,
31,
0
],
[
109.5353,
339,
36.6186,
31,
0
],
[
3.7037,
370,
34.4062,
34,
0
],
[
38.1099,
370,
34.8157,
34,
0
],
[
72.9256,
370,
36.6097,
34,
0
],
[
109.5353,
370,
36.6186,
34,
0
],
[
3.7037,
404,
34.4062,
34,
0
],
[
38.1099,
404,
34.8157,
34,
0
],
[
72.9256,
404,
36.6097,
34,
0
],
[
109.5353,
404,
36.6186,
34,
0
],
[
3.7037,
437,
34.4062,
33,
0
],
[
38.1099,
437,
34.8157,
33,
0
],
[
72.9256,
437,
36.6097,
33,
0
],
[
109.5353,
437,
36.6186,
33,
0
]
]
},
"768": {
"name": "offer-detail",
"viewportWidth": 736,
"width": 736,
"height": 416,
"bones": [
[
0,
0,
5.9783,
44,
8
],
[
38.4829,
7,
19.8412,
26,
8
],
[
90.8267,
0,
9.1733,
44,
8
],
[
0,
60,
100,
356,
10,
true
],
[
2.5815,
79,
46.3315,
21,
8
],
[
2.5815,
108,
46.3315,
46,
8
],
[
51.087,
79,
46.3315,
21,
8
],
[
51.087,
108,
46.3315,
46,
8
],
[
2.5815,
169,
46.3315,
21,
8
],
[
2.5815,
198,
46.3315,
46,
8
],
[
51.087,
169,
46.3315,
21,
8
],
[
51.087,
198,
46.3315,
46,
8
],
[
2.5815,
260,
22.8558,
33,
0
],
[
25.4373,
260,
23.4333,
33,
0
],
[
48.8706,
260,
24.2718,
33,
0
],
[
73.1424,
260,
24.2761,
33,
0
],
[
2.5815,
292,
22.8558,
35,
0
],
[
25.4373,
292,
23.4333,
35,
0
],
[
48.8706,
292,
24.2718,
35,
0
],
[
73.1424,
292,
24.2761,
35,
0
],
[
2.5815,
327,
22.8558,
35,
0
],
[
25.4373,
327,
23.4333,
35,
0
],
[
48.8706,
327,
24.2718,
35,
0
],
[
73.1424,
327,
24.2761,
35,
0
],
[
2.5815,
362,
22.8558,
35,
0
],
[
25.4373,
362,
23.4333,
35,
0
],
[
48.8706,
362,
24.2718,
35,
0
],
[
73.1424,
362,
24.2761,
35,
0
]
]
},
"1280": {
"name": "offer-detail",
"viewportWidth": 996,
"width": 996,
"height": 419,
"bones": [
[
0,
0,
3.2129,
32,
8
],
[
41.0878,
1,
14.6618,
26,
8
],
[
93.6229,
0,
6.3771,
32,
8
],
[
0,
48,
100,
371,
10,
true
],
[
1.9076,
67,
47.2892,
19,
8
],
[
1.9076,
94,
47.2892,
41,
8
],
[
50.8032,
67,
47.2892,
19,
8
],
[
50.8032,
94,
47.2892,
41,
8
],
[
1.9076,
151,
47.2892,
19,
8
],
[
1.9076,
178,
47.2892,
41,
8
],
[
50.8032,
151,
47.2892,
19,
8
],
[
50.8032,
178,
47.2892,
41,
8
],
[
1.9076,
235,
23.2194,
38,
0
],
[
25.1271,
235,
23.9787,
38,
0
],
[
49.1058,
235,
24.4917,
38,
0
],
[
73.5975,
235,
24.4949,
38,
0
],
[
1.9076,
273,
23.2194,
43,
0
],
[
25.1271,
273,
23.9787,
43,
0
],
[
49.1058,
273,
24.4917,
43,
0
],
[
73.5975,
273,
24.4949,
43,
0
],
[
1.9076,
316,
23.2194,
43,
0
],
[
25.1271,
316,
23.9787,
43,
0
],
[
49.1058,
316,
24.4917,
43,
0
],
[
73.5975,
316,
24.4949,
43,
0
],
[
1.9076,
358,
23.2194,
42,
0
],
[
25.1271,
358,
23.9787,
42,
0
],
[
49.1058,
358,
24.4917,
42,
0
],
[
73.5975,
358,
24.4949,
42,
0
]
]
}
},
"_hash": "67676fde7dd5c432922d819fc9bf48db"
}

View File

@@ -1,641 +0,0 @@
{
"breakpoints": {
"375": {
"name": "offers-customers",
"viewportWidth": 351,
"width": 351,
"height": 549,
"bones": [
[
0,
0,
100,
22,
8
],
[
0,
26,
100,
19,
8
],
[
0,
53,
100,
44,
8
],
[
0,
113,
100,
436,
10,
true
],
[
3.7037,
126,
92.5926,
44,
8
],
[
3.7037,
186,
34.562,
31,
0
],
[
38.2657,
186,
23.7936,
31,
0
],
[
62.0593,
186,
32.4653,
31,
0
],
[
94.5246,
186,
51.6293,
31,
0
],
[
3.7037,
217,
34.562,
61,
0
],
[
38.2657,
217,
23.7936,
61,
0
],
[
62.0593,
217,
32.4653,
61,
0
],
[
94.5246,
217,
51.6293,
61,
0
],
[
3.7037,
278,
34.562,
61,
0
],
[
38.2657,
278,
23.7936,
61,
0
],
[
62.0593,
278,
32.4653,
61,
0
],
[
94.5246,
278,
51.6293,
61,
0
],
[
3.7037,
339,
34.562,
61,
0
],
[
38.2657,
339,
23.7936,
61,
0
],
[
62.0593,
339,
32.4653,
61,
0
],
[
94.5246,
339,
51.6293,
61,
0
],
[
3.7037,
400,
34.562,
61,
0
],
[
38.2657,
400,
23.7936,
61,
0
],
[
62.0593,
400,
32.4653,
61,
0
],
[
94.5246,
400,
51.6293,
61,
0
],
[
3.7037,
461,
34.562,
61,
0
],
[
38.2657,
461,
23.7936,
61,
0
],
[
62.0593,
461,
32.4653,
61,
0
],
[
94.5246,
461,
51.6293,
61,
0
]
]
},
"768": {
"name": "offers-customers",
"viewportWidth": 736,
"width": 736,
"height": 502,
"bones": [
[
0,
0,
12.9458,
26,
8
],
[
0,
30,
12.9458,
21,
8
],
[
80.6322,
4,
19.3678,
44,
8
],
[
0,
67,
100,
435,
10,
true
],
[
2.5815,
86,
94.837,
44,
8
],
[
2.5815,
146,
23.2868,
33,
0
],
[
25.8683,
146,
16.453,
33,
0
],
[
42.3212,
146,
21.9175,
33,
0
],
[
64.2387,
146,
33.1798,
33,
0
],
[
2.5815,
179,
23.2868,
61,
0
],
[
25.8683,
179,
16.453,
61,
0
],
[
42.3212,
179,
21.9175,
61,
0
],
[
64.2387,
179,
33.1798,
61,
0
],
[
2.5815,
240,
23.2868,
61,
0
],
[
25.8683,
240,
16.453,
61,
0
],
[
42.3212,
240,
21.9175,
61,
0
],
[
64.2387,
240,
33.1798,
61,
0
],
[
2.5815,
301,
23.2868,
61,
0
],
[
25.8683,
301,
16.453,
61,
0
],
[
42.3212,
301,
21.9175,
61,
0
],
[
64.2387,
301,
33.1798,
61,
0
],
[
2.5815,
362,
23.2868,
61,
0
],
[
25.8683,
362,
16.453,
61,
0
],
[
42.3212,
362,
21.9175,
61,
0
],
[
64.2387,
362,
33.1798,
61,
0
],
[
2.5815,
423,
23.2868,
61,
0
],
[
25.8683,
423,
16.453,
61,
0
],
[
42.3212,
423,
21.9175,
61,
0
],
[
64.2387,
423,
33.1798,
61,
0
]
]
},
"1280": {
"name": "offers-customers",
"viewportWidth": 996,
"width": 996,
"height": 470,
"bones": [
[
0,
0,
9.5664,
26,
8
],
[
0,
30,
9.5664,
21,
8
],
[
86.0897,
10,
13.9103,
32,
8
],
[
0,
67,
100,
403,
10,
true
],
[
1.9076,
86,
96.1847,
36,
8
],
[
1.9076,
138,
25.673,
38,
0
],
[
27.5806,
138,
19.0904,
38,
0
],
[
46.6711,
138,
24.3254,
38,
0
],
[
70.9965,
138,
27.0959,
38,
0
],
[
1.9076,
176,
25.673,
55,
0
],
[
27.5806,
176,
19.0904,
55,
0
],
[
46.6711,
176,
24.3254,
55,
0
],
[
70.9965,
176,
27.0959,
55,
0
],
[
1.9076,
231,
25.673,
55,
0
],
[
27.5806,
231,
19.0904,
55,
0
],
[
46.6711,
231,
24.3254,
55,
0
],
[
70.9965,
231,
27.0959,
55,
0
],
[
1.9076,
286,
25.673,
55,
0
],
[
27.5806,
286,
19.0904,
55,
0
],
[
46.6711,
286,
24.3254,
55,
0
],
[
70.9965,
286,
27.0959,
55,
0
],
[
1.9076,
341,
25.673,
55,
0
],
[
27.5806,
341,
19.0904,
55,
0
],
[
46.6711,
341,
24.3254,
55,
0
],
[
70.9965,
341,
27.0959,
55,
0
],
[
1.9076,
396,
25.673,
55,
0
],
[
27.5806,
396,
19.0904,
55,
0
],
[
46.6711,
396,
24.3254,
55,
0
],
[
70.9965,
396,
27.0959,
55,
0
]
]
}
},
"_hash": "63b2dec2b6ceb84d931a000ab8b669dd"
}

View File

@@ -1,452 +0,0 @@
{
"breakpoints": {
"375": {
"name": "offers-templates",
"viewportWidth": 351,
"width": 351,
"height": 436,
"bones": [
[
0,
0,
100,
436,
10,
true
],
[
3.7037,
13,
92.5926,
44,
8
],
[
3.7037,
73,
39.659,
31,
0
],
[
43.3627,
73,
39.6857,
31,
0
],
[
83.0484,
73,
63.1054,
31,
0
],
[
3.7037,
104,
39.659,
61,
0
],
[
43.3627,
104,
39.6857,
61,
0
],
[
83.0484,
104,
63.1054,
61,
0
],
[
3.7037,
165,
39.659,
61,
0
],
[
43.3627,
165,
39.6857,
61,
0
],
[
83.0484,
165,
63.1054,
61,
0
],
[
3.7037,
226,
39.659,
61,
0
],
[
43.3627,
226,
39.6857,
61,
0
],
[
83.0484,
226,
63.1054,
61,
0
],
[
3.7037,
287,
39.659,
61,
0
],
[
43.3627,
287,
39.6857,
61,
0
],
[
83.0484,
287,
63.1054,
61,
0
],
[
3.7037,
348,
39.659,
61,
0
],
[
43.3627,
348,
39.6857,
61,
0
],
[
83.0484,
348,
63.1054,
61,
0
]
]
},
"768": {
"name": "offers-templates",
"viewportWidth": 736,
"width": 736,
"height": 435,
"bones": [
[
0,
0,
100,
435,
10,
true
],
[
2.5815,
19,
94.837,
44,
8
],
[
2.5815,
79,
26.9744,
33,
0
],
[
29.5559,
79,
26.9977,
33,
0
],
[
56.5536,
79,
40.8649,
33,
0
],
[
2.5815,
112,
26.9744,
61,
0
],
[
29.5559,
112,
26.9977,
61,
0
],
[
56.5536,
112,
40.8649,
61,
0
],
[
2.5815,
173,
26.9744,
61,
0
],
[
29.5559,
173,
26.9977,
61,
0
],
[
56.5536,
173,
40.8649,
61,
0
],
[
2.5815,
234,
26.9744,
61,
0
],
[
29.5559,
234,
26.9977,
61,
0
],
[
56.5536,
234,
40.8649,
61,
0
],
[
2.5815,
295,
26.9744,
61,
0
],
[
29.5559,
295,
26.9977,
61,
0
],
[
56.5536,
295,
40.8649,
61,
0
],
[
2.5815,
356,
26.9744,
61,
0
],
[
29.5559,
356,
26.9977,
61,
0
],
[
56.5536,
356,
40.8649,
61,
0
]
]
},
"1280": {
"name": "offers-templates",
"viewportWidth": 996,
"width": 996,
"height": 403,
"bones": [
[
0,
0,
100,
403,
10,
true
],
[
1.9076,
19,
96.1847,
36,
8
],
[
1.9076,
71,
30.8719,
38,
0
],
[
32.7796,
71,
30.897,
38,
0
],
[
63.6766,
71,
34.4158,
38,
0
],
[
1.9076,
109,
30.8719,
55,
0
],
[
32.7796,
109,
30.897,
55,
0
],
[
63.6766,
109,
34.4158,
55,
0
],
[
1.9076,
164,
30.8719,
55,
0
],
[
32.7796,
164,
30.897,
55,
0
],
[
63.6766,
164,
34.4158,
55,
0
],
[
1.9076,
219,
30.8719,
55,
0
],
[
32.7796,
219,
30.897,
55,
0
],
[
63.6766,
219,
34.4158,
55,
0
],
[
1.9076,
274,
30.8719,
55,
0
],
[
32.7796,
274,
30.897,
55,
0
],
[
63.6766,
274,
34.4158,
55,
0
],
[
1.9076,
329,
30.8719,
55,
0
],
[
32.7796,
329,
30.897,
55,
0
],
[
63.6766,
329,
34.4158,
55,
0
]
]
}
},
"_hash": "5e5881859bd932a42345c69a6a30ca65"
}

View File

@@ -1,872 +0,0 @@
{
"breakpoints": {
"375": {
"name": "offers",
"viewportWidth": 351,
"width": 351,
"height": 497,
"bones": [
[
0,
0,
100,
22,
8
],
[
0,
26,
100,
19,
8
],
[
0,
61,
100,
436,
10,
true
],
[
3.7037,
74,
92.5926,
44,
8
],
[
3.7037,
134,
26.7495,
31,
0
],
[
30.4532,
134,
20.6019,
31,
0
],
[
51.055,
134,
21.4165,
31,
0
],
[
72.4715,
134,
23.0502,
31,
0
],
[
95.5217,
134,
23.0502,
31,
0
],
[
118.5719,
134,
30.7692,
31,
0
],
[
3.7037,
165,
26.7495,
61,
0
],
[
30.4532,
165,
20.6019,
61,
0
],
[
51.055,
165,
21.4165,
61,
0
],
[
72.4715,
165,
23.0502,
61,
0
],
[
95.5217,
165,
23.0502,
61,
0
],
[
118.5719,
165,
30.7692,
61,
0
],
[
3.7037,
226,
26.7495,
61,
0
],
[
30.4532,
226,
20.6019,
61,
0
],
[
51.055,
226,
21.4165,
61,
0
],
[
72.4715,
226,
23.0502,
61,
0
],
[
95.5217,
226,
23.0502,
61,
0
],
[
118.5719,
226,
30.7692,
61,
0
],
[
3.7037,
287,
26.7495,
61,
0
],
[
30.4532,
287,
20.6019,
61,
0
],
[
51.055,
287,
21.4165,
61,
0
],
[
72.4715,
287,
23.0502,
61,
0
],
[
95.5217,
287,
23.0502,
61,
0
],
[
118.5719,
287,
30.7692,
61,
0
],
[
3.7037,
348,
26.7495,
61,
0
],
[
30.4532,
348,
20.6019,
61,
0
],
[
51.055,
348,
21.4165,
61,
0
],
[
72.4715,
348,
23.0502,
61,
0
],
[
95.5217,
348,
23.0502,
61,
0
],
[
118.5719,
348,
30.7692,
61,
0
],
[
3.7037,
409,
26.7495,
61,
0
],
[
30.4532,
409,
20.6019,
61,
0
],
[
51.055,
409,
21.4165,
61,
0
],
[
72.4715,
409,
23.0502,
61,
0
],
[
95.5217,
409,
23.0502,
61,
0
],
[
118.5719,
409,
30.7692,
61,
0
]
]
},
"768": {
"name": "offers",
"viewportWidth": 736,
"width": 736,
"height": 502,
"bones": [
[
0,
0,
11.3451,
26,
8
],
[
0,
30,
11.3451,
21,
8
],
[
0,
67,
100,
435,
10,
true
],
[
2.5815,
86,
94.837,
44,
8
],
[
2.5815,
146,
17.6779,
33,
0
],
[
20.2594,
146,
13.7101,
33,
0
],
[
33.9695,
146,
13.3301,
33,
0
],
[
47.2996,
146,
15.2917,
33,
0
],
[
62.5913,
146,
15.2917,
33,
0
],
[
77.883,
146,
19.5355,
33,
0
],
[
2.5815,
179,
17.6779,
61,
0
],
[
20.2594,
179,
13.7101,
61,
0
],
[
33.9695,
179,
13.3301,
61,
0
],
[
47.2996,
179,
15.2917,
61,
0
],
[
62.5913,
179,
15.2917,
61,
0
],
[
77.883,
179,
19.5355,
61,
0
],
[
2.5815,
240,
17.6779,
61,
0
],
[
20.2594,
240,
13.7101,
61,
0
],
[
33.9695,
240,
13.3301,
61,
0
],
[
47.2996,
240,
15.2917,
61,
0
],
[
62.5913,
240,
15.2917,
61,
0
],
[
77.883,
240,
19.5355,
61,
0
],
[
2.5815,
301,
17.6779,
61,
0
],
[
20.2594,
301,
13.7101,
61,
0
],
[
33.9695,
301,
13.3301,
61,
0
],
[
47.2996,
301,
15.2917,
61,
0
],
[
62.5913,
301,
15.2917,
61,
0
],
[
77.883,
301,
19.5355,
61,
0
],
[
2.5815,
362,
17.6779,
61,
0
],
[
20.2594,
362,
13.7101,
61,
0
],
[
33.9695,
362,
13.3301,
61,
0
],
[
47.2996,
362,
15.2917,
61,
0
],
[
62.5913,
362,
15.2917,
61,
0
],
[
77.883,
362,
19.5355,
61,
0
],
[
2.5815,
423,
17.6779,
61,
0
],
[
20.2594,
423,
13.7101,
61,
0
],
[
33.9695,
423,
13.3301,
61,
0
],
[
47.2996,
423,
15.2917,
61,
0
],
[
62.5913,
423,
15.2917,
61,
0
],
[
77.883,
423,
19.5355,
61,
0
]
]
},
"1280": {
"name": "offers",
"viewportWidth": 996,
"width": 996,
"height": 470,
"bones": [
[
0,
0,
8.3835,
26,
8
],
[
0,
30,
8.3835,
21,
8
],
[
0,
67,
100,
403,
10,
true
],
[
1.9076,
86,
96.1847,
36,
8
],
[
1.9076,
138,
18.8912,
38,
0
],
[
20.7988,
138,
15.0085,
38,
0
],
[
35.8073,
138,
13.333,
38,
0
],
[
49.1403,
138,
16.5553,
38,
0
],
[
65.6956,
138,
16.5553,
38,
0
],
[
82.2509,
138,
15.8415,
38,
0
],
[
1.9076,
176,
18.8912,
55,
0
],
[
20.7988,
176,
15.0085,
55,
0
],
[
35.8073,
176,
13.333,
55,
0
],
[
49.1403,
176,
16.5553,
55,
0
],
[
65.6956,
176,
16.5553,
55,
0
],
[
82.2509,
176,
15.8415,
55,
0
],
[
1.9076,
231,
18.8912,
55,
0
],
[
20.7988,
231,
15.0085,
55,
0
],
[
35.8073,
231,
13.333,
55,
0
],
[
49.1403,
231,
16.5553,
55,
0
],
[
65.6956,
231,
16.5553,
55,
0
],
[
82.2509,
231,
15.8415,
55,
0
],
[
1.9076,
286,
18.8912,
55,
0
],
[
20.7988,
286,
15.0085,
55,
0
],
[
35.8073,
286,
13.333,
55,
0
],
[
49.1403,
286,
16.5553,
55,
0
],
[
65.6956,
286,
16.5553,
55,
0
],
[
82.2509,
286,
15.8415,
55,
0
],
[
1.9076,
341,
18.8912,
55,
0
],
[
20.7988,
341,
15.0085,
55,
0
],
[
35.8073,
341,
13.333,
55,
0
],
[
49.1403,
341,
16.5553,
55,
0
],
[
65.6956,
341,
16.5553,
55,
0
],
[
82.2509,
341,
15.8415,
55,
0
],
[
1.9076,
396,
18.8912,
55,
0
],
[
20.7988,
396,
15.0085,
55,
0
],
[
35.8073,
396,
13.333,
55,
0
],
[
49.1403,
396,
16.5553,
55,
0
],
[
65.6956,
396,
16.5553,
55,
0
],
[
82.2509,
396,
15.8415,
55,
0
]
]
}
},
"_hash": "62d793eb0343d832087d687b76639e09"
}

View File

@@ -1,998 +0,0 @@
{
"breakpoints": {
"375": {
"name": "orders",
"viewportWidth": 351,
"width": 351,
"height": 497,
"bones": [
[
0,
0,
100,
22,
8
],
[
0,
26,
100,
19,
8
],
[
0,
61,
100,
436,
10,
true
],
[
3.7037,
74,
92.5926,
44,
8
],
[
3.7037,
134,
26.7495,
31,
0
],
[
30.4532,
134,
29.1043,
31,
0
],
[
59.5575,
134,
20.6019,
31,
0
],
[
80.1594,
134,
26.6515,
31,
0
],
[
106.8109,
134,
23.0502,
31,
0
],
[
129.8611,
134,
21.2028,
31,
0
],
[
151.0639,
134,
17.094,
31,
0
],
[
3.7037,
165,
26.7495,
61,
0
],
[
30.4532,
165,
29.1043,
61,
0
],
[
59.5575,
165,
20.6019,
61,
0
],
[
80.1594,
165,
26.6515,
61,
0
],
[
106.8109,
165,
23.0502,
61,
0
],
[
129.8611,
165,
21.2028,
61,
0
],
[
151.0639,
165,
17.094,
61,
0
],
[
3.7037,
226,
26.7495,
61,
0
],
[
30.4532,
226,
29.1043,
61,
0
],
[
59.5575,
226,
20.6019,
61,
0
],
[
80.1594,
226,
26.6515,
61,
0
],
[
106.8109,
226,
23.0502,
61,
0
],
[
129.8611,
226,
21.2028,
61,
0
],
[
151.0639,
226,
17.094,
61,
0
],
[
3.7037,
287,
26.7495,
61,
0
],
[
30.4532,
287,
29.1043,
61,
0
],
[
59.5575,
287,
20.6019,
61,
0
],
[
80.1594,
287,
26.6515,
61,
0
],
[
106.8109,
287,
23.0502,
61,
0
],
[
129.8611,
287,
21.2028,
61,
0
],
[
151.0639,
287,
17.094,
61,
0
],
[
3.7037,
348,
26.7495,
61,
0
],
[
30.4532,
348,
29.1043,
61,
0
],
[
59.5575,
348,
20.6019,
61,
0
],
[
80.1594,
348,
26.6515,
61,
0
],
[
106.8109,
348,
23.0502,
61,
0
],
[
129.8611,
348,
21.2028,
61,
0
],
[
151.0639,
348,
17.094,
61,
0
],
[
3.7037,
409,
26.7495,
61,
0
],
[
30.4532,
409,
29.1043,
61,
0
],
[
59.5575,
409,
20.6019,
61,
0
],
[
80.1594,
409,
26.6515,
61,
0
],
[
106.8109,
409,
23.0502,
61,
0
],
[
129.8611,
409,
21.2028,
61,
0
],
[
151.0639,
409,
17.094,
61,
0
]
]
},
"768": {
"name": "orders",
"viewportWidth": 736,
"width": 736,
"height": 502,
"bones": [
[
0,
0,
16.6461,
26,
8
],
[
0,
30,
16.6461,
21,
8
],
[
0,
67,
100,
435,
10,
true
],
[
2.5815,
86,
94.837,
44,
8
],
[
2.5815,
146,
15.642,
33,
0
],
[
18.2235,
146,
16.9837,
33,
0
],
[
35.2072,
146,
12.1306,
33,
0
],
[
47.3378,
146,
14.5338,
33,
0
],
[
61.8716,
146,
13.5296,
33,
0
],
[
75.4012,
146,
12.4745,
33,
0
],
[
87.8758,
146,
9.5427,
33,
0
],
[
2.5815,
179,
15.642,
61,
0
],
[
18.2235,
179,
16.9837,
61,
0
],
[
35.2072,
179,
12.1306,
61,
0
],
[
47.3378,
179,
14.5338,
61,
0
],
[
61.8716,
179,
13.5296,
61,
0
],
[
75.4012,
179,
12.4745,
61,
0
],
[
87.8758,
179,
9.5427,
61,
0
],
[
2.5815,
240,
15.642,
61,
0
],
[
18.2235,
240,
16.9837,
61,
0
],
[
35.2072,
240,
12.1306,
61,
0
],
[
47.3378,
240,
14.5338,
61,
0
],
[
61.8716,
240,
13.5296,
61,
0
],
[
75.4012,
240,
12.4745,
61,
0
],
[
87.8758,
240,
9.5427,
61,
0
],
[
2.5815,
301,
15.642,
61,
0
],
[
18.2235,
301,
16.9837,
61,
0
],
[
35.2072,
301,
12.1306,
61,
0
],
[
47.3378,
301,
14.5338,
61,
0
],
[
61.8716,
301,
13.5296,
61,
0
],
[
75.4012,
301,
12.4745,
61,
0
],
[
87.8758,
301,
9.5427,
61,
0
],
[
2.5815,
362,
15.642,
61,
0
],
[
18.2235,
362,
16.9837,
61,
0
],
[
35.2072,
362,
12.1306,
61,
0
],
[
47.3378,
362,
14.5338,
61,
0
],
[
61.8716,
362,
13.5296,
61,
0
],
[
75.4012,
362,
12.4745,
61,
0
],
[
87.8758,
362,
9.5427,
61,
0
],
[
2.5815,
423,
15.642,
61,
0
],
[
18.2235,
423,
16.9837,
61,
0
],
[
35.2072,
423,
12.1306,
61,
0
],
[
47.3378,
423,
14.5338,
61,
0
],
[
61.8716,
423,
13.5296,
61,
0
],
[
75.4012,
423,
12.4745,
61,
0
],
[
87.8758,
423,
9.5427,
61,
0
]
]
},
"1280": {
"name": "orders",
"viewportWidth": 996,
"width": 996,
"height": 470,
"bones": [
[
0,
0,
12.3008,
26,
8
],
[
0,
30,
12.3008,
21,
8
],
[
0,
67,
100,
403,
10,
true
],
[
1.9076,
86,
96.1847,
36,
8
],
[
1.9076,
138,
16.2243,
38,
0
],
[
18.1319,
138,
17.5044,
38,
0
],
[
35.6363,
138,
12.8891,
38,
0
],
[
48.5254,
138,
13.7535,
38,
0
],
[
62.2788,
138,
14.2178,
38,
0
],
[
76.4966,
138,
13.2138,
38,
0
],
[
89.7104,
138,
8.382,
38,
0
],
[
1.9076,
176,
16.2243,
55,
0
],
[
18.1319,
176,
17.5044,
55,
0
],
[
35.6363,
176,
12.8891,
55,
0
],
[
48.5254,
176,
13.7535,
55,
0
],
[
62.2788,
176,
14.2178,
55,
0
],
[
76.4966,
176,
13.2138,
55,
0
],
[
89.7104,
176,
8.382,
55,
0
],
[
1.9076,
231,
16.2243,
55,
0
],
[
18.1319,
231,
17.5044,
55,
0
],
[
35.6363,
231,
12.8891,
55,
0
],
[
48.5254,
231,
13.7535,
55,
0
],
[
62.2788,
231,
14.2178,
55,
0
],
[
76.4966,
231,
13.2138,
55,
0
],
[
89.7104,
231,
8.382,
55,
0
],
[
1.9076,
286,
16.2243,
55,
0
],
[
18.1319,
286,
17.5044,
55,
0
],
[
35.6363,
286,
12.8891,
55,
0
],
[
48.5254,
286,
13.7535,
55,
0
],
[
62.2788,
286,
14.2178,
55,
0
],
[
76.4966,
286,
13.2138,
55,
0
],
[
89.7104,
286,
8.382,
55,
0
],
[
1.9076,
341,
16.2243,
55,
0
],
[
18.1319,
341,
17.5044,
55,
0
],
[
35.6363,
341,
12.8891,
55,
0
],
[
48.5254,
341,
13.7535,
55,
0
],
[
62.2788,
341,
14.2178,
55,
0
],
[
76.4966,
341,
13.2138,
55,
0
],
[
89.7104,
341,
8.382,
55,
0
],
[
1.9076,
396,
16.2243,
55,
0
],
[
18.1319,
396,
17.5044,
55,
0
],
[
35.6363,
396,
12.8891,
55,
0
],
[
48.5254,
396,
13.7535,
55,
0
],
[
62.2788,
396,
14.2178,
55,
0
],
[
76.4966,
396,
13.2138,
55,
0
],
[
89.7104,
396,
8.382,
55,
0
]
]
}
},
"_hash": "677a0002aa805c9f7790bc68c6374bb5"
}

View File

@@ -1,371 +0,0 @@
{
"breakpoints": {
"375": {
"name": "project-detail",
"viewportWidth": 351,
"width": 351,
"height": 481,
"bones": [
[
3.4188,
12,
5.698,
20,
"50%"
],
[
14.8148,
9,
50.2315,
22,
8
],
[
0,
52,
48.0057,
44,
8
],
[
51.4245,
52,
48.5755,
44,
8
],
[
0,
112,
100,
188,
10,
true
],
[
3.7037,
125,
48.1481,
21,
8
],
[
3.7037,
154,
48.1481,
44,
8
],
[
56.4103,
125,
48.1481,
21,
8
],
[
56.4103,
154,
48.1481,
44,
8
],
[
3.7037,
214,
48.1481,
21,
8
],
[
3.7037,
243,
48.1481,
44,
8
],
[
56.4103,
214,
48.1481,
21,
8
],
[
56.4103,
243,
48.1481,
44,
8
],
[
0,
316,
100,
165,
10,
true
],
[
3.7037,
329,
92.5926,
17,
8
],
[
3.7037,
357,
92.5926,
104,
8
]
]
},
"768": {
"name": "project-detail",
"viewportWidth": 736,
"width": 736,
"height": 453,
"bones": [
[
1.6304,
12,
2.7174,
20,
"50%"
],
[
7.0652,
7,
29.2799,
26,
8
],
[
78.2375,
0,
9.1733,
44,
8
],
[
89.0413,
0,
10.9587,
44,
8
],
[
0,
60,
100,
200,
10,
true
],
[
2.5815,
79,
46.3315,
21,
8
],
[
2.5815,
108,
46.3315,
44,
8
],
[
51.087,
79,
46.3315,
21,
8
],
[
51.087,
108,
46.3315,
44,
8
],
[
2.5815,
168,
46.3315,
21,
8
],
[
2.5815,
197,
46.3315,
44,
8
],
[
51.087,
168,
46.3315,
21,
8
],
[
51.087,
197,
46.3315,
44,
8
],
[
0,
276,
100,
177,
10,
true
],
[
2.5815,
295,
94.837,
17,
8
],
[
2.5815,
323,
94.837,
104,
8
]
]
},
"1280": {
"name": "project-detail",
"viewportWidth": 996,
"width": 996,
"height": 404,
"bones": [
[
0.6024,
7,
2.008,
20,
"50%"
],
[
4.0161,
2,
21.6365,
26,
8
],
[
84.7217,
0,
6.3771,
34,
8
],
[
92.3036,
0,
7.6964,
34,
8
],
[
0,
50,
100,
180,
10,
true
],
[
1.9076,
69,
47.2892,
19,
8
],
[
1.9076,
96,
47.2892,
36,
8
],
[
50.8032,
69,
47.2892,
19,
8
],
[
50.8032,
96,
47.2892,
36,
8
],
[
1.9076,
148,
47.2892,
19,
8
],
[
1.9076,
175,
47.2892,
36,
8
],
[
50.8032,
148,
47.2892,
19,
8
],
[
50.8032,
175,
47.2892,
36,
8
],
[
0,
246,
100,
157,
10,
true
],
[
1.9076,
265,
96.1847,
17,
8
],
[
1.9076,
294,
96.1847,
84,
8
]
]
}
},
"_hash": "ab5e1f108d42c55b0e6382fcaffff793"
}

View File

@@ -1,746 +0,0 @@
{
"breakpoints": {
"375": {
"name": "projects",
"viewportWidth": 351,
"width": 351,
"height": 497,
"bones": [
[
0,
0,
100,
22,
8
],
[
0,
26,
100,
19,
8
],
[
0,
61,
100,
436,
10,
true
],
[
3.7037,
74,
92.5926,
44,
8
],
[
3.7037,
134,
34.6065,
31,
0
],
[
38.3102,
134,
31.3568,
31,
0
],
[
69.667,
134,
26.6515,
31,
0
],
[
96.3186,
134,
27.7066,
31,
0
],
[
124.0251,
134,
22.1287,
31,
0
],
[
3.7037,
165,
34.6065,
61,
0
],
[
38.3102,
165,
31.3568,
61,
0
],
[
69.667,
165,
26.6515,
61,
0
],
[
96.3186,
165,
27.7066,
61,
0
],
[
124.0251,
165,
22.1287,
61,
0
],
[
3.7037,
226,
34.6065,
61,
0
],
[
38.3102,
226,
31.3568,
61,
0
],
[
69.667,
226,
26.6515,
61,
0
],
[
96.3186,
226,
27.7066,
61,
0
],
[
124.0251,
226,
22.1287,
61,
0
],
[
3.7037,
287,
34.6065,
61,
0
],
[
38.3102,
287,
31.3568,
61,
0
],
[
69.667,
287,
26.6515,
61,
0
],
[
96.3186,
287,
27.7066,
61,
0
],
[
124.0251,
287,
22.1287,
61,
0
],
[
3.7037,
348,
34.6065,
61,
0
],
[
38.3102,
348,
31.3568,
61,
0
],
[
69.667,
348,
26.6515,
61,
0
],
[
96.3186,
348,
27.7066,
61,
0
],
[
124.0251,
348,
22.1287,
61,
0
],
[
3.7037,
409,
34.6065,
61,
0
],
[
38.3102,
409,
31.3568,
61,
0
],
[
69.667,
409,
26.6515,
61,
0
],
[
96.3186,
409,
27.7066,
61,
0
],
[
124.0251,
409,
22.1287,
61,
0
]
]
},
"768": {
"name": "projects",
"viewportWidth": 736,
"width": 736,
"height": 502,
"bones": [
[
0,
0,
10.9099,
26,
8
],
[
0,
30,
10.9099,
21,
8
],
[
0,
67,
100,
435,
10,
true
],
[
2.5815,
86,
94.837,
44,
8
],
[
2.5815,
146,
23.429,
33,
0
],
[
26.0105,
146,
21.2806,
33,
0
],
[
47.2911,
146,
18.1704,
33,
0
],
[
65.4615,
146,
17.6694,
33,
0
],
[
83.1309,
146,
14.2875,
33,
0
],
[
2.5815,
179,
23.429,
61,
0
],
[
26.0105,
179,
21.2806,
61,
0
],
[
47.2911,
179,
18.1704,
61,
0
],
[
65.4615,
179,
17.6694,
61,
0
],
[
83.1309,
179,
14.2875,
61,
0
],
[
2.5815,
240,
23.429,
61,
0
],
[
26.0105,
240,
21.2806,
61,
0
],
[
47.2911,
240,
18.1704,
61,
0
],
[
65.4615,
240,
17.6694,
61,
0
],
[
83.1309,
240,
14.2875,
61,
0
],
[
2.5815,
301,
23.429,
61,
0
],
[
26.0105,
301,
21.2806,
61,
0
],
[
47.2911,
301,
18.1704,
61,
0
],
[
65.4615,
301,
17.6694,
61,
0
],
[
83.1309,
301,
14.2875,
61,
0
],
[
2.5815,
362,
23.429,
61,
0
],
[
26.0105,
362,
21.2806,
61,
0
],
[
47.2911,
362,
18.1704,
61,
0
],
[
65.4615,
362,
17.6694,
61,
0
],
[
83.1309,
362,
14.2875,
61,
0
],
[
2.5815,
423,
23.429,
61,
0
],
[
26.0105,
423,
21.2806,
61,
0
],
[
47.2911,
423,
18.1704,
61,
0
],
[
65.4615,
423,
17.6694,
61,
0
],
[
83.1309,
423,
14.2875,
61,
0
]
]
},
"1280": {
"name": "projects",
"viewportWidth": 996,
"width": 996,
"height": 470,
"bones": [
[
0,
0,
8.0619,
26,
8
],
[
0,
30,
8.0619,
21,
8
],
[
0,
67,
100,
403,
10,
true
],
[
1.9076,
86,
96.1847,
36,
8
],
[
1.9076,
138,
24.4588,
38,
0
],
[
26.3664,
138,
22.4068,
38,
0
],
[
48.7732,
138,
19.4308,
38,
0
],
[
68.2041,
138,
17.2612,
38,
0
],
[
85.4653,
138,
12.6271,
38,
0
],
[
1.9076,
176,
24.4588,
55,
0
],
[
26.3664,
176,
22.4068,
55,
0
],
[
48.7732,
176,
19.4308,
55,
0
],
[
68.2041,
176,
17.2612,
55,
0
],
[
85.4653,
176,
12.6271,
55,
0
],
[
1.9076,
231,
24.4588,
55,
0
],
[
26.3664,
231,
22.4068,
55,
0
],
[
48.7732,
231,
19.4308,
55,
0
],
[
68.2041,
231,
17.2612,
55,
0
],
[
85.4653,
231,
12.6271,
55,
0
],
[
1.9076,
286,
24.4588,
55,
0
],
[
26.3664,
286,
22.4068,
55,
0
],
[
48.7732,
286,
19.4308,
55,
0
],
[
68.2041,
286,
17.2612,
55,
0
],
[
85.4653,
286,
12.6271,
55,
0
],
[
1.9076,
341,
24.4588,
55,
0
],
[
26.3664,
341,
22.4068,
55,
0
],
[
48.7732,
341,
19.4308,
55,
0
],
[
68.2041,
341,
17.2612,
55,
0
],
[
85.4653,
341,
12.6271,
55,
0
],
[
1.9076,
396,
24.4588,
55,
0
],
[
26.3664,
396,
22.4068,
55,
0
],
[
48.7732,
396,
19.4308,
55,
0
],
[
68.2041,
396,
17.2612,
55,
0
],
[
85.4653,
396,
12.6271,
55,
0
]
]
}
},
"_hash": "17f8285c3ca514ddef6d48c1183ed642"
}

View File

@@ -1,50 +0,0 @@
"use client"
// Auto-generated by `npx boneyard-js build` — do not edit
import { registerBones } from 'boneyard-js'
import { configureBoneyard } from 'boneyard-js/react'
import _dash_sessions from './dash-sessions.bones.json'
import _attendance_history_fund from './attendance-history-fund.bones.json'
import _attendance_history_table from './attendance-history-table.bones.json'
import _leave_requests from './leave-requests.bones.json'
import _leave_approval from './leave-approval.bones.json'
import _attendance_balances from './attendance-balances.bones.json'
import _trips_history from './trips-history.bones.json'
import _trips_admin from './trips-admin.bones.json'
import _vehicles from './vehicles.bones.json'
import _offers from './offers.bones.json'
import _orders from './orders.bones.json'
import _projects from './projects.bones.json'
import _offers_customers from './offers-customers.bones.json'
import _users from './users.bones.json'
import _audit_log_rows from './audit-log-rows.bones.json'
import _offer_detail from './offer-detail.bones.json'
import _invoice_detail from './invoice-detail.bones.json'
import _project_detail from './project-detail.bones.json'
import _attendance_create from './attendance-create.bones.json'
import _offers_templates from './offers-templates.bones.json'
configureBoneyard({"color":"#e0e0e0","animate":"shimmer","shimmerColor":"#f0f0f0","speed":"1.2s","shimmerAngle":110})
registerBones({
"dash-sessions": _dash_sessions,
"attendance-history-fund": _attendance_history_fund,
"attendance-history-table": _attendance_history_table,
"leave-requests": _leave_requests,
"leave-approval": _leave_approval,
"attendance-balances": _attendance_balances,
"trips-history": _trips_history,
"trips-admin": _trips_admin,
"vehicles": _vehicles,
"offers": _offers,
"orders": _orders,
"projects": _projects,
"offers-customers": _offers_customers,
"users": _users,
"audit-log-rows": _audit_log_rows,
"offer-detail": _offer_detail,
"invoice-detail": _invoice_detail,
"project-detail": _project_detail,
"attendance-create": _attendance_create,
"offers-templates": _offers_templates,
})

View File

@@ -1,725 +0,0 @@
{
"breakpoints": {
"375": {
"name": "trips-admin",
"viewportWidth": 317,
"width": 317,
"height": 437,
"bones": [
[
0,
0,
100,
22,
8
],
[
0,
26,
100,
19,
8
],
[
0,
61,
100,
376,
10,
true
],
[
4.1009,
74,
37.8795,
31,
0
],
[
41.9805,
74,
43.4592,
31,
0
],
[
85.4397,
74,
31.6739,
31,
0
],
[
117.1136,
74,
16.6108,
31,
0
],
[
133.7244,
74,
28.1053,
31,
0
],
[
4.1009,
105,
37.8795,
61,
0
],
[
41.9805,
105,
43.4592,
61,
0
],
[
85.4397,
105,
31.6739,
61,
0
],
[
117.1136,
105,
16.6108,
61,
0
],
[
133.7244,
105,
28.1053,
61,
0
],
[
4.1009,
166,
37.8795,
61,
0
],
[
41.9805,
166,
43.4592,
61,
0
],
[
85.4397,
166,
31.6739,
61,
0
],
[
117.1136,
166,
16.6108,
61,
0
],
[
133.7244,
166,
28.1053,
61,
0
],
[
4.1009,
227,
37.8795,
61,
0
],
[
41.9805,
227,
43.4592,
61,
0
],
[
85.4397,
227,
31.6739,
61,
0
],
[
117.1136,
227,
16.6108,
61,
0
],
[
133.7244,
227,
28.1053,
61,
0
],
[
4.1009,
288,
37.8795,
61,
0
],
[
41.9805,
288,
43.4592,
61,
0
],
[
85.4397,
288,
31.6739,
61,
0
],
[
117.1136,
288,
16.6108,
61,
0
],
[
133.7244,
288,
28.1053,
61,
0
],
[
4.1009,
349,
37.8795,
61,
0
],
[
41.9805,
349,
43.4592,
61,
0
],
[
85.4397,
349,
31.6739,
61,
0
],
[
117.1136,
349,
16.6108,
61,
0
],
[
133.7244,
349,
28.1053,
61,
0
]
]
},
"768": {
"name": "trips-admin",
"viewportWidth": 690,
"width": 690,
"height": 442,
"bones": [
[
0,
0,
16.3202,
26,
8
],
[
0,
30,
16.3202,
21,
8
],
[
0,
67,
100,
375,
10,
true
],
[
2.7536,
86,
22.8057,
33,
0
],
[
25.5593,
86,
26.0711,
33,
0
],
[
51.6304,
86,
19.1757,
33,
0
],
[
70.8062,
86,
10.3601,
33,
0
],
[
81.1662,
86,
16.0802,
33,
0
],
[
2.7536,
119,
22.8057,
61,
0
],
[
25.5593,
119,
26.0711,
61,
0
],
[
51.6304,
119,
19.1757,
61,
0
],
[
70.8062,
119,
10.3601,
61,
0
],
[
81.1662,
119,
16.0802,
61,
0
],
[
2.7536,
180,
22.8057,
61,
0
],
[
25.5593,
180,
26.0711,
61,
0
],
[
51.6304,
180,
19.1757,
61,
0
],
[
70.8062,
180,
10.3601,
61,
0
],
[
81.1662,
180,
16.0802,
61,
0
],
[
2.7536,
241,
22.8057,
61,
0
],
[
25.5593,
241,
26.0711,
61,
0
],
[
51.6304,
241,
19.1757,
61,
0
],
[
70.8062,
241,
10.3601,
61,
0
],
[
81.1662,
241,
16.0802,
61,
0
],
[
2.7536,
302,
22.8057,
61,
0
],
[
25.5593,
302,
26.0711,
61,
0
],
[
51.6304,
302,
19.1757,
61,
0
],
[
70.8062,
302,
10.3601,
61,
0
],
[
81.1662,
302,
16.0802,
61,
0
],
[
2.7536,
363,
22.8057,
61,
0
],
[
25.5593,
363,
26.0711,
61,
0
],
[
51.6304,
363,
19.1757,
61,
0
],
[
70.8062,
363,
10.3601,
61,
0
],
[
81.1662,
363,
16.0802,
61,
0
]
]
},
"1280": {
"name": "trips-admin",
"viewportWidth": 950,
"width": 950,
"height": 418,
"bones": [
[
0,
0,
11.8536,
26,
8
],
[
0,
30,
11.8536,
21,
8
],
[
0,
67,
100,
351,
10,
true
],
[
2,
86,
23.523,
38,
0
],
[
25.523,
86,
26.574,
38,
0
],
[
52.097,
86,
20.1382,
38,
0
],
[
72.2352,
86,
11.9046,
38,
0
],
[
84.1398,
86,
13.8602,
38,
0
],
[
2,
124,
23.523,
55,
0
],
[
25.523,
124,
26.574,
55,
0
],
[
52.097,
124,
20.1382,
55,
0
],
[
72.2352,
124,
11.9046,
55,
0
],
[
84.1398,
124,
13.8602,
55,
0
],
[
2,
179,
23.523,
55,
0
],
[
25.523,
179,
26.574,
55,
0
],
[
52.097,
179,
20.1382,
55,
0
],
[
72.2352,
179,
11.9046,
55,
0
],
[
84.1398,
179,
13.8602,
55,
0
],
[
2,
234,
23.523,
55,
0
],
[
25.523,
234,
26.574,
55,
0
],
[
52.097,
234,
20.1382,
55,
0
],
[
72.2352,
234,
11.9046,
55,
0
],
[
84.1398,
234,
13.8602,
55,
0
],
[
2,
289,
23.523,
55,
0
],
[
25.523,
289,
26.574,
55,
0
],
[
52.097,
289,
20.1382,
55,
0
],
[
72.2352,
289,
11.9046,
55,
0
],
[
84.1398,
289,
13.8602,
55,
0
],
[
2,
344,
23.523,
55,
0
],
[
25.523,
344,
26.574,
55,
0
],
[
52.097,
344,
20.1382,
55,
0
],
[
72.2352,
344,
11.9046,
55,
0
],
[
84.1398,
344,
13.8602,
55,
0
]
]
}
},
"_hash": "39a325f430c84bb51960a684759a8f0c"
}

View File

@@ -1,725 +0,0 @@
{
"breakpoints": {
"375": {
"name": "trips-history",
"viewportWidth": 317,
"width": 317,
"height": 300,
"bones": [
[
0,
0,
100,
22,
8
],
[
0,
26,
100,
19,
8
],
[
0,
61,
100,
239,
10,
true
],
[
4.1009,
74,
35.2326,
31,
0
],
[
39.3336,
74,
40.4278,
31,
0
],
[
79.7614,
74,
29.4657,
31,
0
],
[
109.2271,
74,
37.1402,
31,
0
],
[
146.3673,
74,
15.4623,
31,
0
],
[
4.1009,
105,
35.2326,
34,
0
],
[
39.3336,
105,
40.4278,
34,
0
],
[
79.7614,
105,
29.4657,
34,
0
],
[
109.2271,
105,
37.1402,
34,
0
],
[
146.3673,
105,
15.4623,
34,
0
],
[
4.1009,
138,
35.2326,
34,
0
],
[
39.3336,
138,
40.4278,
34,
0
],
[
79.7614,
138,
29.4657,
34,
0
],
[
109.2271,
138,
37.1402,
34,
0
],
[
146.3673,
138,
15.4623,
34,
0
],
[
4.1009,
172,
35.2326,
34,
0
],
[
39.3336,
172,
40.4278,
34,
0
],
[
79.7614,
172,
29.4657,
34,
0
],
[
109.2271,
172,
37.1402,
34,
0
],
[
146.3673,
172,
15.4623,
34,
0
],
[
4.1009,
205,
35.2326,
34,
0
],
[
39.3336,
205,
40.4278,
34,
0
],
[
79.7614,
205,
29.4657,
34,
0
],
[
109.2271,
205,
37.1402,
34,
0
],
[
146.3673,
205,
15.4623,
34,
0
],
[
4.1009,
239,
35.2326,
33,
0
],
[
39.3336,
239,
40.4278,
33,
0
],
[
79.7614,
239,
29.4657,
33,
0
],
[
109.2271,
239,
37.1402,
33,
0
],
[
146.3673,
239,
15.4623,
33,
0
]
]
},
"768": {
"name": "trips-history",
"viewportWidth": 690,
"width": 690,
"height": 312,
"bones": [
[
0,
0,
16.6033,
26,
8
],
[
0,
30,
16.6033,
21,
8
],
[
0,
67,
100,
245,
10,
true
],
[
2.7536,
86,
21.0417,
33,
0
],
[
23.7953,
86,
24.0534,
33,
0
],
[
47.8487,
86,
17.6925,
33,
0
],
[
65.5412,
86,
22.1445,
33,
0
],
[
87.6857,
86,
9.5607,
33,
0
],
[
2.7536,
119,
21.0417,
35,
0
],
[
23.7953,
119,
24.0534,
35,
0
],
[
47.8487,
119,
17.6925,
35,
0
],
[
65.5412,
119,
22.1445,
35,
0
],
[
87.6857,
119,
9.5607,
35,
0
],
[
2.7536,
154,
21.0417,
35,
0
],
[
23.7953,
154,
24.0534,
35,
0
],
[
47.8487,
154,
17.6925,
35,
0
],
[
65.5412,
154,
22.1445,
35,
0
],
[
87.6857,
154,
9.5607,
35,
0
],
[
2.7536,
189,
21.0417,
35,
0
],
[
23.7953,
189,
24.0534,
35,
0
],
[
47.8487,
189,
17.6925,
35,
0
],
[
65.5412,
189,
22.1445,
35,
0
],
[
87.6857,
189,
9.5607,
35,
0
],
[
2.7536,
224,
21.0417,
35,
0
],
[
23.7953,
224,
24.0534,
35,
0
],
[
47.8487,
224,
17.6925,
35,
0
],
[
65.5412,
224,
22.1445,
35,
0
],
[
87.6857,
224,
9.5607,
35,
0
],
[
2.7536,
259,
21.0417,
35,
0
],
[
23.7953,
259,
24.0534,
35,
0
],
[
47.8487,
259,
17.6925,
35,
0
],
[
65.5412,
259,
22.1445,
35,
0
],
[
87.6857,
259,
9.5607,
35,
0
]
]
},
"1280": {
"name": "trips-history",
"viewportWidth": 958,
"width": 958,
"height": 355,
"bones": [
[
0,
0,
11.9585,
26,
8
],
[
0,
30,
11.9585,
21,
8
],
[
0,
67,
100,
288,
10,
true
],
[
1.9833,
86,
21.1541,
38,
0
],
[
23.1374,
86,
23.8974,
38,
0
],
[
47.0348,
86,
18.1106,
38,
0
],
[
65.1455,
86,
22.1604,
38,
0
],
[
87.3059,
86,
10.7108,
38,
0
],
[
1.9833,
124,
21.1541,
43,
0
],
[
23.1374,
124,
23.8974,
43,
0
],
[
47.0348,
124,
18.1106,
43,
0
],
[
65.1455,
124,
22.1604,
43,
0
],
[
87.3059,
124,
10.7108,
43,
0
],
[
1.9833,
167,
21.1541,
43,
0
],
[
23.1374,
167,
23.8974,
43,
0
],
[
47.0348,
167,
18.1106,
43,
0
],
[
65.1455,
167,
22.1604,
43,
0
],
[
87.3059,
167,
10.7108,
43,
0
],
[
1.9833,
209,
21.1541,
43,
0
],
[
23.1374,
209,
23.8974,
43,
0
],
[
47.0348,
209,
18.1106,
43,
0
],
[
65.1455,
209,
22.1604,
43,
0
],
[
87.3059,
209,
10.7108,
43,
0
],
[
1.9833,
252,
21.1541,
43,
0
],
[
23.1374,
252,
23.8974,
43,
0
],
[
47.0348,
252,
18.1106,
43,
0
],
[
65.1455,
252,
22.1604,
43,
0
],
[
87.3059,
252,
10.7108,
43,
0
],
[
1.9833,
294,
21.1541,
42,
0
],
[
23.1374,
294,
23.8974,
42,
0
],
[
47.0348,
294,
18.1106,
42,
0
],
[
65.1455,
294,
22.1604,
42,
0
],
[
87.3059,
294,
10.7108,
42,
0
]
]
}
},
"_hash": "6b54a0afbb4863895e318916b1fdca67"
}

View File

@@ -1,767 +0,0 @@
{
"breakpoints": {
"375": {
"name": "users",
"viewportWidth": 351,
"width": 351,
"height": 549,
"bones": [
[
0,
0,
100,
22,
8
],
[
0,
26,
100,
19,
8
],
[
0,
53,
100,
44,
8
],
[
0,
113,
100,
436,
10,
true
],
[
3.7037,
126,
92.5926,
44,
8
],
[
3.7037,
186,
37.362,
31,
0
],
[
41.0657,
186,
26.429,
31,
0
],
[
67.4947,
186,
36.1779,
31,
0
],
[
103.6725,
186,
23.62,
31,
0
],
[
127.2926,
186,
18.8613,
31,
0
],
[
3.7037,
217,
37.362,
61,
0
],
[
41.0657,
217,
26.429,
61,
0
],
[
67.4947,
217,
36.1779,
61,
0
],
[
103.6725,
217,
23.62,
61,
0
],
[
127.2926,
217,
18.8613,
61,
0
],
[
3.7037,
278,
37.362,
61,
0
],
[
41.0657,
278,
26.429,
61,
0
],
[
67.4947,
278,
36.1779,
61,
0
],
[
103.6725,
278,
23.62,
61,
0
],
[
127.2926,
278,
18.8613,
61,
0
],
[
3.7037,
339,
37.362,
61,
0
],
[
41.0657,
339,
26.429,
61,
0
],
[
67.4947,
339,
36.1779,
61,
0
],
[
103.6725,
339,
23.62,
61,
0
],
[
127.2926,
339,
18.8613,
61,
0
],
[
3.7037,
400,
37.362,
61,
0
],
[
41.0657,
400,
26.429,
61,
0
],
[
67.4947,
400,
36.1779,
61,
0
],
[
103.6725,
400,
23.62,
61,
0
],
[
127.2926,
400,
18.8613,
61,
0
],
[
3.7037,
461,
37.362,
61,
0
],
[
41.0657,
461,
26.429,
61,
0
],
[
67.4947,
461,
36.1779,
61,
0
],
[
103.6725,
461,
23.62,
61,
0
],
[
127.2926,
461,
18.8613,
61,
0
]
]
},
"768": {
"name": "users",
"viewportWidth": 736,
"width": 736,
"height": 502,
"bones": [
[
0,
0,
12.6741,
26,
8
],
[
0,
30,
12.6741,
21,
8
],
[
81.2479,
4,
18.7521,
44,
8
],
[
0,
67,
100,
435,
10,
true
],
[
2.5815,
86,
94.837,
44,
8
],
[
2.5815,
146,
24.3079,
33,
0
],
[
26.8894,
146,
18.6481,
33,
0
],
[
45.5375,
146,
23.5628,
33,
0
],
[
69.1003,
146,
15.6568,
33,
0
],
[
84.7571,
146,
12.6613,
33,
0
],
[
2.5815,
179,
24.3079,
61,
0
],
[
26.8894,
179,
18.6481,
61,
0
],
[
45.5375,
179,
23.5628,
61,
0
],
[
69.1003,
179,
15.6568,
61,
0
],
[
84.7571,
179,
12.6613,
61,
0
],
[
2.5815,
240,
24.3079,
61,
0
],
[
26.8894,
240,
18.6481,
61,
0
],
[
45.5375,
240,
23.5628,
61,
0
],
[
69.1003,
240,
15.6568,
61,
0
],
[
84.7571,
240,
12.6613,
61,
0
],
[
2.5815,
301,
24.3079,
61,
0
],
[
26.8894,
301,
18.6481,
61,
0
],
[
45.5375,
301,
23.5628,
61,
0
],
[
69.1003,
301,
15.6568,
61,
0
],
[
84.7571,
301,
12.6613,
61,
0
],
[
2.5815,
362,
24.3079,
61,
0
],
[
26.8894,
362,
18.6481,
61,
0
],
[
45.5375,
362,
23.5628,
61,
0
],
[
69.1003,
362,
15.6568,
61,
0
],
[
84.7571,
362,
12.6613,
61,
0
],
[
2.5815,
423,
24.3079,
61,
0
],
[
26.8894,
423,
18.6481,
61,
0
],
[
45.5375,
423,
23.5628,
61,
0
],
[
69.1003,
423,
15.6568,
61,
0
],
[
84.7571,
423,
12.6613,
61,
0
]
]
},
"1280": {
"name": "users",
"viewportWidth": 996,
"width": 996,
"height": 505,
"bones": [
[
0,
0,
9.3656,
26,
8
],
[
0,
30,
9.3656,
21,
8
],
[
86.5446,
10,
13.4554,
32,
8
],
[
0,
67,
100,
438,
10,
true
],
[
1.9076,
86,
96.1847,
36,
8
],
[
1.9076,
138,
25.3655,
38,
0
],
[
27.2732,
138,
20.4302,
38,
0
],
[
47.7033,
138,
22.8571,
38,
0
],
[
70.5604,
138,
15.9011,
38,
0
],
[
86.4615,
138,
11.6309,
38,
0
],
[
1.9076,
176,
25.3655,
62,
0
],
[
27.2732,
176,
20.4302,
62,
0
],
[
47.7033,
176,
22.8571,
62,
0
],
[
70.5604,
176,
15.9011,
62,
0
],
[
86.4615,
176,
11.6309,
62,
0
],
[
1.9076,
238,
25.3655,
62,
0
],
[
27.2732,
238,
20.4302,
62,
0
],
[
47.7033,
238,
22.8571,
62,
0
],
[
70.5604,
238,
15.9011,
62,
0
],
[
86.4615,
238,
11.6309,
62,
0
],
[
1.9076,
300,
25.3655,
62,
0
],
[
27.2732,
300,
20.4302,
62,
0
],
[
47.7033,
300,
22.8571,
62,
0
],
[
70.5604,
300,
15.9011,
62,
0
],
[
86.4615,
300,
11.6309,
62,
0
],
[
1.9076,
362,
25.3655,
62,
0
],
[
27.2732,
362,
20.4302,
62,
0
],
[
47.7033,
362,
22.8571,
62,
0
],
[
70.5604,
362,
15.9011,
62,
0
],
[
86.4615,
362,
11.6309,
62,
0
],
[
1.9076,
424,
25.3655,
62,
0
],
[
27.2732,
424,
20.4302,
62,
0
],
[
47.7033,
424,
22.8571,
62,
0
],
[
70.5604,
424,
15.9011,
62,
0
],
[
86.4615,
424,
11.6309,
62,
0
]
]
}
},
"_hash": "53e8df6c8f8bf975b3b88bfca3bbd804"
}

View File

@@ -1,746 +0,0 @@
{
"breakpoints": {
"375": {
"name": "vehicles",
"viewportWidth": 351,
"width": 351,
"height": 530,
"bones": [
[
0,
0,
100,
22,
8
],
[
0,
34,
100,
44,
8
],
[
0,
94,
100,
436,
10,
true
],
[
3.7037,
107,
92.5926,
44,
8
],
[
3.7037,
167,
23.9583,
31,
0
],
[
27.662,
167,
24.4168,
31,
0
],
[
52.0789,
167,
29.042,
31,
0
],
[
81.1209,
167,
18.8435,
31,
0
],
[
99.9644,
167,
46.1895,
31,
0
],
[
3.7037,
197,
23.9583,
61,
0
],
[
27.662,
197,
24.4168,
61,
0
],
[
52.0789,
197,
29.042,
61,
0
],
[
81.1209,
197,
18.8435,
61,
0
],
[
99.9644,
197,
46.1895,
61,
0
],
[
3.7037,
258,
23.9583,
61,
0
],
[
27.662,
258,
24.4168,
61,
0
],
[
52.0789,
258,
29.042,
61,
0
],
[
81.1209,
258,
18.8435,
61,
0
],
[
99.9644,
258,
46.1895,
61,
0
],
[
3.7037,
319,
23.9583,
61,
0
],
[
27.662,
319,
24.4168,
61,
0
],
[
52.0789,
319,
29.042,
61,
0
],
[
81.1209,
319,
18.8435,
61,
0
],
[
99.9644,
319,
46.1895,
61,
0
],
[
3.7037,
380,
23.9583,
61,
0
],
[
27.662,
380,
24.4168,
61,
0
],
[
52.0789,
380,
29.042,
61,
0
],
[
81.1209,
380,
18.8435,
61,
0
],
[
99.9644,
380,
46.1895,
61,
0
],
[
3.7037,
441,
23.9583,
61,
0
],
[
27.662,
441,
24.4168,
61,
0
],
[
52.0789,
441,
29.042,
61,
0
],
[
81.1209,
441,
18.8435,
61,
0
],
[
99.9644,
441,
46.1895,
61,
0
]
]
},
"768": {
"name": "vehicles",
"viewportWidth": 736,
"width": 736,
"height": 495,
"bones": [
[
0,
7,
10.1478,
26,
8
],
[
82.6427,
0,
17.3573,
44,
8
],
[
0,
60,
100,
435,
10,
true
],
[
2.5815,
79,
94.837,
44,
8
],
[
2.5815,
139,
16.4126,
33,
0
],
[
18.9941,
139,
16.5039,
33,
0
],
[
35.498,
139,
19.5058,
33,
0
],
[
55.0038,
139,
12.8843,
33,
0
],
[
67.8881,
139,
29.5304,
33,
0
],
[
2.5815,
172,
16.4126,
61,
0
],
[
18.9941,
172,
16.5039,
61,
0
],
[
35.498,
172,
19.5058,
61,
0
],
[
55.0038,
172,
12.8843,
61,
0
],
[
67.8881,
172,
29.5304,
61,
0
],
[
2.5815,
233,
16.4126,
61,
0
],
[
18.9941,
233,
16.5039,
61,
0
],
[
35.498,
233,
19.5058,
61,
0
],
[
55.0038,
233,
12.8843,
61,
0
],
[
67.8881,
233,
29.5304,
61,
0
],
[
2.5815,
294,
16.4126,
61,
0
],
[
18.9941,
294,
16.5039,
61,
0
],
[
35.498,
294,
19.5058,
61,
0
],
[
55.0038,
294,
12.8843,
61,
0
],
[
67.8881,
294,
29.5304,
61,
0
],
[
2.5815,
355,
16.4126,
61,
0
],
[
18.9941,
355,
16.5039,
61,
0
],
[
35.498,
355,
19.5058,
61,
0
],
[
55.0038,
355,
12.8843,
61,
0
],
[
67.8881,
355,
29.5304,
61,
0
],
[
2.5815,
416,
16.4126,
61,
0
],
[
18.9941,
416,
16.5039,
61,
0
],
[
35.498,
416,
19.5058,
61,
0
],
[
55.0038,
416,
12.8843,
61,
0
],
[
67.8881,
416,
29.5304,
61,
0
]
]
},
"1280": {
"name": "vehicles",
"viewportWidth": 996,
"width": 996,
"height": 451,
"bones": [
[
0,
1,
7.4987,
26,
8
],
[
87.5753,
0,
12.4247,
32,
8
],
[
0,
48,
100,
403,
10,
true
],
[
1.9076,
67,
96.1847,
36,
8
],
[
1.9076,
119,
18.3531,
38,
0
],
[
20.2607,
119,
18.2762,
38,
0
],
[
38.537,
119,
21.1785,
38,
0
],
[
59.7154,
119,
14.7841,
38,
0
],
[
74.4996,
119,
23.5928,
38,
0
],
[
1.9076,
157,
18.3531,
55,
0
],
[
20.2607,
157,
18.2762,
55,
0
],
[
38.537,
157,
21.1785,
55,
0
],
[
59.7154,
157,
14.7841,
55,
0
],
[
74.4996,
157,
23.5928,
55,
0
],
[
1.9076,
212,
18.3531,
55,
0
],
[
20.2607,
212,
18.2762,
55,
0
],
[
38.537,
212,
21.1785,
55,
0
],
[
59.7154,
212,
14.7841,
55,
0
],
[
74.4996,
212,
23.5928,
55,
0
],
[
1.9076,
267,
18.3531,
55,
0
],
[
20.2607,
267,
18.2762,
55,
0
],
[
38.537,
267,
21.1785,
55,
0
],
[
59.7154,
267,
14.7841,
55,
0
],
[
74.4996,
267,
23.5928,
55,
0
],
[
1.9076,
322,
18.3531,
55,
0
],
[
20.2607,
322,
18.2762,
55,
0
],
[
38.537,
322,
21.1785,
55,
0
],
[
59.7154,
322,
14.7841,
55,
0
],
[
74.4996,
322,
23.5928,
55,
0
],
[
1.9076,
377,
18.3531,
55,
0
],
[
20.2607,
377,
18.2762,
55,
0
],
[
38.537,
377,
21.1785,
55,
0
],
[
59.7154,
377,
14.7841,
55,
0
],
[
74.4996,
377,
23.5928,
55,
0
]
]
}
},
"_hash": "567bad6080dc9ba9767c6e40a88559b9"
}

View File

@@ -41,6 +41,11 @@
} }
} }
/* Danger card — highlighted border for alert/important cards */
.admin-card-danger {
border: 2px solid var(--danger);
}
/* ============================================================================ /* ============================================================================
Badges Badges
============================================================================ */ ============================================================================ */
@@ -123,6 +128,16 @@
color: var(--danger); color: var(--danger);
} }
.admin-badge-incoming {
background: var(--success-soft);
color: var(--success);
}
.admin-badge-outgoing {
background: var(--info-soft);
color: var(--info);
}
/* Status Badges - Leave Requests */ /* Status Badges - Leave Requests */
.badge-pending { .badge-pending {
background: color-mix(in srgb, var(--warning) 15%, transparent); background: color-mix(in srgb, var(--warning) 15%, transparent);
@@ -716,6 +731,7 @@
.admin-kpi-grid { .admin-kpi-grid {
display: grid; display: grid;
gap: 0.875rem; gap: 0.875rem;
margin-bottom: 1rem;
} }
.admin-kpi-4 { .admin-kpi-4 {
@@ -923,3 +939,163 @@
max-height: 80px; max-height: 80px;
object-fit: contain; object-fit: contain;
} }
/* ============================================================================
Search Bar & Filters
============================================================================ */
.admin-search-bar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.admin-search-bar .admin-form-input,
.admin-search-bar .admin-form-select {
flex: 1 1 140px;
min-width: 140px;
max-width: 280px;
}
.admin-search-bar .react-datepicker-wrapper {
flex: 1 1 140px;
min-width: 140px;
max-width: 280px;
}
.admin-search-bar .admin-btn {
flex-shrink: 0;
}
.admin-search {
position: relative;
flex: 1 1 200px;
min-width: 180px;
max-width: 320px;
display: flex;
align-items: center;
}
.admin-search svg {
position: absolute;
left: 10px;
color: var(--text-muted);
pointer-events: none;
}
.admin-search .admin-form-input {
padding-left: 32px;
}
@media (max-width: 768px) {
.admin-search-bar {
flex-direction: column;
align-items: stretch;
}
.admin-search-bar .admin-form-input,
.admin-search-bar .admin-form-select {
max-width: 100%;
}
.admin-search-bar .react-datepicker-wrapper {
max-width: 100%;
}
.admin-search {
max-width: 100%;
flex: 1 1 100%;
}
}
@media (max-width: 480px) {
.admin-search-bar .admin-form-input,
.admin-search-bar .admin-form-select {
min-width: 100%;
}
.admin-search-bar .react-datepicker-wrapper {
min-width: 100%;
}
}
/* ============================================================================
Item Picker (Warehouse)
============================================================================ */
.admin-item-picker-list {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 100;
max-height: 240px;
overflow-y: auto;
overscroll-behavior: contain;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-top: none;
border-radius: 0 0 var(--border-radius-sm) var(--border-radius-sm);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 4px 0;
margin: 0;
list-style: none;
}
.admin-item-picker-list::-webkit-scrollbar {
width: 5px;
}
.admin-item-picker-list::-webkit-scrollbar-track {
background: transparent;
}
.admin-item-picker-list::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 99px;
}
.admin-item-picker-list::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.admin-item-picker-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 8px 12px;
cursor: pointer;
transition: background var(--transition);
border-radius: 4px;
margin: 0 4px;
}
.admin-item-picker-item:hover,
.admin-item-picker-item.active {
background: var(--bg-secondary);
}
.admin-item-picker-name {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
flex: 1;
}
.admin-item-picker-number {
font-size: 11.5px;
color: var(--text-tertiary);
white-space: nowrap;
}
.admin-item-picker-qty {
font-size: 11.5px;
color: var(--text-secondary);
white-space: nowrap;
}

View File

@@ -1,4 +1,4 @@
import { forwardRef, useMemo } from "react"; import { forwardRef } from "react";
import DatePicker, { registerLocale } from "react-datepicker"; import DatePicker, { registerLocale } from "react-datepicker";
import { cs } from "date-fns/locale"; import { cs } from "date-fns/locale";
import { parse, format } from "date-fns"; import { parse, format } from "date-fns";
@@ -124,7 +124,7 @@ export default function AdminDatePicker({
disabled, disabled,
placeholder, placeholder,
}: AdminDatePickerProps) { }: AdminDatePickerProps) {
const useNative = useMemo(() => isTouchDevice(), []); const useNative = isTouchDevice();
if (useNative) { if (useNative) {
return ( return (
@@ -178,15 +178,12 @@ export default function AdminDatePicker({
return undefined; return undefined;
}; };
const customInput = useMemo( const customInput = (
() => ( <CustomInput
<CustomInput required={required}
required={required} placeholder={placeholder}
placeholder={placeholder} disabled={disabled}
disabled={disabled} />
/>
),
[required, placeholder, disabled],
); );
const commonProps = { const commonProps = {
@@ -194,6 +191,7 @@ export default function AdminDatePicker({
onChange: handleChange, onChange: handleChange,
locale: "cs", locale: "cs",
customInput, customInput,
placeholderText: placeholder,
minDate: parseMinMax(minDate), minDate: parseMinMax(minDate),
maxDate: parseMinMax(maxDate), maxDate: parseMinMax(maxDate),
popperPlacement: "bottom-start" as const, popperPlacement: "bottom-start" as const,

View File

@@ -1,4 +1,4 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { import {
formatDate, formatDate,
formatDatetime, formatDatetime,
@@ -8,34 +8,13 @@ import {
getLeaveTypeName, getLeaveTypeName,
getLeaveTypeBadgeClass, getLeaveTypeBadgeClass,
} from "../utils/attendanceHelpers"; } from "../utils/attendanceHelpers";
import type { AttendanceRecord as HookAttendanceRecord } from "../hooks/useAttendanceAdmin";
interface ProjectLog { interface AttendanceRecord extends HookAttendanceRecord {
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;
user_name: string;
leave_type?: string;
leave_hours?: number;
arrival_time?: string | null;
departure_time?: string | null;
break_start?: string | null;
break_end?: string | null;
arrival_lat?: number | string | null; arrival_lat?: number | string | null;
arrival_lng?: number | string | null; arrival_lng?: number | string | null;
departure_lat?: number | string | null; departure_lat?: number | string | null;
departure_lng?: number | string | null; departure_lng?: number | string | null;
project_name?: string;
project_logs?: ProjectLog[];
notes?: string | null;
} }
interface AttendanceShiftTableProps { interface AttendanceShiftTableProps {
@@ -51,7 +30,7 @@ function formatBreak(record: AttendanceRecord): string {
if (record.break_start) { if (record.break_start) {
return `${formatTime(record.break_start)} - ?`; return `${formatTime(record.break_start)} - ?`;
} }
return "\u2014"; return "";
} }
function renderProjectCell(record: AttendanceRecord): React.ReactNode { function renderProjectCell(record: AttendanceRecord): React.ReactNode {
@@ -97,7 +76,7 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
> >
{log.project_name || `#${log.project_id}`}{" "} {log.project_name || `#${log.project_id}`}{" "}
{durationValid {durationValid
? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " \u25B8" : ""})` ? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " " : ""})`
: "—"} : "—"}
</span> </span>
); );
@@ -115,7 +94,7 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
</span> </span>
); );
} }
return "\u2014"; return "";
} }
export default function AttendanceShiftTable({ export default function AttendanceShiftTable({
@@ -156,7 +135,10 @@ export default function AttendanceShiftTable({
const workMinutes = isLeave const workMinutes = isLeave
? (record.leave_hours != null ? Number(record.leave_hours) : 8) * ? (record.leave_hours != null ? Number(record.leave_hours) : 8) *
60 60
: calculateWorkMinutes(record); : calculateWorkMinutes({
...record,
notes: record.notes ?? undefined,
});
const hasLocation = const hasLocation =
(record.arrival_lat && record.arrival_lng) || (record.arrival_lat && record.arrival_lng) ||
(record.departure_lat && record.departure_lng); (record.departure_lat && record.departure_lng);
@@ -173,18 +155,16 @@ export default function AttendanceShiftTable({
</span> </span>
</td> </td>
<td className="admin-mono"> <td className="admin-mono">
{isLeave ? "\u2014" : formatDatetime(record.arrival_time)} {isLeave ? "" : formatDatetime(record.arrival_time)}
</td> </td>
<td className="admin-mono"> <td className="admin-mono">
{isLeave ? "\u2014" : formatBreak(record)} {isLeave ? "" : formatBreak(record)}
</td> </td>
<td className="admin-mono"> <td className="admin-mono">
{isLeave ? "\u2014" : formatDatetime(record.departure_time)} {isLeave ? "" : formatDatetime(record.departure_time)}
</td> </td>
<td className="admin-mono"> <td className="admin-mono">
{workMinutes > 0 {workMinutes > 0 ? `${formatMinutes(workMinutes)} h` : "—"}
? `${formatMinutes(workMinutes)} h`
: "\u2014"}
</td> </td>
<td>{renderProjectCell(record)}</td> <td>{renderProjectCell(record)}</td>
<td> <td>
@@ -195,10 +175,10 @@ export default function AttendanceShiftTable({
title="Zobrazit polohu" title="Zobrazit polohu"
aria-label="Zobrazit polohu" aria-label="Zobrazit polohu"
> >
<span aria-hidden="true">{"\uD83D\uDCCD"}</span> <span aria-hidden="true">{"📍"}</span>
</Link> </Link>
) : ( ) : (
"\u2014" ""
)} )}
</td> </td>
<td <td

View File

@@ -1,4 +1,4 @@
import type { ReactNode } from "react"; import { useEffect, type ReactNode } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
interface ConfirmModalProps { interface ConfirmModalProps {
@@ -91,6 +91,19 @@ export default function ConfirmModal({
confirmVariant, confirmVariant,
loading, loading,
}: ConfirmModalProps) { }: ConfirmModalProps) {
useEffect(() => {
if (!isOpen) return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape" && !loading) {
onClose();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, loading, onClose]);
return ( return (
<AnimatePresence> <AnimatePresence>
{isOpen && ( {isOpen && (

View File

@@ -0,0 +1,118 @@
import { useEffect, type ReactNode, type FormEvent } from "react";
import { motion, AnimatePresence } from "framer-motion";
import useModalLock from "../hooks/useModalLock";
export interface FormModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit?: () => void; // called when user clicks primary button (only if provided)
title: string;
children: ReactNode; // modal body (form fields)
submitLabel?: string; // primary button text
cancelLabel?: string; // cancel button text
loading?: boolean;
hideFooter?: boolean; // for "view-only" modals
size?: "sm" | "md" | "lg"; // optional
closeOnBackdrop?: boolean; // default true
closeOnEsc?: boolean; // default true
}
export default function FormModal({
isOpen,
onClose,
onSubmit,
title,
children,
submitLabel = "Uložit",
cancelLabel = "Zrušit",
loading = false,
hideFooter = false,
size = "md",
closeOnBackdrop = true,
closeOnEsc = true,
}: FormModalProps) {
useModalLock(isOpen);
useEffect(() => {
if (!isOpen || !closeOnEsc) return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape" && !loading) {
onClose();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, closeOnEsc, loading, onClose]);
const handleBackdropClick = () => {
if (closeOnBackdrop && !loading) onClose();
};
const handlePrimaryClick = (e: FormEvent) => {
e.preventDefault();
if (onSubmit) onSubmit();
};
const titleId = "form-modal-title";
const modalClassName =
size === "lg" ? "admin-modal admin-modal-lg" : "admin-modal";
return (
<AnimatePresence>
{isOpen && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-backdrop" onClick={handleBackdropClick} />
<motion.div
className={modalClassName}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 id={titleId} className="admin-modal-title">
{title}
</h2>
</div>
<div className="admin-modal-body">{children}</div>
{!hideFooter && (
<div className="admin-modal-footer">
<button
type="button"
onClick={() => !loading && onClose()}
className="admin-btn admin-btn-secondary"
disabled={loading}
>
{cancelLabel}
</button>
{onSubmit && (
<button
type="button"
onClick={handlePrimaryClick}
className="admin-btn admin-btn-primary"
disabled={loading}
>
{loading ? "Zpracování..." : submitLabel}
</button>
)}
</div>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -4,8 +4,6 @@ import { projectFilesOptions } from "../lib/queries/projects";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import ConfirmModal from "./ConfirmModal"; import ConfirmModal from "./ConfirmModal";
import apiFetch from "../utils/api"; import apiFetch from "../utils/api";
import { Skeleton } from "boneyard-js/react";
import ProjectFileManagerFixture from "../fixtures/ProjectFileManagerFixture";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
@@ -472,13 +470,9 @@ export default function ProjectFileManager({
if (filesLoading && items.length === 0 && !errorMessage) { if (filesLoading && items.length === 0 && !errorMessage) {
return ( return (
<Skeleton <div className="admin-loading">
name="project-file-manager" <div className="admin-spinner" />
loading={filesLoading && items.length === 0} </div>
fixture={<ProjectFileManagerFixture />}
>
<div />
</Skeleton>
); );
} }

View File

@@ -85,7 +85,15 @@ export interface ShiftFormModalProps {
function ProjectTimeStatus({ form, projectLogs }: ProjectTimeStatusProps) { function ProjectTimeStatus({ form, projectLogs }: ProjectTimeStatusProps) {
const totalWork = calcFormWorkMinutes(form); const totalWork = calcFormWorkMinutes(form);
const totalProject = calcProjectMinutesTotal(projectLogs); const totalProject = calcProjectMinutesTotal(
projectLogs.map((l) => ({
...l,
project_id:
l.project_id !== "" && l.project_id != null
? Number(l.project_id)
: undefined,
})),
);
const remaining = totalWork - totalProject; const remaining = totalWork - totalProject;
const hasLogs = projectLogs.some((l) => l.project_id); const hasLogs = projectLogs.some((l) => l.project_id);
@@ -329,7 +337,6 @@ export default function ShiftFormModal({
<option value="work">Práce</option> <option value="work">Práce</option>
<option value="vacation">Dovolená</option> <option value="vacation">Dovolená</option>
<option value="sick">Nemoc</option> <option value="sick">Nemoc</option>
<option value="holiday">Svátek</option>
<option value="unpaid">Neplacené volno</option> <option value="unpaid">Neplacené volno</option>
</select> </select>
</div> </div>

View File

@@ -332,6 +332,201 @@ const menuSections: MenuSection[] = [
}, },
], ],
}, },
{
label: "Sklad",
items: [
{
path: "/warehouse",
label: "Přehled",
permission: "warehouse.view",
end: true,
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" />
</svg>
),
},
{
path: "/warehouse/items",
label: "Položky",
permission: "warehouse.view",
matchPrefix: "/warehouse/items",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" />
</svg>
),
},
{
path: "/warehouse/receipts",
label: "Příjmy",
permission: "warehouse.operate",
matchPrefix: "/warehouse/receipts",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="17 11 12 6 7 11" />
<line x1="12" y1="6" x2="12" y2="18" />
<path d="M5 19h14" />
</svg>
),
},
{
path: "/warehouse/issues",
label: "Výdeje",
permission: "warehouse.operate",
matchPrefix: "/warehouse/issues",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="7 13 12 18 17 13" />
<line x1="12" y1="18" x2="12" y2="6" />
<path d="M5 5h14" />
</svg>
),
},
{
path: "/warehouse/reservations",
label: "Rezervace",
permission: "warehouse.operate",
matchPrefix: "/warehouse/reservations",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<line x1="19" y1="8" x2="19" y2="14" />
<line x1="22" y1="11" x2="16" y2="11" />
</svg>
),
},
{
path: "/warehouse/inventory",
label: "Inventura",
permission: "warehouse.inventory",
matchPrefix: "/warehouse/inventory",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</svg>
),
},
{
path: "/warehouse/reports",
label: "Reporty",
permission: "warehouse.view",
end: true,
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="20" x2="18" y2="10" />
<line x1="12" y1="20" x2="12" y2="4" />
<line x1="6" y1="20" x2="6" y2="14" />
</svg>
),
},
{
path: "/warehouse/categories",
label: "Kategorie",
permission: "warehouse.manage",
matchPrefix: "/warehouse/categories",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="4" y1="21" x2="4" y2="14" />
<line x1="4" y1="10" x2="4" y2="3" />
<line x1="12" y1="21" x2="12" y2="12" />
<line x1="12" y1="8" x2="12" y2="3" />
<line x1="20" y1="21" x2="20" y2="16" />
<line x1="20" y1="12" x2="20" y2="3" />
<line x1="1" y1="14" x2="7" y2="14" />
<line x1="9" y1="8" x2="15" y2="8" />
<line x1="17" y1="16" x2="23" y2="16" />
</svg>
),
},
{
path: "/warehouse/locations",
label: "Lokace",
permission: "warehouse.manage",
matchPrefix: "/warehouse/locations",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
),
},
{
path: "/warehouse/suppliers",
label: "Dodavatelé",
permission: "warehouse.manage",
matchPrefix: "/warehouse/suppliers",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
),
},
],
},
{ {
label: "Systém", label: "Systém",
items: [ items: [

View File

@@ -7,8 +7,6 @@ import useModalLock from "../../hooks/useModalLock";
import apiFetch from "../../utils/api"; import apiFetch from "../../utils/api";
import { formatSessionDate } from "../../utils/dashboardHelpers"; import { formatSessionDate } from "../../utils/dashboardHelpers";
import { sessionsOptions, type Session } from "../../lib/queries/dashboard"; import { sessionsOptions, type Session } from "../../lib/queries/dashboard";
import { Skeleton } from "boneyard-js/react";
import DashSessionsFixture from "../../fixtures/DashSessionsFixture";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
@@ -154,11 +152,11 @@ export default function DashSessions() {
)} )}
</div> </div>
<div className="admin-card-body" style={{ padding: 0 }}> <div className="admin-card-body" style={{ padding: 0 }}>
<Skeleton {sessionsLoading ? (
name="dash-sessions" <div className="admin-loading">
loading={sessionsLoading} <div className="admin-spinner" />
fixture={<DashSessionsFixture />} </div>
> ) : (
<> <>
{sessions.length === 0 && ( {sessions.length === 0 && (
<div <div
@@ -231,7 +229,7 @@ export default function DashSessions() {
</div> </div>
)} )}
</> </>
</Skeleton> )}
</div> </div>
</motion.div> </motion.div>

View File

@@ -0,0 +1,74 @@
import { useQuery } from "@tanstack/react-query";
import { jsonQuery } from "../../lib/apiAdapter";
interface Batch {
id: number;
quantity: number;
unit_price: number;
received_at: string;
is_consumed: boolean;
}
interface BatchesResponse {
batches: Batch[];
total_stock: number;
reserved_quantity: number;
available_quantity: number;
}
interface BatchPickerProps {
itemId: number | null;
value: number | null;
onChange: (batchId: number, unitPrice: number) => void;
}
export default function BatchPicker({
itemId,
value,
onChange,
}: BatchPickerProps) {
const { data: response } = useQuery({
queryKey: ["warehouse", "batches", itemId],
queryFn: () =>
jsonQuery<BatchesResponse>(
`/api/admin/warehouse/items/${itemId}/batches`,
),
enabled: !!itemId,
});
const batches = response?.batches ?? [];
return (
<div>
{response && response.reserved_quantity > 0 && (
<div
style={{
fontSize: "0.75rem",
color: "var(--text-tertiary)",
marginBottom: "0.25rem",
}}
>
Dostupné: {response.available_quantity} ks (z toho rezervováno:{" "}
{response.reserved_quantity} ks)
</div>
)}
<select
className="admin-form-select"
value={value ?? ""}
onChange={(e) => {
const batchId = Number(e.target.value);
const batch = batches.find((b) => b.id === batchId);
if (batch) onChange(batch.id, Number(batch.unit_price));
}}
>
<option value="">-- auto FIFO --</option>
{batches.map((b) => (
<option key={b.id} value={b.id}>
{new Date(b.received_at).toLocaleDateString("cs-CZ")} | {b.quantity}{" "}
ks | {Number(b.unit_price).toFixed(2)}
</option>
))}
</select>
</div>
);
}

View File

@@ -0,0 +1,228 @@
import { useState, useRef, useEffect, useCallback, useId } from "react";
import { createPortal } from "react-dom";
import { useQuery } from "@tanstack/react-query";
import { warehouseItemListOptions } from "../../lib/queries/warehouse";
interface ItemPickerProps {
value: number | null;
onChange: (itemId: number) => void;
itemName?: string;
}
export default function ItemPicker({
value,
onChange,
itemName,
}: ItemPickerProps) {
const [search, setSearch] = useState(itemName ?? "");
const [open, setOpen] = useState(false);
const { data } = useQuery(warehouseItemListOptions({ search, perPage: 20 }));
const items = data?.data ?? [];
const containerRef = useRef<HTMLDivElement>(null);
const activeIndexRef = useRef<number>(-1);
const listboxId = useId();
const [activeIndex, setActiveIndex] = useState<number>(-1);
const [dropdownStyle, setDropdownStyle] = useState<{
position: "fixed";
top: number;
left: number;
width: number;
zIndex: number;
} | null>(null);
const updatePosition = useCallback(() => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
setDropdownStyle({
position: "fixed",
top: rect.bottom + 2,
left: rect.left,
width: rect.width,
zIndex: 100,
});
}, []);
// Reset active index when items change
useEffect(() => {
if (activeIndex >= items.length) {
activeIndexRef.current = -1;
setActiveIndex(-1);
}
}, [items, activeIndex]);
useEffect(() => {
if (open) {
updatePosition();
const onScroll = () => updatePosition();
const onClose = () => setOpen(false);
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onClose);
return () => {
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onClose);
};
} else {
setDropdownStyle(null);
activeIndexRef.current = -1;
setActiveIndex(-1);
}
}, [open, updatePosition]);
// Close on click-outside via mousedown on document
useEffect(() => {
if (!open) return;
const onDocMouseDown = (e: MouseEvent) => {
const target = e.target;
if (containerRef.current && target instanceof Node) {
if (!containerRef.current.contains(target)) {
// Don't close if click landed on an option in the portal
const listEl = document.getElementById(listboxId);
if (listEl && listEl.contains(target)) return;
setOpen(false);
}
}
};
document.addEventListener("mousedown", onDocMouseDown);
return () => document.removeEventListener("mousedown", onDocMouseDown);
}, [open, listboxId]);
const handleSelect = (itemId: number, name: string) => {
onChange(itemId);
setOpen(false);
setSearch(name);
};
const setActive = (index: number) => {
activeIndexRef.current = index;
setActiveIndex(index);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault();
if (!open) {
setOpen(true);
if (items.length > 0) setActive(0);
return;
}
const next =
activeIndexRef.current < items.length - 1
? activeIndexRef.current + 1
: 0;
setActive(next);
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (!open) {
setOpen(true);
if (items.length > 0) setActive(items.length - 1);
return;
}
const prev =
activeIndexRef.current > 0
? activeIndexRef.current - 1
: items.length - 1;
setActive(prev);
} else if (e.key === "Enter") {
if (
open &&
activeIndexRef.current >= 0 &&
activeIndexRef.current < items.length
) {
e.preventDefault();
const item = items[activeIndexRef.current];
handleSelect(item.id, item.name);
}
} else if (e.key === "Escape") {
if (open) {
e.preventDefault();
setOpen(false);
}
} else if (e.key === "Home") {
if (open && items.length > 0) {
e.preventDefault();
setActive(0);
}
} else if (e.key === "End") {
if (open && items.length > 0) {
e.preventDefault();
setActive(items.length - 1);
}
} else if (e.key === "Tab") {
setOpen(false);
}
};
const activeId =
activeIndex >= 0 ? `${listboxId}-option-${activeIndex}` : undefined;
return (
<div
ref={containerRef}
style={{ position: "relative" }}
role="combobox"
aria-haspopup="listbox"
aria-expanded={open}
aria-owns={listboxId}
>
<input
type="text"
className="admin-form-input"
placeholder="Hledat položku..."
value={search}
role="searchbox"
aria-autocomplete="list"
aria-controls={listboxId}
aria-activedescendant={activeId}
onChange={(e) => {
setSearch(e.target.value);
setOpen(true);
}}
onFocus={() => {
setOpen(true);
updatePosition();
}}
onKeyDown={handleKeyDown}
/>
{open &&
items.length > 0 &&
dropdownStyle &&
createPortal(
<ul
id={listboxId}
role="listbox"
className="admin-item-picker-list"
style={dropdownStyle}
>
{items.map((item, index) => (
<li
key={item.id}
id={`${listboxId}-option-${index}`}
role="option"
aria-selected={value === item.id}
className={`admin-item-picker-item ${activeIndex === index ? "active" : ""}`}
onMouseDown={(e) => {
e.preventDefault();
handleSelect(item.id, item.name);
}}
>
<span className="admin-item-picker-name">{item.name}</span>
{item.item_number && (
<span className="admin-item-picker-number">
{item.item_number}
</span>
)}
{item.available_quantity !== undefined && (
<span className="admin-item-picker-qty">
{item.available_quantity} {item.unit}
</span>
)}
</li>
))}
</ul>,
document.body,
)}
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { useQuery } from "@tanstack/react-query";
import {
warehouseLocationListOptions,
type WarehouseLocation,
} from "../../lib/queries/warehouse";
interface LocationSelectProps {
value: number | null;
onChange: (locationId: number | null) => void;
}
export default function LocationSelect({
value,
onChange,
}: LocationSelectProps) {
const { data: locations } = useQuery(warehouseLocationListOptions());
return (
<select
className="admin-form-select"
value={value ?? ""}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
>
<option value="">-- bez lokace --</option>
{locations?.map((loc: WarehouseLocation) => (
<option key={loc.id} value={loc.id}>
{loc.code} - {loc.name}
</option>
))}
</select>
);
}

View File

@@ -0,0 +1,54 @@
import { useQuery } from "@tanstack/react-query";
import { warehouseReservationListOptions } from "../../lib/queries/warehouse";
interface ReservationPickerProps {
itemId: number | null;
projectId: number | null;
value: number | null;
onChange: (reservationId: number | null, remainingQty: number) => void;
}
export default function ReservationPicker({
itemId,
projectId,
value,
onChange,
}: ReservationPickerProps) {
const { data: result } = useQuery(
warehouseReservationListOptions({
item_id: itemId ?? undefined,
project_id: projectId ?? undefined,
status: "ACTIVE",
perPage: 100,
}),
);
const reservations = result?.data ?? [];
return (
<select
className="admin-form-select"
value={value ?? ""}
onChange={(e) => {
const val = e.target.value;
if (!val) {
onChange(null, 0);
return;
}
const reservationId = Number(val);
const reservation = reservations.find((r) => r.id === reservationId);
if (reservation) {
onChange(reservationId, Number(reservation.remaining_qty));
}
}}
>
<option value=""> žádná rezervace </option>
{reservations.map((r) => (
<option key={r.id} value={r.id}>
R{r.id} {r.project?.name ?? `Projekt #${r.project_id}`} zbývá:{" "}
{Number(r.remaining_qty)} {r.item?.unit ?? "ks"}
</option>
))}
</select>
);
}

View File

@@ -0,0 +1,33 @@
import { useQuery } from "@tanstack/react-query";
import {
warehouseSupplierListOptions,
type WarehouseSupplier,
} from "../../lib/queries/warehouse";
interface SupplierSelectProps {
value: number | null;
onChange: (supplierId: number | null) => void;
}
export default function SupplierSelect({
value,
onChange,
}: SupplierSelectProps) {
const { data } = useQuery(warehouseSupplierListOptions({ perPage: 100 }));
const suppliers = data?.data ?? [];
return (
<select
className="admin-form-select"
value={value ?? ""}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
>
<option value="">-- bez dodavatele --</option>
{suppliers.map((s: WarehouseSupplier) => (
<option key={s.id} value={s.id}>
{s.name}
{s.ico ? ` (${s.ico})` : ""}
</option>
))}
</select>
);
}

View File

@@ -0,0 +1,249 @@
import { ReactNode, useId, useRef } from "react";
import ItemPicker from "./ItemPicker";
import LocationSelect from "./LocationSelect";
import BatchPicker from "./BatchPicker";
export interface MovementItem {
key: string;
item_id: number | null;
item_name?: string;
quantity: number;
unit_price: number;
location_id: number | null;
batch_id: number | null;
reservation_id: number | null;
notes: string | null;
}
export type MovementRowRenderer<T> = (
item: T,
index: number,
updateItem: (field: string, value: unknown) => void,
removeItem: () => void,
) => ReactNode;
interface WarehouseMovementTableProps<T extends { key: string }> {
items: T[];
onChange: (items: T[]) => void;
mode: "receipt" | "issue" | "inventory";
defaultItem?: () => Omit<T, "key">;
renderRow?: MovementRowRenderer<T>;
renderHeader?: () => ReactNode;
}
function parseDecimal(raw: string): number {
// Strip leading minus if you want negative support; here we want only positive
const cleaned = raw.replace(/[eE+\-]/g, "");
const n = Number(cleaned);
return Number.isFinite(n) ? n : 0;
}
const builtInDefaultMovementItem = (): Omit<MovementItem, "key"> => ({
item_id: null,
quantity: 0,
unit_price: 0,
location_id: null,
batch_id: null,
reservation_id: null,
notes: null,
});
export default function WarehouseMovementTable<T extends { key: string }>({
items,
onChange,
mode,
defaultItem,
renderRow,
renderHeader,
}: WarehouseMovementTableProps<T>) {
const baseId = useId();
const counterRef = useRef(0);
const nextKey = () => `${baseId}-item-${counterRef.current++}`;
const addItem = () => {
const factory =
defaultItem ??
(builtInDefaultMovementItem as unknown as () => Omit<T, "key">);
onChange([...items, { ...(factory() as object), key: nextKey() } as T]);
};
const removeItem = (index: number) => {
onChange(items.filter((_, i) => i !== index));
};
const updateItem = (index: number, field: string, value: unknown) => {
const updated = [...items];
updated[index] = { ...updated[index], [field]: value };
onChange(updated);
};
// If a custom row renderer is supplied, the caller is fully in charge of
// the row layout (and typically the column headers too — see inventory mode).
if (renderRow) {
return (
<div>
<div className="admin-warehouse-movement-table">
<table className="admin-table">
{renderHeader && (
<thead>
<tr>{renderHeader()}</tr>
</thead>
)}
<tbody>
{items.map((item, index) => (
<tr key={item.key}>
{renderRow(
item,
index,
(field, value) => updateItem(index, field, value),
() => removeItem(index),
)}
</tr>
))}
</tbody>
</table>
</div>
<button
type="button"
className="admin-btn admin-btn-secondary"
onClick={addItem}
>
+ Přidat řádek
</button>
</div>
);
}
// Default rendering: only valid for "receipt" | "issue" — narrow at runtime.
const movementItems = items as unknown as MovementItem[];
return (
<div>
<div className="admin-warehouse-movement-table">
<table className="admin-table">
<thead>
<tr>
<th className="admin-warehouse-col-item">Položka</th>
{mode === "issue" && (
<th className="admin-warehouse-col-batch">Šarže</th>
)}
<th className="admin-warehouse-col-qty">Množství</th>
<th className="admin-warehouse-col-price">Cena/ks</th>
<th className="admin-warehouse-col-location">Lokace</th>
<th className="admin-warehouse-col-notes">Poznámka</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{movementItems.map((item, index) => (
<tr key={item.key}>
<td className="admin-warehouse-col-item">
<ItemPicker
value={item.item_id}
itemName={item.item_name}
onChange={(id) => updateItem(index, "item_id", id)}
/>
</td>
{mode === "issue" && (
<td className="admin-warehouse-col-batch">
<BatchPicker
itemId={item.item_id}
value={item.batch_id}
onChange={(batchId, unitPrice) => {
updateItem(index, "batch_id", batchId);
updateItem(index, "unit_price", unitPrice);
}}
/>
</td>
)}
<td className="admin-warehouse-col-qty">
<input
type="number"
className="admin-form-input"
value={item.quantity || ""}
onChange={(e) =>
updateItem(
index,
"quantity",
parseDecimal(e.target.value),
)
}
min="0"
step="0.001"
inputMode="decimal"
pattern="[0-9]+([\.,][0-9]+)?"
aria-label={`Množství, řádek ${index + 1}`}
/>
</td>
<td className="admin-warehouse-col-price">
<input
type="number"
className="admin-form-input"
value={item.unit_price || ""}
onChange={(e) =>
updateItem(
index,
"unit_price",
parseDecimal(e.target.value),
)
}
min="0"
step="0.01"
disabled={mode === "issue"}
inputMode="decimal"
pattern="[0-9]+([\.,][0-9]+)?"
aria-label={`Cena za kus, řádek ${index + 1}`}
/>
</td>
<td className="admin-warehouse-col-location">
<LocationSelect
value={item.location_id}
onChange={(id) => updateItem(index, "location_id", id)}
/>
</td>
<td className="admin-warehouse-col-notes">
<input
type="text"
className="admin-form-input"
value={item.notes ?? ""}
onChange={(e) => updateItem(index, "notes", e.target.value)}
/>
</td>
<td>
<div className="admin-table-actions">
<button
type="button"
className="admin-btn-icon danger"
onClick={() => removeItem(index)}
title="Odebrat řádek"
aria-label="Odebrat řádek"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
type="button"
className="admin-btn admin-btn-secondary"
onClick={addItem}
>
+ Přidat řádek
</button>
</div>
);
}

View File

@@ -1,69 +0,0 @@
export default function AttendanceAdminFixture() {
return (
<div>
<div className="admin-page-header">
<div>
<h1 className="admin-page-title">Správa docházky</h1>
</div>
<div className="admin-page-actions">
<button className="admin-btn admin-btn-secondary">
Vyplnit měsíc
</button>
<button className="admin-btn admin-btn-primary">Přidat záznam</button>
</div>
</div>
<div className="admin-card mb-6">
<div className="admin-card-body">
<div className="admin-form-row">
<label className="admin-form-label">Měsíc</label>
<select className="admin-form-select" />
<label className="admin-form-label">Zaměstnanec</label>
<select className="admin-form-select" />
</div>
</div>
</div>
<div className="admin-grid admin-grid-3">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="admin-card">
<div className="admin-card-body">
<div className="flex-row gap-2 mb-2">
<span style={{ fontWeight: 600 }}>Jan Novák</span>
<span className="attendance-working-badge finished">
&#10007;
</span>
</div>
<div className="admin-stat-value">8:00</div>
<div className="admin-stat-label">odpracováno</div>
</div>
</div>
))}
</div>
<div className="admin-card">
<div className="admin-card-body">
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Příchod</th>
<th>Odchod</th>
<th>Hodiny</th>
</tr>
</thead>
<tbody>
{Array.from({ length: 4 }, (_, i) => (
<tr key={i}>
<td>1. 4. 2025</td>
<td className="admin-mono">08:00</td>
<td className="admin-mono">16:00</td>
<td className="admin-mono">8:00</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,104 +0,0 @@
export default function AttendanceBalancesFixture() {
return (
<div>
<div className="admin-page-header">
<div>
<h1 className="admin-page-title">Správa bilancí</h1>
</div>
<div className="admin-page-actions">
<select className="admin-form-select" style={{ minWidth: 100 }}>
<option>2025</option>
</select>
</div>
</div>
<div className="admin-card">
<div className="admin-card-body">
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Zaměstnanec</th>
<th>Nárok (h)</th>
<th>Čerpáno (h)</th>
<th>Zbývá (h)</th>
<th>Nemoc (h)</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{Array.from({ length: 4 }, (_, i) => (
<tr key={i}>
<td className="fw-500">Jan Novák</td>
<td className="admin-mono">160</td>
<td className="admin-mono">40.0</td>
<td className="admin-mono">120.0</td>
<td className="admin-mono">8.0</td>
<td>
<div className="admin-table-actions">
<button className="admin-btn-icon" title="Upravit">
&#9998;
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<div className="mt-6">
<h2 className="admin-page-title mb-4" style={{ fontSize: "1.25rem" }}>
Měsíční přehled fondu 2025
</h2>
<div className="admin-grid admin-grid-3">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="admin-card">
<div className="admin-card-body">
<h3 style={{ fontWeight: 600, fontSize: "1rem", margin: 0 }}>
Duben
</h3>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0.375rem",
marginTop: "0.5rem",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 12,
}}
>
<span>Jan Novák</span>
<span className="text-secondary">8h</span>
</div>
<div
style={{
height: 3,
background: "var(--bg-tertiary)",
borderRadius: 2,
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: "75%",
background: "var(--gradient)",
borderRadius: 2,
}}
/>
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -1,43 +0,0 @@
export default function AttendanceCreateFixture() {
return (
<div>
<div className="admin-page-header">
<h1 className="admin-page-title">Zapsat docházku</h1>
</div>
<div className="admin-card" style={{ maxWidth: 600 }}>
<div className="admin-card-body">
<FormField label="Uživatel">
<select className="admin-form-select">
<option>Jan Novák</option>
</select>
</FormField>
<FormField label="Datum">
<input type="date" className="admin-form-input" />
</FormField>
<FormField label="Příchod">
<input type="time" className="admin-form-input" />
</FormField>
<FormField label="Odchod">
<input type="time" className="admin-form-input" />
</FormField>
<button className="admin-btn admin-btn-primary">Uložit</button>
</div>
</div>
</div>
);
}
function FormField({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="admin-form-group">
<label className="admin-form-label">{label}</label>
{children}
</div>
);
}

View File

@@ -1,79 +0,0 @@
export default function AttendanceFixture() {
return (
<div>
<div className="admin-page-header">
<div>
<h1 className="admin-page-title">Docházka</h1>
<p className="admin-page-subtitle">pondělí 28. dubna 2025</p>
</div>
</div>
<div className="attendance-layout">
<div className="attendance-main">
<div className="attendance-clock-card">
<div className="attendance-clock-header">
<div className="attendance-clock-status">
<span className="attendance-status-dot" />
<span>Nepracuji</span>
</div>
<div className="attendance-clock-time">08:30</div>
</div>
<div className="attendance-clock-actions">
<button className="admin-btn admin-btn-primary w-full">
Příchod
</button>
<button className="admin-btn admin-btn-secondary w-full">
Žádost o nepřítomnost
</button>
</div>
</div>
</div>
<div className="attendance-sidebar">
<div className="attendance-balance-card">
<h3 className="attendance-balance-title">Dovolená 2025</h3>
<div className="attendance-balance-value">
<span className="attendance-balance-number">12</span>
<span className="attendance-balance-unit">dnů</span>
</div>
<div className="attendance-balance-detail">
<span>Celkem: 160h</span>
<span>Čerpáno: 64h</span>
</div>
<div className="attendance-balance-bar">
<div
className="attendance-balance-progress"
style={{ width: "60%" }}
/>
</div>
</div>
<div className="admin-stat-card">
<div className="admin-stat-icon danger">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-label">Nemoc 2025</span>
<span className="admin-stat-value">16h čerpáno</span>
</div>
</div>
<div className="attendance-quick-links">
<h4 className="attendance-quick-title">Rychlé odkazy</h4>
<a className="attendance-quick-link">
<span>Moje žádosti</span>
</a>
<a className="attendance-quick-link">
<span>Historie docházky</span>
</a>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,102 +0,0 @@
export default function AttendanceHistoryFixture() {
return (
<div>
<div className="admin-page-header">
<div>
<h1 className="admin-page-title">Historie docházky</h1>
<p className="admin-page-subtitle">duben 2025</p>
</div>
<div className="admin-page-actions">
<button className="admin-btn admin-btn-secondary">Tisk</button>
</div>
</div>
<div className="admin-card mb-6">
<div className="admin-card-body">
<div className="admin-form-row">
<label className="admin-form-label">Měsíc</label>
<input className="admin-form-input" readOnly value="04/2025" />
</div>
</div>
</div>
<div className="admin-card mb-6">
<div className="admin-card-body">
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<div className="admin-stat-icon info">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</div>
<div style={{ flex: 1, minWidth: 200 }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
marginBottom: "0.375rem",
}}
>
<span style={{ fontWeight: 600 }}>Fond: 120h / 160h</span>
<span
className="text-secondary"
style={{ fontSize: "0.8125rem" }}
>
20 prac. dnů
</span>
</div>
<div className="attendance-balance-bar">
<div
className="attendance-balance-progress"
style={{ width: "75%" }}
/>
</div>
</div>
</div>
</div>
</div>
<div className="admin-card">
<div className="admin-card-body">
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Typ</th>
<th>Příchod</th>
<th>Pauza</th>
<th>Odchod</th>
<th>Hodiny</th>
</tr>
</thead>
<tbody>
{Array.from({ length: 5 }, (_, i) => (
<tr key={i}>
<td className="admin-mono">1. 4. 2025</td>
<td>
<span className="admin-badge admin-badge-info">
Práce
</span>
</td>
<td className="admin-mono">08:00</td>
<td className="admin-mono">12:00 12:30</td>
<td className="admin-mono">16:30</td>
<td className="admin-mono">8:00</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,49 +0,0 @@
export default function AttendanceLocationFixture() {
return (
<div>
<div className="admin-page-header">
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
<h1 className="admin-page-title">Lokace</h1>
</div>
</div>
<div className="admin-card" style={{ height: 300, marginBottom: "1rem" }}>
<div
style={{
background: "var(--bg-secondary)",
height: "100%",
borderRadius: 8,
}}
/>
</div>
<div
style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1rem" }}
>
<div className="admin-card">
<div className="admin-card-body">
<h3 style={{ marginBottom: "0.5rem" }}>Poloha</h3>
<p>50.0755° N, 14.4378° E</p>
<p>Praha, Česká republika</p>
</div>
</div>
<div className="admin-card">
<div className="admin-card-body">
<h3 style={{ marginBottom: "0.5rem" }}>Čas záznamu</h3>
<p>1. 1. 2024 08:00</p>
<p>Přesnost: 10 m</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,52 +0,0 @@
export default function AuditLogFixture() {
return (
<div>
<div className="admin-page-header">
<div>
<h1 className="admin-page-title">Audit log</h1>
<p className="admin-page-subtitle">Záznam změn v systému</p>
</div>
</div>
<div className="admin-card">
<div className="admin-card-body">
<div
className="admin-search-bar mb-4"
style={{ display: "flex", gap: "0.5rem" }}
>
<input className="admin-form-input" placeholder="" />
<select className="admin-form-select" />
<select className="admin-form-select" />
</div>
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Čas</th>
<th>Uživatel</th>
<th>Akce</th>
<th>Entita</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
{Array.from({ length: 5 }, (_, i) => (
<tr key={i}>
<td className="admin-mono">1. 1. 2024 10:00</td>
<td>admin</td>
<td>
<span className="admin-badge admin-badge-create">
Vytvoření
</span>
</td>
<td>Faktura</td>
<td style={{ maxWidth: 300 }}>Nová faktura FV-2024-001</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,69 +0,0 @@
export default function CompanySettingsFixture() {
return (
<div>
<div className="admin-page-header">
<div>
<h1 className="admin-page-title">Nastavení firmy</h1>
<p className="admin-page-subtitle">Firemní údaje a bankovní účty</p>
</div>
<button className="admin-btn admin-btn-primary">
Uložit nastavení
</button>
</div>
<div className="admin-settings-grid">
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">Firemní údaje</h3>
</div>
<div className="admin-card-body">
<div className="admin-form">
<label className="admin-form-label">Název firmy</label>
<input
className="admin-form-input"
readOnly
value="BOHA s.r.o."
/>
<div className="admin-form-row">
<label className="admin-form-label">Ulice</label>
<input
className="admin-form-input"
readOnly
value="Hlavní 123"
/>
<label className="admin-form-label">Město</label>
<input className="admin-form-input" readOnly value="Praha" />
</div>
</div>
</div>
</div>
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">Bankovní účty</h3>
</div>
<div className="admin-card-body">
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Název</th>
<th>Banka</th>
<th>Číslo účtu</th>
<th>Měna</th>
</tr>
</thead>
<tbody>
<tr>
<td>Hlavní účet</td>
<td>ČSOB</td>
<td className="admin-mono">123456/0300</td>
<td>CZK</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,63 +0,0 @@
export default function DashSessionsFixture() {
return (
<div className="admin-card">
<div
className="admin-card-header"
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "0.75rem",
}}
>
<h2 className="admin-card-title">Přihlášená zařízení</h2>
<button className="admin-btn admin-btn-secondary admin-btn-sm">
Odhlásit ostatní
</button>
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
<div className="dash-sessions-list">
{Array.from({ length: 3 }, (_, i) => (
<div
key={i}
className={`dash-session-item${i === 0 ? " dash-session-item-current" : ""}`}
>
<div className="dash-session-icon">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
</div>
<div className="dash-session-info">
<div className="dash-session-device">
Chrome na Windows
{i === 0 && (
<span
className="admin-badge admin-badge-success"
style={{ marginLeft: "0.5rem" }}
>
Aktuální
</span>
)}
</div>
<div className="dash-session-meta">
<span>192.168.1.100</span>
<span className="dash-session-meta-separator">|</span>
<span>před 2 hodinami</span>
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -1,94 +0,0 @@
export default function DashboardFixture() {
return (
<div>
<div className="admin-page-header">
<div>
<h1 className="admin-page-title">Dashboard</h1>
<p className="admin-page-subtitle">Přehled</p>
</div>
</div>
<div
className="dash-kpi-grid"
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: "1rem",
marginBottom: "1rem",
}}
>
{["Nabídky", "Objednávky", "Faktury", "Projekty"].map((label, i) => (
<div
key={i}
className="dash-kpi-card"
style={{
padding: "1.25rem",
borderRadius: 10,
background: "var(--bg-secondary)",
}}
>
<div style={{ fontSize: "0.875rem", marginBottom: "0.25rem" }}>
{label}
</div>
<div style={{ fontSize: "1.5rem", fontWeight: 600 }}>12</div>
<div style={{ fontSize: "0.75rem" }}>tento měsíc</div>
</div>
))}
</div>
<div
className="dash-quick-actions"
style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem" }}
>
{["Nová nabídka", "Nová faktura", "Zapsat docházku"].map((label, i) => (
<button
key={i}
className="admin-btn admin-btn-secondary"
style={{ flex: 1 }}
>
{label}
</button>
))}
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "2fr 1fr",
gap: "1rem",
marginBottom: "1rem",
}}
>
<div className="admin-card" style={{ minHeight: 320 }}>
<div className="admin-card-body">
<h3 className="admin-card-title">Docházka dnes</h3>
<div style={{ height: 200 }} />
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div className="admin-card" style={{ minHeight: 150 }}>
<div className="admin-card-body">
<h3 className="admin-card-title">Aktivita</h3>
</div>
</div>
<div className="admin-card" style={{ minHeight: 150 }}>
<div className="admin-card-body">
<h3 className="admin-card-title">Profil</h3>
</div>
</div>
</div>
</div>
<div
style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1rem" }}
>
<div className="admin-card" style={{ minHeight: 200 }}>
<div className="admin-card-body">
<h3 className="admin-card-title">Relace</h3>
</div>
</div>
<div className="admin-card" style={{ minHeight: 200 }}>
<div className="admin-card-body">
<h3 className="admin-card-title">Poslední aktivity</h3>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,86 +0,0 @@
export default function InvoiceDetailFixture() {
return (
<div>
<div className="admin-page-header">
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<a href="/invoices" className="admin-btn-icon">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</a>
<div>
<h1 className="admin-page-title">FV-2024-001</h1>
</div>
</div>
<div className="admin-page-actions">
<button className="admin-btn admin-btn-primary">Uložit</button>
<button className="admin-btn admin-btn-secondary">Zaplaceno</button>
<button className="admin-btn admin-btn-secondary">Smazat</button>
</div>
</div>
<div className="admin-card" style={{ marginBottom: "1rem" }}>
<div className="admin-card-body">
<div
className="admin-form-row"
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "1rem",
}}
>
<div className="admin-form-group">
<label className="admin-form-label">Zákazník</label>
<div>Firma s.r.o.</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Stav</label>
<span className="admin-badge admin-badge-invoice-issued">
Vystavena
</span>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Datum vystavení</label>
<div>1. 1. 2024</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Datum splatnosti</label>
<div>15. 1. 2024</div>
</div>
</div>
</div>
</div>
<div className="admin-card">
<div className="admin-card-body">
<h3 className="admin-card-title">Položky</h3>
<table className="admin-table">
<thead>
<tr>
<th>Položka</th>
<th>Množství</th>
<th>Cena</th>
<th>Celkem</th>
</tr>
</thead>
<tbody>
{Array.from({ length: 3 }, (_, i) => (
<tr key={i}>
<td>Služba {i + 1}</td>
<td>1</td>
<td>10 000 </td>
<td>10 000 </td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -1,83 +0,0 @@
export default function InvoicesFixture() {
return (
<div>
<div className="admin-page-header">
<div>
<h1 className="admin-page-title">Faktury</h1>
<p className="admin-page-subtitle">15 faktur</p>
</div>
</div>
<div
className="dash-kpi-grid"
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: "1rem",
marginBottom: "1rem",
}}
>
{["Vystaveno", "Zaplaceno", "Po splatnosti", "Celkem"].map(
(label, i) => (
<div
key={i}
className="dash-kpi-card"
style={{
padding: "1.25rem",
borderRadius: 10,
background: "var(--bg-secondary)",
}}
>
<div style={{ fontSize: "0.875rem", marginBottom: "0.25rem" }}>
{label}
</div>
<div style={{ fontSize: "1.5rem", fontWeight: 600 }}>
{i * 5 + 3}
</div>
</div>
),
)}
</div>
<div className="admin-card">
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<input className="admin-form-input" placeholder="" />
</div>
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Číslo</th>
<th>Zákazník</th>
<th>Stav</th>
<th>Datum</th>
<th className="text-right">Částka</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{Array.from({ length: 5 }, (_, i) => (
<tr key={i}>
<td className="admin-mono">FV-2024-00{i + 1}</td>
<td>Firma s.r.o.</td>
<td>
<span className="admin-badge admin-badge-invoice-issued">
Vystaveno
</span>
</td>
<td className="admin-mono">1. 1. 2024</td>
<td className="admin-mono text-right">50 000 </td>
<td>
<div className="admin-table-actions">
<button className="admin-btn-icon">👁</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,51 +0,0 @@
export default function LeaveApprovalFixture() {
return (
<div>
<div className="admin-page-header">
<div>
<h1 className="admin-page-title">Schvalování dovolené</h1>
<p className="admin-page-subtitle">2 čekající</p>
</div>
</div>
<div className="admin-card">
<div className="admin-card-body">
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Uživatel</th>
<th>Typ</th>
<th>Od</th>
<th>Do</th>
<th>Dní</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{Array.from({ length: 3 }, (_, i) => (
<tr key={i}>
<td>Jan Novák</td>
<td>Dovolená</td>
<td className="admin-mono">1. 7. 2024</td>
<td className="admin-mono">5. 7. 2024</td>
<td>5</td>
<td>
<div className="admin-table-actions">
<button className="admin-btn admin-btn-sm admin-btn-primary">
Schválit
</button>
<button className="admin-btn admin-btn-sm admin-btn-secondary">
Zamítnout
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,53 +0,0 @@
export default function LeaveRequestsFixture() {
return (
<div>
<div className="admin-page-header">
<div>
<h1 className="admin-page-title">Žádosti o dovolenou</h1>
<p className="admin-page-subtitle">3 žádosti</p>
</div>
<button className="admin-btn admin-btn-primary">+ Nová žádost</button>
</div>
<div className="admin-card">
<div className="admin-card-body">
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Uživatel</th>
<th>Typ</th>
<th>Od</th>
<th>Do</th>
<th>Dní</th>
<th>Stav</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{Array.from({ length: 3 }, (_, i) => (
<tr key={i}>
<td>Jan Novák</td>
<td>Dovolená</td>
<td className="admin-mono">1. 7. 2024</td>
<td className="admin-mono">5. 7. 2024</td>
<td>5</td>
<td>
<span className="admin-badge admin-badge-pending">
Čeká
</span>
</td>
<td>
<div className="admin-table-actions">
<button className="admin-btn-icon">Zrušit</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,60 +0,0 @@
export default function OfferDetailFixture() {
return (
<div>
<div className="admin-page-header">
<button className="admin-btn-icon"></button>
<h1 className="admin-page-title">NAB-2024-001</h1>
<button className="admin-btn admin-btn-primary">Uložit</button>
</div>
<div className="admin-card">
<div className="admin-card-body">
<div
className="admin-form-row"
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "1rem",
}}
>
<div className="admin-form-group">
<label className="admin-form-label">Číslo nabídky</label>
<div className="admin-form-input">NAB-2024-001</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Zákazník</label>
<div className="admin-form-input">Firma s.r.o.</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Datum</label>
<div className="admin-form-input">1. 1. 2024</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Platnost do</label>
<div className="admin-form-input">31. 1. 2024</div>
</div>
</div>
<table className="admin-table" style={{ marginTop: "1rem" }}>
<thead>
<tr>
<th>Položka</th>
<th>Množství</th>
<th>Cena</th>
<th>Celkem</th>
</tr>
</thead>
<tbody>
{Array.from({ length: 3 }, (_, i) => (
<tr key={i}>
<td>Položka {i + 1}</td>
<td>1</td>
<td>10 000 </td>
<td>10 000 </td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -1,49 +0,0 @@
export default function OffersCustomersFixture() {
return (
<div>
<div className="admin-page-header">
<div>
<h1 className="admin-page-title">Zákazníci</h1>
<p className="admin-page-subtitle">8 zákazníků</p>
</div>
<button className="admin-btn admin-btn-primary">
+ Přidat zákazníka
</button>
</div>
<div className="admin-card">
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<input className="admin-form-input" placeholder="" />
</div>
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Název</th>
<th>Město</th>
<th>IČO</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{Array.from({ length: 5 }, (_, i) => (
<tr key={i}>
<td>Firma s.r.o.</td>
<td>Praha</td>
<td className="admin-mono">12345678</td>
<td>
<div className="admin-table-actions">
<button className="admin-btn-icon"></button>
<button className="admin-btn-icon danger">🗑</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,54 +0,0 @@
export default function OffersFixture() {
return (
<div>
<div className="admin-page-header">
<div>
<h1 className="admin-page-title">Nabídky</h1>
<p className="admin-page-subtitle">12 nabídek</p>
</div>
</div>
<div className="admin-card">
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<input className="admin-form-input" placeholder="" />
</div>
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Číslo</th>
<th>Zákazník</th>
<th>Stav</th>
<th>Datum</th>
<th className="text-right">Celkem</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{Array.from({ length: 5 }, (_, i) => (
<tr key={i}>
<td className="admin-mono">NAB-2024-00{i + 1}</td>
<td>Firma s.r.o.</td>
<td>
<span className="admin-badge admin-badge-offer-active">
Aktivní
</span>
</td>
<td className="admin-mono">1. 1. 2024</td>
<td className="admin-mono text-right fw-500">100 000 </td>
<td>
<div className="admin-table-actions">
<button className="admin-btn-icon">👁</button>
<button className="admin-btn-icon"></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,36 +0,0 @@
export default function OffersTemplatesFixture() {
return (
<div className="admin-card">
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<input className="admin-form-input" placeholder="" />
</div>
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Název</th>
<th>Cena</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{Array.from({ length: 5 }, (_, i) => (
<tr key={i}>
<td>Šablona {i + 1}</td>
<td className="admin-mono text-right">1 000 </td>
<td>
<div className="admin-table-actions">
<button className="admin-btn-icon"></button>
<button className="admin-btn-icon danger">🗑</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -1,89 +0,0 @@
export default function OrderDetailFixture() {
return (
<div>
<div className="admin-page-header">
<div className="admin-page-header-left">
<Link
to="/orders"
className="admin-btn-icon"
style={{ marginRight: "0.5rem" }}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</Link>
<div>
<h1 className="admin-page-title">OBJ-2024-001</h1>
</div>
</div>
<div className="admin-page-actions">
<button className="admin-btn admin-btn-primary">Uložit</button>
<button className="admin-btn admin-btn-secondary">Stornovat</button>
</div>
</div>
<div className="admin-card" style={{ marginBottom: "1rem" }}>
<div className="admin-card-body">
<div
className="admin-form-row"
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "1rem",
}}
>
<div className="admin-form-group">
<label className="admin-form-label">Zákazník</label>
<div>Firma s.r.o.</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Stav</label>
<span className="admin-badge admin-badge-order-realizace">
V realizaci
</span>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Datum vytvoření</label>
<div>1. 1. 2024</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Celkem</label>
<div className="fw-500">50 000 </div>
</div>
</div>
</div>
</div>
<div className="admin-card">
<div className="admin-card-body">
<h3 className="admin-card-title">Položky</h3>
<table className="admin-table">
<thead>
<tr>
<th>Položka</th>
<th>Množství</th>
<th>Cena</th>
<th>Celkem</th>
</tr>
</thead>
<tbody>
{Array.from({ length: 3 }, (_, i) => (
<tr key={i}>
<td>Položka {i + 1}</td>
<td>1</td>
<td>10 000 </td>
<td>10 000 </td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -1,55 +0,0 @@
export default function OrdersFixture() {
return (
<div>
<div className="admin-page-header">
<div>
<h1 className="admin-page-title">Objednávky</h1>
<p className="admin-page-subtitle">8 objednávek</p>
</div>
</div>
<div className="admin-card">
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<input className="admin-form-input" placeholder="" />
</div>
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Číslo</th>
<th>Nabídka</th>
<th>Zákazník</th>
<th>Stav</th>
<th>Datum</th>
<th className="text-right">Celkem</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{Array.from({ length: 5 }, (_, i) => (
<tr key={i}>
<td className="admin-mono">OBJ-2024-00{i + 1}</td>
<td>NAB-2024-00{i + 1}</td>
<td>Firma s.r.o.</td>
<td>
<span className="admin-badge admin-badge-order-realizace">
V realizaci
</span>
</td>
<td className="admin-mono">1. 1. 2024</td>
<td className="admin-mono text-right fw-500">50 000 </td>
<td>
<div className="admin-table-actions">
<button className="admin-btn-icon">👁</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,70 +0,0 @@
export default function ProjectDetailFixture() {
return (
<div>
<div className="admin-page-header">
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<a href="/projects" className="admin-btn-icon">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</a>
<div>
<h1 className="admin-page-title">PRJ-001 Projekt Alpha</h1>
</div>
</div>
<div className="admin-page-actions">
<button className="admin-btn admin-btn-primary">Uložit</button>
<button className="admin-btn admin-btn-secondary">Smazat</button>
</div>
</div>
<div className="admin-card" style={{ marginBottom: "1rem" }}>
<div className="admin-card-body">
<div
className="admin-form-row"
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "1rem",
}}
>
<div className="admin-form-group">
<label className="admin-form-label">Název projektu</label>
<input
className="admin-form-input"
value="Projekt Alpha"
readOnly
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Stav</label>
<select className="admin-form-select">
<option>Aktivní</option>
</select>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Začátek</label>
<input type="date" className="admin-form-input" readOnly />
</div>
<div className="admin-form-group">
<label className="admin-form-label">Konec</label>
<input type="date" className="admin-form-input" readOnly />
</div>
</div>
</div>
</div>
<div className="admin-card" style={{ marginBottom: "1rem" }}>
<div className="admin-card-body">
<h3 className="admin-card-title">Poznámky</h3>
<textarea className="admin-form-input" rows={4} readOnly />
</div>
</div>
</div>
);
}

View File

@@ -1,53 +0,0 @@
export default function ProjectFileManagerFixture() {
return (
<div className="admin-card">
<div className="admin-card-body">
<h3 className="admin-card-title">Soubory</h3>
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
marginBottom: "0.75rem",
fontSize: "0.875rem",
color: "var(--text-secondary)",
}}
>
<span>Projekt</span>
<span>/</span>
<span>Dokumentace</span>
</div>
{Array.from({ length: 4 }, (_, i) => (
<div
key={i}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.5rem 0",
borderBottom: i < 3 ? "1px solid var(--border-color)" : "none",
}}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<span style={{ flex: 1 }}>dokument_{i + 1}.pdf</span>
<span
style={{ color: "var(--text-secondary)", fontSize: "0.8rem" }}
>
2.{i + 1} MB
</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -1,51 +0,0 @@
export default function ProjectsFixture() {
return (
<div>
<div className="admin-page-header">
<div>
<h1 className="admin-page-title">Projekty</h1>
<p className="admin-page-subtitle">6 projektů</p>
</div>
</div>
<div className="admin-card">
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<input className="admin-form-input" placeholder="" />
</div>
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Číslo</th>
<th>Název</th>
<th>Zákazník</th>
<th>Stav</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{Array.from({ length: 5 }, (_, i) => (
<tr key={i}>
<td className="admin-mono">PRJ-2024-00{i + 1}</td>
<td>Projekt Alpha</td>
<td>Firma s.r.o.</td>
<td>
<span className="admin-badge admin-badge-project-active">
Aktivní
</span>
</td>
<td>
<div className="admin-table-actions">
<button className="admin-btn-icon">👁</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,80 +0,0 @@
export default function ReceivedInvoicesFixture() {
return (
<div>
<div className="admin-page-header">
<div>
<h1 className="admin-page-title">Přijaté faktury</h1>
<p className="admin-page-subtitle">8 faktur</p>
</div>
</div>
<div
className="dash-kpi-grid"
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: "1rem",
marginBottom: "1rem",
}}
>
{["K úhradě", "Zaplaceno", "Po splatnosti", "Celkem"].map(
(label, i) => (
<div
key={i}
className="dash-kpi-card"
style={{
padding: "1.25rem",
borderRadius: 10,
background: "var(--bg-secondary)",
}}
>
<div style={{ fontSize: "0.875rem", marginBottom: "0.25rem" }}>
{label}
</div>
<div style={{ fontSize: "1.5rem", fontWeight: 600 }}>
{i * 3 + 2}
</div>
</div>
),
)}
</div>
<div className="admin-card">
<div className="admin-card-body">
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Číslo</th>
<th>Dodavatel</th>
<th>Částka</th>
<th>Datum</th>
<th>Stav</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{Array.from({ length: 5 }, (_, i) => (
<tr key={i}>
<td className="admin-mono">PF-2024-00{i + 1}</td>
<td>Dodavatel s.r.o.</td>
<td className="admin-mono text-right">10 000 </td>
<td className="admin-mono">1. 1. 2024</td>
<td>
<span className="admin-badge admin-badge-invoice-issued">
K úhradě
</span>
</td>
<td>
<div className="admin-table-actions">
<button className="admin-btn-icon"></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,70 +0,0 @@
export default function SettingsFixture() {
return (
<div>
<div className="admin-page-header">
<div>
<h1 className="admin-page-title">Nastavení</h1>
</div>
</div>
<div className="admin-tab-bar">
<button className="admin-tab active">Role</button>
<button className="admin-tab">Systém</button>
</div>
<div className="admin-card">
<div className="admin-card-body">
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Název role</th>
<th>Popis</th>
<th>Oprávnění</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{Array.from({ length: 3 }, (_, i) => (
<tr key={i}>
<td className="fw-500">admin</td>
<td>Správa celého systému</td>
<td>
<span className="admin-badge admin-badge-info">
všechna
</span>
</td>
<td>
<div className="admin-table-actions">
<button className="admin-btn-icon" title="Upravit">
&#9998;
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<div className="admin-card">
<div className="admin-card-header">
<h2 className="admin-card-title">Docházka</h2>
</div>
<div className="admin-card-body">
<div className="admin-form">
<div className="admin-form-row">
<label className="admin-form-label">
Limit pro přestávku (hodiny)
</label>
<input className="admin-form-input" readOnly value="6" />
<label className="admin-form-label">
Délka krátké přestávky (min)
</label>
<input className="admin-form-input" readOnly value="15" />
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,44 +0,0 @@
export default function TripsAdminFixture() {
return (
<div>
<div className="admin-page-header">
<div>
<h1 className="admin-page-title">Správa jízd</h1>
<p className="admin-page-subtitle">10 jízd</p>
</div>
</div>
<div className="admin-card">
<div className="admin-card-body">
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Vozidlo</th>
<th>Uživatel</th>
<th>Km</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{Array.from({ length: 5 }, (_, i) => (
<tr key={i}>
<td className="admin-mono">1. 1. 2024</td>
<td>Škoda Octavia</td>
<td>Jan Novák</td>
<td className="admin-mono">200</td>
<td>
<div className="admin-table-actions">
<button className="admin-btn-icon"></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,132 +0,0 @@
export default function TripsFixture() {
return (
<div>
<div className="admin-page-header">
<div>
<h1 className="admin-page-title">Kniha jízd</h1>
<p className="admin-page-subtitle">duben 2025</p>
</div>
<div className="admin-page-actions">
<button className="admin-btn admin-btn-primary">Přidat jízdu</button>
</div>
</div>
<div className="admin-grid admin-grid-4">
<div className="admin-stat-card info">
<div className="admin-stat-icon info">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="20" x2="12" y2="10" />
<line x1="18" y1="20" x2="18" y2="4" />
<line x1="6" y1="20" x2="6" y2="16" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">12</span>
<span className="admin-stat-label">Počet jízd</span>
</div>
</div>
<div className="admin-stat-card">
<div className="admin-stat-icon">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">1 240 km</span>
<span className="admin-stat-label">Celkem naježděno</span>
</div>
</div>
<div className="admin-stat-card success">
<div className="admin-stat-icon success">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
<circle cx="5.5" cy="18" r="2" />
<circle cx="18.5" cy="18" r="2" />
<path d="M8 18h8" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">980 km</span>
<span className="admin-stat-label">Služební</span>
</div>
</div>
<div className="admin-stat-card warning">
<div className="admin-stat-icon warning">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">260 km</span>
<span className="admin-stat-label">Soukromé</span>
</div>
</div>
</div>
<div className="admin-card mt-6">
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Poslední jízdy</h2>
<a className="admin-btn admin-btn-secondary admin-btn-sm">
Zobrazit historii
</a>
</div>
<div className="admin-card-body">
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Vozidlo</th>
<th>Trasa</th>
<th>Vzdálenost</th>
</tr>
</thead>
<tbody>
{Array.from({ length: 4 }, (_, i) => (
<tr key={i}>
<td className="admin-mono">1. 4. 2025</td>
<td>
<span className="admin-badge">1A2 3456</span>
</td>
<td>Praha Brno</td>
<td className="admin-mono">
<strong>200 km</strong>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,40 +0,0 @@
export default function TripsHistoryFixture() {
return (
<div>
<div className="admin-page-header">
<div>
<h1 className="admin-page-title">Historie jízd</h1>
<p className="admin-page-subtitle">10 jízd</p>
</div>
</div>
<div className="admin-card">
<div className="admin-card-body">
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Vozidlo</th>
<th>Uživatel</th>
<th>Trasa</th>
<th>Km</th>
</tr>
</thead>
<tbody>
{Array.from({ length: 5 }, (_, i) => (
<tr key={i}>
<td className="admin-mono">1. 1. 2024</td>
<td>Škoda Octavia</td>
<td>Jan Novák</td>
<td>Praha Brno</td>
<td className="admin-mono">200</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,66 +0,0 @@
export default function UsersFixture() {
return (
<div>
<div className="admin-page-header">
<div>
<h1 className="admin-page-title">Uživatelé</h1>
<p className="admin-page-subtitle">5 uživatelů</p>
</div>
<button className="admin-btn admin-btn-primary">
+ Přidat uživatele
</button>
</div>
<div className="admin-card">
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<input className="admin-form-input" placeholder="" />
</div>
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Uživatel</th>
<th>E-mail</th>
<th>Role</th>
<th>Stav</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{Array.from({ length: 5 }, (_, i) => (
<tr key={i}>
<td>
<div className="admin-table-user">
<div className="admin-table-avatar">A</div>
<div>
<div className="admin-table-name">Jan Novák</div>
<div className="admin-table-username">@jan</div>
</div>
</div>
</td>
<td>jan@email.cz</td>
<td>
<span className="admin-badge admin-badge-admin">
Administrátor
</span>
</td>
<td>
<span className="admin-badge admin-badge-active">
Aktivní
</span>
</td>
<td>
<div className="admin-table-actions">
<button className="admin-btn-icon"></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,48 +0,0 @@
export default function VehiclesFixture() {
return (
<div>
<div className="admin-page-header">
<h1 className="admin-page-title">Vozidla</h1>
<button className="admin-btn admin-btn-primary">
+ Přidat vozidlo
</button>
</div>
<div className="admin-card">
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<input className="admin-form-input" placeholder="" />
</div>
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Značka</th>
<th>Model</th>
<th>SPZ</th>
<th>Rok</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{Array.from({ length: 5 }, (_, i) => (
<tr key={i}>
<td>Škoda</td>
<td>Octavia</td>
<td className="admin-mono">1A2 3456</td>
<td>2024</td>
<td>
<div className="admin-table-actions">
<button className="admin-btn-icon"></button>
<button className="admin-btn-icon danger">🗑</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import apiFetch from "../utils/api"; import apiFetch from "../utils/api";
import { isHoliday } from "../../utils/czech-holidays";
import { import {
calcProjectMinutesTotal, calcProjectMinutesTotal,
calcFormWorkMinutes, calcFormWorkMinutes,
@@ -10,9 +11,17 @@ import {
formatDate, formatDate,
formatMinutes, formatMinutes,
getLeaveTypeName, getLeaveTypeName,
getLeaveTypeBadgeClass,
formatTimeOrDatetimePrint, formatTimeOrDatetimePrint,
calculateWorkMinutesPrint, calculateWorkMinutesPrint,
isWeekendDate,
getDaysInMonth,
shiftDateForMonth,
formatHoursDecimal,
calculateNightMinutes,
normalizeDateStr,
holidaysInMonth,
calculateFreeHolidayHours,
getCzechWeekday,
} from "../utils/attendanceHelpers"; } from "../utils/attendanceHelpers";
import type { import type {
ShiftFormData, ShiftFormData,
@@ -29,7 +38,7 @@ interface AlertContext {
alert: { success: (msg: string) => void; error: (msg: string) => void }; alert: { success: (msg: string) => void; error: (msg: string) => void };
} }
interface AttendanceRecord { export interface AttendanceRecord {
id: number; id: number;
user_id: number; user_id: number;
shift_date: string; shift_date: string;
@@ -39,7 +48,7 @@ interface AttendanceRecord {
departure_time?: string | null; departure_time?: string | null;
break_start?: string | null; break_start?: string | null;
break_end?: string | null; break_end?: string | null;
notes?: string; notes?: string | null;
project_id?: number | null; project_id?: number | null;
project_name?: string; project_name?: string;
project_logs?: Array<{ project_logs?: Array<{
@@ -73,7 +82,6 @@ interface UserTotal {
working: boolean; working: boolean;
vacation_hours: number; vacation_hours: number;
sick_hours: number; sick_hours: number;
holiday_hours: number;
unpaid_hours: number; unpaid_hours: number;
overtime: number; overtime: number;
missing: number; missing: number;
@@ -81,6 +89,16 @@ interface UserTotal {
business_days: number; business_days: number;
worked_hours: number; worked_hours: number;
covered: number; covered: number;
// mzda-style summary fields (set by computeUserTotals)
worked_hours_raw?: number;
odpracovano?: number;
vcetne_svatku?: number;
prescas?: number;
svatek?: number;
weekend_hours?: number;
night_minutes?: number;
worked_holiday_hours?: number; // sum of hours worked ON a holiday
records?: AttendanceRecord[];
} }
interface LeaveBalance { interface LeaveBalance {
@@ -121,6 +139,11 @@ const combineDatetime = (date: string, time: string): string | null =>
/** /**
* Compute per-user totals from raw attendance records. * Compute per-user totals from raw attendance records.
* This replaces the server-side `user_totals` that the PHP backend returned. * This replaces the server-side `user_totals` that the PHP backend returned.
*
* Adds mzda-style summary fields (odpracovano, vcetne_svatku, prescas, svatek,
* weekend_hours, night_minutes) and a fund computed as
* (rawBizDays + holidayCount) × 8 — holidays count as work days for fund
* purposes, per the mzda.pdf model.
*/ */
function computeUserTotals( function computeUserTotals(
records: AttendanceRecord[], records: AttendanceRecord[],
@@ -128,6 +151,7 @@ function computeUserTotals(
month: string, month: string,
): Record<string, UserTotal> { ): Record<string, UserTotal> {
const totals: Record<string, UserTotal> = {}; const totals: Record<string, UserTotal> = {};
const recordsByUser = new Map<number, AttendanceRecord[]>();
for (const rec of records) { for (const rec of records) {
const uid = String(rec.user_id); const uid = String(rec.user_id);
@@ -144,7 +168,6 @@ function computeUserTotals(
working: false, working: false,
vacation_hours: 0, vacation_hours: 0,
sick_hours: 0, sick_hours: 0,
holiday_hours: 0,
unpaid_hours: 0, unpaid_hours: 0,
overtime: 0, overtime: 0,
missing: 0, missing: 0,
@@ -152,15 +175,23 @@ function computeUserTotals(
business_days: 0, business_days: 0,
worked_hours: 0, worked_hours: 0,
covered: 0, covered: 0,
records: [],
}; };
} }
const t = totals[uid]; const t = totals[uid];
t.records!.push(rec);
if (!recordsByUser.has(rec.user_id)) recordsByUser.set(rec.user_id, []);
recordsByUser.get(rec.user_id)!.push(rec);
const leaveType = rec.leave_type || "work"; const leaveType = rec.leave_type || "work";
if (leaveType === "work") { if (leaveType === "work") {
// Only work records contribute to "minutes" (matching PHP calculateUserTotals) // Only work records contribute to "minutes" (matching PHP calculateUserTotals)
t.minutes += calculateWorkMinutes(rec); t.minutes += calculateWorkMinutes({
...rec,
notes: rec.notes ?? undefined,
});
} else { } else {
const leaveHours = Number(rec.leave_hours) || 8; const leaveHours = Number(rec.leave_hours) || 8;
switch (leaveType) { switch (leaveType) {
@@ -170,12 +201,14 @@ function computeUserTotals(
case "sick": case "sick":
t.sick_hours += leaveHours; t.sick_hours += leaveHours;
break; break;
case "holiday":
t.holiday_hours += leaveHours;
break;
case "unpaid": case "unpaid":
t.unpaid_hours += leaveHours; t.unpaid_hours += leaveHours;
break; break;
// "holiday" leave_type is no longer selectable in the UI — it is
// auto-computed from Czech public holidays (see freeHolidayHours
// below). Old records may still exist in the DB; treat them as a
// paid non-work entry and skip so they don't double-count with
// the auto-detected svátek.
} }
} }
@@ -190,7 +223,10 @@ function computeUserTotals(
const yr = parseInt(yearStr, 10); const yr = parseInt(yearStr, 10);
const mo = parseInt(monthStr, 10) - 1; const mo = parseInt(monthStr, 10) - 1;
// Count business days in month (Mon-Fri) // Count Mon-Fri business days in month (INCLUDING holidays).
// The fund is rawBizDays × 8 — holidays stay in the count so the
// user is paid for them via the fund. Svátek (free holiday hours) is
// computed separately as "8h per holiday the user did not work".
let rawBizDays = 0; let rawBizDays = 0;
const cur = new Date(yr, mo, 1); const cur = new Date(yr, mo, 1);
while (cur.getMonth() === mo) { while (cur.getMonth() === mo) {
@@ -199,23 +235,100 @@ function computeUserTotals(
cur.setDate(cur.getDate() + 1); cur.setDate(cur.getDate() + 1);
} }
const holidayDates = holidaysInMonth(yr, mo + 1);
const holidayCount = holidayDates.length;
for (const uid of Object.keys(totals)) { for (const uid of Object.keys(totals)) {
const t = totals[uid]; const t = totals[uid];
// Subtract holiday days from business days for this user const userRecords = recordsByUser.get(Number(uid)) || [];
const holidayDays = Math.round(t.holiday_hours / 8);
const bizDays = Math.max(0, rawBizDays - holidayDays); // Odpracováno: floor(worked / 8) × 8 — round to whole days, drop remainder.
const fund = bizDays * 8; const workedHoursRaw = t.minutes / 60;
const workedHours = Math.round((t.minutes / 60) * 10) / 10; const odpracovano = Math.floor(workedHoursRaw / 8) * 8;
// Covered = worked + vacation + sick (NOT holiday/unpaid — matching PHP)
const leaveHours = t.vacation_hours + t.sick_hours; // So/Ne: sum of worked hours on Sat/Sun (raw, not rounded).
const covered = Math.round((workedHours + leaveHours) * 10) / 10; const weekendHours = userRecords
.filter(
(r) =>
(r.leave_type || "work") === "work" && isWeekendDate(r.shift_date),
)
.reduce((sum, r) => sum + calculateWorkMinutesPrint(r) / 60, 0);
// Práce v noci: minutes in 22:00-06:00 across all work records.
const nightMinutes = userRecords
.filter((r) => (r.leave_type || "work") === "work")
.reduce((sum, r) => sum + calculateNightMinutes(r), 0);
// Svátek (free): 8h per holiday the user did not work.
const freeHolidayHours = calculateFreeHolidayHours(
userRecords,
holidayDates,
);
// Worked holidays: holidays the user DID work (counted toward
// Odpracováno, but their 8h must not flow into Přesčas).
const workedDatesSet = new Set(
userRecords
.filter((r) => (r.leave_type || "work") === "work")
.map((r) => normalizeDateStr(r.shift_date))
.filter(Boolean),
);
let workedHolidayCount = 0;
let workedHolidayHours = 0;
for (const hd of holidayDates) {
if (workedDatesSet.has(hd)) workedHolidayCount++;
}
// Sum the actual hours worked on holiday dates (for the KPI card's
// "fond used" total = real_work + vacation + worked_holiday + free_holiday).
for (const r of userRecords) {
if ((r.leave_type || "work") !== "work") continue;
const d = normalizeDateStr(r.shift_date);
if (d && holidayDates.includes(d)) {
workedHolidayHours += calculateWorkMinutesPrint(r) / 60;
}
}
// Fund = Mon-Fri × 8h. Holidays count as work days (already in rawBizDays).
const fund = rawBizDays * 8;
// Včetně svátků a přesčasů (mzda formula):
// odpracovano + vacation + remainder min(freeHols, workedHols × 8)
//
// Two-way logic matching the print:
// • If the user worked at least one holiday, that worked holiday's
// 8h is in Odpracováno and shouldn't also count as Přesčas. We
// subtract one 8h per worked holiday (capped at the total free
// holiday hours — can't go negative).
// • If the user did NOT work any holiday, no adjustment is needed:
// all unworked holidays are already paid via the Svátek line and
// don't flow into Přesčas, so vcetne_svatku just equals the
// work + vacation + remainder.
const remainder = workedHoursRaw - odpracovano;
const holidayAdj = Math.min(freeHolidayHours, workedHolidayCount * 8);
const vcetneSv = odpracovano - holidayAdj + t.vacation_hours + remainder;
// Přesčas = vcetneSv odpracovano (derived).
const prescas = vcetneSv - odpracovano;
// Legacy fields (kept so existing UI doesn't break).
const workedHours = Math.round(workedHoursRaw * 10) / 10;
const covered = Math.round((workedHours + t.vacation_hours) * 10) / 10;
t.fund = fund; t.fund = fund;
t.business_days = bizDays; t.business_days = rawBizDays;
t.worked_hours = workedHours; t.worked_hours = workedHours;
t.covered = covered; t.covered = covered;
t.missing = Math.max(0, Math.round((fund - covered) * 10) / 10); t.missing = Math.max(0, Math.round((fund - covered) * 10) / 10);
t.overtime = Math.max(0, Math.round((covered - fund) * 10) / 10); t.overtime = Math.max(0, Math.round((covered - fund) * 10) / 10);
t.worked_hours_raw = workedHoursRaw;
t.odpracovano = odpracovano;
t.vcetne_svatku = vcetneSv;
t.prescas = prescas;
t.svatek = freeHolidayHours;
t.weekend_hours = weekendHours;
t.night_minutes = nightMinutes;
t.worked_holiday_hours = workedHolidayHours;
} }
return totals; return totals;
@@ -234,14 +347,6 @@ function escapeHtml(str: string): string {
.replace(/'/g, "&#039;"); .replace(/'/g, "&#039;");
} }
function renderFundStatus(userData: Record<string, any>): string {
if (userData.overtime > 0)
return `<span class="leave-badge badge-overtime">+${escapeHtml(String(userData.overtime))}h přesčas</span>`;
if (userData.missing > 0)
return `<span style="color:#dc2626">${escapeHtml(String(userData.missing))}h</span>`;
return '<span style="color:#16a34a">splněno</span>';
}
function buildProjectLogsHtml(record: Record<string, any>): string { function buildProjectLogsHtml(record: Record<string, any>): string {
if (record.project_logs && record.project_logs.length > 0) { if (record.project_logs && record.project_logs.length > 0) {
return record.project_logs return record.project_logs
@@ -272,94 +377,220 @@ function buildProjectLogsHtml(record: Record<string, any>): string {
return escapeHtml(record.project_name || "—"); return escapeHtml(record.project_name || "—");
} }
function buildLeaveSummaryHtml(
userId: string,
userData: Record<string, any>,
printData: Record<string, any>,
): string {
const bal = printData.leave_balances[userId];
let parts = `<strong>Dovolená ${escapeHtml(String(printData.year))}:</strong> Zbývá ${bal.vacation_remaining.toFixed(1)}h z ${bal.vacation_total}h`;
if (userData.vacation_hours > 0)
parts += ` | <span class="leave-badge badge-vacation">Tento měsíc: ${escapeHtml(String(userData.vacation_hours))}h</span>`;
if (userData.sick_hours > 0)
parts += ` | <span class="leave-badge badge-sick">Nemoc: ${escapeHtml(String(userData.sick_hours))}h</span>`;
if (userData.holiday_hours > 0)
parts += ` | <span class="leave-badge badge-holiday">Svátek: ${escapeHtml(String(userData.holiday_hours))}h</span>`;
if (userData.overtime > 0)
parts += ` | <span class="leave-badge badge-overtime">Přesčas: +${escapeHtml(String(userData.overtime))}h</span>`;
return `<div class="leave-summary">${parts}</div>`;
}
function buildUserSectionHtml( function buildUserSectionHtml(
userId: string, userId: string,
userData: Record<string, any>, userData: Record<string, any>,
printData: Record<string, any>, printData: Record<string, any>,
): string { ): string {
const leaveHtml = printData.leave_balances[userId] // Build a date-keyed lookup of the user's records.
? buildLeaveSummaryHtml(userId, userData, printData) const recordsByDate = new Map<string, Record<string, any>>();
: ""; for (const r of userData.records || []) {
recordsByDate.set(normalizeDateStr(r.shift_date), r);
}
const recordRows = (userData.records || []) // Iterate one row per day of the month.
.map((record: any) => { const [yearStr, monthStr] = printData.month.split("-");
const leaveType = record.leave_type || "work"; const yr = parseInt(yearStr, 10);
const isLeave = leaveType !== "work"; const mo = parseInt(monthStr, 10);
const workMinutes = calculateWorkMinutesPrint(record); const daysInMonth = getDaysInMonth(yr, mo);
const hours = Math.floor(workMinutes / 60);
const mins = workMinutes % 60;
const breakCell =
isLeave || !record.break_start || !record.break_end
? "—"
: `${escapeHtml(formatTimeOrDatetimePrint(record.break_start, record.shift_date))} - ${escapeHtml(formatTimeOrDatetimePrint(record.break_end, record.shift_date))}`;
return `<tr> const userRecords = (userData.records || []) as Record<string, any>[];
<td>${escapeHtml(formatDate(record.shift_date))}</td>
<td><span class="leave-badge ${escapeHtml(getLeaveTypeBadgeClass(leaveType))}">${escapeHtml(getLeaveTypeName(leaveType))}</span></td> const rows: string[] = [];
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = shiftDateForMonth(yr, mo, d);
const record = recordsByDate.get(dateStr);
const weekend = isWeekendDate(dateStr);
const holiday = isHoliday(dateStr);
const leaveType = (record?.leave_type as string) || "work";
const rowClasses = [
weekend && "weekend",
holiday && "holiday",
leaveType === "vacation" && "vacation",
]
.filter(Boolean)
.join(" ");
if (!record) {
rows.push(`<tr class="${rowClasses}">
<td>${escapeHtml(formatDate(dateStr))}</td>
<td>${escapeHtml(getCzechWeekday(dateStr))}</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr>`);
continue;
}
const isLeave = leaveType !== "work";
const workMinutes = calculateWorkMinutesPrint(record);
const hours = Math.floor(workMinutes / 60);
const mins = workMinutes % 60;
const breakCell =
isLeave || !record.break_start || !record.break_end
? "—"
: `${escapeHtml(formatTimeOrDatetimePrint(record.break_start, record.shift_date))} - ${escapeHtml(formatTimeOrDatetimePrint(record.break_end, record.shift_date))}`;
let hoursCell: string;
if (workMinutes > 0 && !isLeave) {
hoursCell = `${hours}:${String(mins).padStart(2, "0")} (${formatHoursDecimal(workMinutes)})`;
} else if (isLeave) {
hoursCell = formatHoursDecimal((Number(record.leave_hours) || 8) * 60);
} else {
hoursCell = "—";
}
rows.push(`<tr class="${rowClasses}">
<td>${escapeHtml(formatDate(dateStr))}</td>
<td>${escapeHtml(getCzechWeekday(dateStr))}</td>
<td>${escapeHtml(getLeaveTypeName(leaveType))}</td>
<td class="text-center">${isLeave ? "—" : escapeHtml(formatTimeOrDatetimePrint(record.arrival_time, record.shift_date))}</td> <td class="text-center">${isLeave ? "—" : escapeHtml(formatTimeOrDatetimePrint(record.arrival_time, record.shift_date))}</td>
<td class="text-center">${breakCell}</td> <td class="text-center">${breakCell}</td>
<td class="text-center">${isLeave ? "—" : escapeHtml(formatTimeOrDatetimePrint(record.departure_time, record.shift_date))}</td> <td class="text-center">${isLeave ? "—" : escapeHtml(formatTimeOrDatetimePrint(record.departure_time, record.shift_date))}</td>
<td class="text-center">${workMinutes > 0 ? `${hours}:${String(mins).padStart(2, "0")}` : "—"}</td> <td class="text-center">${hoursCell}</td>
<td style="font-size:8px">${buildProjectLogsHtml(record)}</td> <td style="font-size:8px">${buildProjectLogsHtml(record)}</td>
<td>${escapeHtml(record.notes || "")}</td> <td>${escapeHtml(record.notes || "")}</td>
</tr>`; </tr>`);
}) }
.join("");
const fundRow = // ----- mzda-style summary numbers (computed here, not from userData,
userData.fund !== null // because the backend's getPrintData only returns the legacy fields). -----
? `<tr>
<td colspan="6" class="text-right">Fond měsíce:</td> // Worked minutes: sum of calculateWorkMinutesPrint across work records.
<td class="text-center">${escapeHtml(String(userData.covered))}h / ${escapeHtml(String(userData.fund))}h</td> let workedMinutes = 0;
<td colspan="2">${renderFundStatus(userData)}</td> let weekendHoursRaw = 0;
</tr>` let nightMinutes = 0;
: ""; for (const r of userRecords) {
const leaveType = r.leave_type || "work";
if (leaveType !== "work") continue;
const m = calculateWorkMinutesPrint(r);
workedMinutes += m;
if (isWeekendDate(r.shift_date)) {
weekendHoursRaw += m / 60;
}
nightMinutes += calculateNightMinutes(r);
}
// Sum of vacation/sick/unpaid hours (in hours, not minutes). The
// "holiday" leave_type is auto-computed from Czech holidays further down
// and no longer selectable in the UI, so it's deliberately omitted here.
let vacationHours = 0,
sickHours = 0;
for (const r of userRecords) {
const lt = r.leave_type || "work";
if (lt === "work") continue;
const h = Number(r.leave_hours) || 8;
if (lt === "vacation") vacationHours += h;
else if (lt === "sick") sickHours += h;
}
// Odpracováno: floor(worked / 8) × 8.
const workedHoursRaw = workedMinutes / 60;
const odpracovano = Math.floor(workedHoursRaw / 8) * 8;
// Free Svátek: 8h per holiday date the user did not work.
const holidayDates = holidaysInMonth(yr, mo);
const holidayCount = holidayDates.length;
const workedDates = new Set(
userRecords
.filter((r) => (r.leave_type || "work") === "work")
.map((r) => normalizeDateStr(r.shift_date))
.filter(Boolean),
);
let freeHolidayHours = 0;
let workedHolidayCount = 0;
for (const hd of holidayDates) {
if (workedDates.has(hd)) workedHolidayCount++;
else freeHolidayHours += 8;
}
// Fund: count Mon-Fri (incl. holidays) × 8h.
let rawBizDays = 0;
const cur = new Date(yr, mo - 1, 1);
while (cur.getMonth() === mo - 1) {
const dow = cur.getDay();
if (dow !== 0 && dow !== 6) rawBizDays++;
cur.setDate(cur.getDate() + 1);
}
const businessDays = rawBizDays;
const fund = businessDays * 8;
// Včetně svátků a přesčasů: floor(worked/8)*8 worked_holiday*8 + vacation + remainder.
// (A worked holiday counts toward Odpracováno, but its 8h should not flow
// into Přesčas — so we subtract it here. Unworked holidays are paid via
// the fund and don't affect this line.)
const remainder = workedHoursRaw - odpracovano;
const vcetneSv =
odpracovano - workedHolidayCount * 8 + vacationHours + remainder;
// Přesčas = #2 - #1.
const prescas = vcetneSv - odpracovano;
const summaryHtml = `<div class="user-summary">
<div class="summary-row">
<span class="summary-label">Odpracováno:</span>
<span class="summary-value">${formatHoursDecimal(odpracovano * 60)}h</span>
</div>
<div class="summary-row">
<span class="summary-label">Odpracováno včetně svátků a přesčasů:</span>
<span class="summary-value">${formatHoursDecimal(vcetneSv * 60)}h</span>
</div>
<div class="summary-row">
<span class="summary-label">Dovolená:</span>
<span class="summary-value">${formatHoursDecimal(vacationHours * 60)}h</span>
</div>
<div class="summary-row">
<span class="summary-label">Přesčas:</span>
<span class="summary-value">${formatHoursDecimal(prescas * 60)}h</span>
</div>
<div class="summary-row">
<span class="summary-label">Svátek:</span>
<span class="summary-value">${formatHoursDecimal(freeHolidayHours * 60)}h</span>
</div>
<div class="summary-row">
<span class="summary-label">So/Ne:</span>
<span class="summary-value">${formatHoursDecimal(weekendHoursRaw * 60)}h</span>
</div>
<div class="summary-row">
<span class="summary-label">Práce v noci:</span>
<span class="summary-value">${formatHoursDecimal(nightMinutes)}h</span>
</div>
<div class="summary-row summary-fund">
<span class="summary-label">Fond měsíce:</span>
<span class="summary-value">${formatHoursDecimal(fund * 60)}h (${businessDays} dnů)</span>
</div>
<div class="legend">
<span class="legend-item"><span class="legend-swatch weekend"></span>Víkend (So/Ne)</span>
<span class="legend-item"><span class="legend-swatch holiday"></span>Svátek</span>
<span class="legend-item"><span class="legend-swatch weekend-holiday"></span>Víkend + svátek</span>
<span class="legend-item"><span class="legend-swatch vacation"></span>Dovolená</span>
</div>
</div>`;
return `<div class="user-section"> return `<div class="user-section">
<div class="user-header"> <div class="user-header">
<h3>${escapeHtml(userData.name)}</h3> <h3>${escapeHtml(userData.name)}</h3>
<span class="total">Odpracováno: ${escapeHtml(formatMinutes(userData.minutes))} h</span> <span class="total">Odpracováno: ${escapeHtml(formatMinutes(userData.minutes))} h</span>
</div> </div>
${leaveHtml}
<table> <table>
<thead><tr> <thead><tr>
<th style="width:70px">Datum</th> <th style="width:55px">Datum</th>
<th style="width:70px">Typ</th> <th style="width:55px">Den</th>
<th class="text-center" style="width:70px">Příchod</th> <th style="width:60px">Typ</th>
<th class="text-center" style="width:90px">Pauza</th> <th class="text-center" style="width:55px">Příchod</th>
<th class="text-center" style="width:70px">Odchod</th> <th class="text-center" style="width:80px">Pauza</th>
<th class="text-center" style="width:80px">Hodiny</th> <th class="text-center" style="width:55px">Odchod</th>
<th class="text-center" style="width:85px">Hodiny</th>
<th>Projekty</th> <th>Projekty</th>
<th>Poznámka</th> <th>Poznámka</th>
</tr></thead> </tr></thead>
<tbody>${recordRows}</tbody> <tbody>${rows.join("")}</tbody>
<tfoot>
<tr>
<td colspan="6" class="text-right">Odpracováno:</td>
<td class="text-center">${escapeHtml(formatMinutes(userData.minutes))} h</td>
<td colspan="2"></td>
</tr>
${fundRow}
</tfoot>
</table> </table>
${summaryHtml}
</div>`; </div>`;
} }
@@ -377,7 +608,13 @@ function buildPrintHtml(
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Docházka - ${escapeHtml(pData.month_name)}</title> <title>Docházka - ${escapeHtml(pData.month_name)}</title>
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
body { body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 11px; line-height: 1.4; color: #000; background: #fff; padding: 15mm; font-size: 11px; line-height: 1.4; color: #000; background: #fff; padding: 15mm;
@@ -407,19 +644,47 @@ function buildPrintHtml(
.user-section th { background: #333; color: #fff; font-weight: 600; font-size: 10px; text-transform: uppercase; } .user-section th { background: #333; color: #fff; font-weight: 600; font-size: 10px; text-transform: uppercase; }
.user-section td { font-size: 10px; } .user-section td { font-size: 10px; }
.user-section tr:nth-child(even) { background: #f9f9f9; } .user-section tr:nth-child(even) { background: #f9f9f9; }
/* Weekend and holiday row highlighting — must come after the
:nth-child(even) rule and use higher specificity (tr.X td) to win. */
.user-section tr.weekend td { background: #fde68a; }
.user-section tr.holiday td { background: #bbf7d0; }
.user-section tr.weekend.holiday td { background: #fcd34d; }
.user-section tr.vacation td { background: #bfdbfe; }
.text-center { text-align: center; } .text-center { text-align: center; }
.text-right { text-align: right; } .text-right { text-align: right; }
.user-section tfoot td { background: #eee; font-weight: 600; }
.leave-badge { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 9px; font-weight: 500; } .leave-badge { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 9px; font-weight: 500; }
.badge-vacation { background: #dbeafe; color: #1d4ed8; } .badge-vacation { background: #dbeafe; color: #1d4ed8; }
.badge-sick { background: #fee2e2; color: #dc2626; } .badge-sick { background: #fee2e2; color: #dc2626; }
.badge-holiday { background: #dcfce7; color: #16a34a; } .badge-holiday { background: #dcfce7; color: #16a34a; }
.badge-unpaid { background: #f3f4f6; color: #6b7280; } .badge-unpaid { background: #f3f4f6; color: #6b7280; }
.badge-overtime { background: #fef3c7; color: #d97706; } .badge-overtime { background: #fef3c7; color: #d97706; }
.leave-summary { .user-summary {
margin-top: 10px; padding: 8px 15px; background: #f9f9f9; margin-top: 10px; padding: 10px 15px;
border: 1px solid #ddd; font-size: 10px; background: #f9f9f9; border: 1px solid #ddd; font-size: 10px;
} }
.user-summary .summary-row {
display: flex; justify-content: space-between; padding: 2px 0;
}
.user-summary .summary-label { color: #555; }
.user-summary .summary-value { font-weight: 600; }
.user-summary .summary-fund {
border-top: 1px solid #ddd; margin-top: 4px; padding-top: 6px;
}
.legend {
display: flex; flex-wrap: wrap; gap: 14px;
margin-top: 8px; padding-top: 6px;
border-top: 1px dashed #ddd;
font-size: 9px; color: #555;
}
.legend-item { display: inline-flex; align-items: center; gap: 5px; }
.legend-swatch {
display: inline-block; width: 12px; height: 12px;
border: 1px solid #333; vertical-align: middle;
}
.legend-swatch.weekend { background: #fde68a; }
.legend-swatch.holiday { background: #bbf7d0; }
.legend-swatch.weekend-holiday { background: #fcd34d; }
.legend-swatch.vacation { background: #bfdbfe; }
.print-wrapper-table { width: 100%; border-collapse: collapse; border: none; } .print-wrapper-table { width: 100%; border-collapse: collapse; border: none; }
.print-wrapper-table > thead > tr > td, .print-wrapper-table > thead > tr > td,
.print-wrapper-table > tbody > tr > td { padding: 0; border: none; background: none; } .print-wrapper-table > tbody > tr > td { padding: 0; border: none; background: none; }
@@ -664,7 +929,15 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
formData: ShiftFormData, formData: ShiftFormData,
): boolean => { ): boolean => {
const totalWork = calcFormWorkMinutes(formData); const totalWork = calcFormWorkMinutes(formData);
const totalProject = calcProjectMinutesTotal(logs); const totalProject = calcProjectMinutesTotal(
logs.map((l) => ({
...l,
project_id:
l.project_id !== "" && l.project_id != null
? Number(l.project_id)
: undefined,
})),
);
if (totalWork > 0 && totalProject !== totalWork) { if (totalWork > 0 && totalProject !== totalWork) {
const wH = Math.floor(totalWork / 60); const wH = Math.floor(totalWork / 60);
const wM = totalWork % 60; const wM = totalWork % 60;
@@ -869,7 +1142,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
const userName = record.users const userName = record.users
? `${record.users.first_name} ${record.users.last_name}`.trim() || ? `${record.users.first_name} ${record.users.last_name}`.trim() ||
record.users.username record.users.username
: ((record as Record<string, unknown>).user_name as string) || : ((record as unknown as Record<string, unknown>).user_name as string) ||
`User #${record.user_id}`; `User #${record.user_id}`;
const enriched = { ...record, user_name: userName }; const enriched = { ...record, user_name: userName };
setEditingRecord(enriched); setEditingRecord(enriched);

View File

@@ -0,0 +1,24 @@
import { useEffect, useState } from "react";
/**
* Returns true when the user has expressed a preference for reduced motion
* (OS-level setting: `prefers-reduced-motion: reduce`).
*
* SSR-safe: starts as `false` (the default state), so the first render uses
* the normal animation. The effect then reconciles with the live matchMedia
* state on the client and re-renders if needed.
*/
export default function useReducedMotion(): boolean {
const [reduced, setReduced] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
setReduced(mq.matches);
const onChange = () => setReduced(mq.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
return reduced;
}

View File

@@ -38,6 +38,7 @@ export interface InvoiceItem {
quantity: number; quantity: number;
unit: string; unit: string;
unit_price: number; unit_price: number;
vat_rate?: number;
is_included_in_total: boolean; is_included_in_total: boolean;
position?: number; position?: number;
} }

View File

@@ -0,0 +1,113 @@
import {
useMutation,
useQueryClient,
type UseMutationOptions,
} from "@tanstack/react-query";
import apiFetch from "../../utils/api";
export interface ApiError extends Error {
status?: number;
}
export type HttpMethod = "POST" | "PUT" | "PATCH" | "DELETE";
interface ApiResponseBody<T> {
success: boolean;
data?: T;
error?: string;
message?: string;
}
async function performMutation<TIn, TOut>(opts: {
url: string | ((input: TIn) => string);
method: HttpMethod | ((input: TIn) => HttpMethod);
body?: TIn;
}): Promise<TOut> {
const url =
typeof opts.url === "function" ? opts.url(opts.body as TIn) : opts.url;
const method =
typeof opts.method === "function"
? opts.method(opts.body as TIn)
: opts.method;
const response = await apiFetch(url, {
method,
headers:
opts.body !== undefined
? { "Content-Type": "application/json" }
: undefined,
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
});
if (response.status === 401) {
const err: ApiError = new Error("Unauthorized");
err.status = 401;
throw err;
}
let result: ApiResponseBody<TOut>;
try {
result = (await response.json()) as ApiResponseBody<TOut>;
} catch {
throw new Error("Invalid JSON response");
}
if (!response.ok || !result.success) {
const err: ApiError = new Error(
result.error || `Request failed (${response.status})`,
);
err.status = response.status;
throw err;
}
return result.data as TOut;
}
export interface ApiMutationOptions<TIn, TOut> {
url: string | ((input: TIn) => string);
method: HttpMethod | ((input: TIn) => HttpMethod);
/** Query-key prefixes to invalidate on success. Broad per CLAUDE.md. */
invalidate?: readonly string[];
}
/**
* Hook that wraps `useMutation` with `apiFetch` and broad-invalidation.
*
* Usage:
* const createCategory = useApiMutation<CategoryForm, WarehouseCategory>({
* url: "/api/admin/warehouse/categories",
* method: "POST",
* invalidate: ["warehouse"],
* });
* createCategory.mutate(form, {
* onSuccess: () => { ... },
* });
*
* The returned `data` from a successful mutation is `TOut`. Errors are thrown
* as `ApiError` (with `.status` attached) so callers can branch on HTTP code.
*/
export function useApiMutation<TIn, TOut>(
opts: ApiMutationOptions<TIn, TOut> &
Omit<UseMutationOptions<TOut, ApiError, TIn>, "mutationFn">,
) {
const { url, method, invalidate, onSuccess, ...rest } = opts;
const queryClient = useQueryClient();
return useMutation<TOut, ApiError, TIn, unknown>({
mutationFn: (input: TIn) =>
performMutation<TIn, TOut>({ url, method, body: input }),
...rest,
onSuccess: (data, variables, onMutateResult, context) => {
if (invalidate) {
for (const key of invalidate) {
queryClient.invalidateQueries({ queryKey: [key] });
}
}
// Forward to user-provided onSuccess with the 4-arg signature expected by TanStack.
(
onSuccess as
| ((d: TOut, v: TIn, r: unknown, c: unknown) => void)
| undefined
)?.(data, variables, onMutateResult, context);
},
});
}

View File

@@ -19,6 +19,7 @@ export interface ScopeSection {
export interface ScopeTemplate { export interface ScopeTemplate {
id: number; id: number;
name: string; name: string;
title?: string;
description?: string; description?: string;
scope_template_sections?: ScopeSection[]; scope_template_sections?: ScopeSection[];
} }
@@ -70,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],
@@ -80,6 +83,9 @@ 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}` : ""}`);
}, },
@@ -127,9 +133,6 @@ export interface OfferDetailData {
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;
items?: OfferItemData[]; items?: OfferItemData[];
sections?: OfferSectionData[]; sections?: OfferSectionData[];
status: string; status: string;
@@ -142,6 +145,9 @@ export const offerDetailOptions = (id: string | undefined) =>
queryKey: ["offers", id], queryKey: ["offers", id],
queryFn: () => jsonQuery<OfferDetailData>(`/api/admin/offers/${id}`), queryFn: () => jsonQuery<OfferDetailData>(`/api/admin/offers/${id}`),
enabled: !!id, enabled: !!id,
// 404s (deleted/missing offers) are not transient. Retrying just spams
// GETs and fires the detail-page redirect toast repeatedly.
retry: false,
}); });
export const offerNextNumberOptions = () => export const offerNextNumberOptions = () =>

View File

@@ -28,6 +28,12 @@ export interface ProjectData {
project_notes?: ProjectNote[]; project_notes?: ProjectNote[];
} }
export interface Project {
id: number;
project_number: string;
name: string;
}
export const projectListOptions = (filters: { export const projectListOptions = (filters: {
search?: string; search?: string;
sort?: string; sort?: string;
@@ -45,7 +51,9 @@ export const projectListOptions = (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(`/api/admin/projects${qs ? `?${qs}` : ""}`); return paginatedJsonQuery<Project>(
`/api/admin/projects${qs ? `?${qs}` : ""}`,
);
}, },
}); });

View File

@@ -48,6 +48,12 @@ export interface CompanySettingsData {
offer_number_pattern?: string; offer_number_pattern?: string;
order_number_pattern?: string; order_number_pattern?: string;
invoice_number_pattern?: string; invoice_number_pattern?: string;
warehouse_receipt_prefix?: string;
warehouse_receipt_number_pattern?: string;
warehouse_issue_prefix?: string;
warehouse_issue_number_pattern?: string;
warehouse_inventory_prefix?: string;
warehouse_inventory_number_pattern?: string;
// Misc // Misc
app_version?: string; app_version?: string;

View File

@@ -0,0 +1,430 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
// --- Types ---
export interface WarehouseItem {
id: number;
item_number: string | null;
name: string;
description: string | null;
category_id: number | null;
unit: string;
min_quantity: number | null;
is_active: boolean;
notes: string | null;
category?: { id: number; name: string } | null;
total_quantity?: number;
available_quantity?: number;
stock_value?: number;
below_minimum?: boolean;
}
export interface WarehouseReceipt {
id: number;
receipt_number: string | null;
supplier_id: number | null;
delivery_note_number: string | null;
delivery_note_date: string | null;
received_by: number | null;
notes: string | null;
status: "DRAFT" | "CONFIRMED" | "CANCELLED";
created_at: string;
modified_at: string | null;
supplier?: { id: number; name: string } | null;
received_by_user?: {
id: number;
first_name: string;
last_name: string;
} | null;
_count?: { items: number };
items?: WarehouseReceiptItem[];
attachments?: WarehouseReceiptAttachment[];
}
export interface WarehouseReceiptItem {
id: number;
receipt_id: number;
item_id: number;
quantity: number;
unit_price: number;
location_id: number | null;
notes: string | null;
item?: WarehouseItem;
location?: { id: number; code: string; name: string } | null;
}
export interface WarehouseReceiptAttachment {
id: number;
receipt_id: number;
file_name: string;
file_mime: string;
file_size: number;
file_path: string;
created_at: string;
}
export interface WarehouseIssue {
id: number;
issue_number: string | null;
project_id: number;
issued_by: number | null;
notes: string | null;
status: "DRAFT" | "CONFIRMED" | "CANCELLED";
created_at: string;
modified_at: string | null;
project?: { id: number; name: string; project_number: string } | null;
issued_by_user?: { id: number; first_name: string; last_name: string } | null;
_count?: { items: number };
items?: WarehouseIssueItem[];
}
export interface WarehouseIssueItem {
id: number;
issue_id: number;
item_id: number;
batch_id: number;
quantity: number;
location_id: number | null;
reservation_id: number | null;
notes: string | null;
item?: WarehouseItem;
batch?: {
id: number;
quantity: number;
unit_price: number;
received_at: string;
} | null;
location?: { id: number; code: string; name: string } | null;
reservation?: { id: number; quantity: number; remaining_qty: number } | null;
}
export interface WarehouseReservation {
id: number;
item_id: number;
project_id: number;
quantity: number;
remaining_qty: number;
reserved_by: number | null;
notes: string | null;
status: "ACTIVE" | "FULFILLED" | "CANCELLED";
created_at: string;
modified_at: string | null;
item?: WarehouseItem;
project?: { id: number; name: string } | null;
reserved_by_user?: {
id: number;
first_name: string;
last_name: string;
} | null;
}
export interface WarehouseInventorySession {
id: number;
session_number: string | null;
notes: string | null;
status: "DRAFT" | "CONFIRMED";
created_at: string;
modified_at: string | null;
items?: WarehouseInventoryItem[];
}
export interface WarehouseInventoryItem {
id: number;
session_id: number;
item_id: number;
location_id: number | null;
system_qty: number;
actual_qty: number;
difference: number;
notes: string | null;
item?: WarehouseItem;
location?: { id: number; code: string; name: string } | null;
}
export interface WarehouseCategory {
id: number;
name: string;
description: string | null;
sort_order: number;
}
export interface WarehouseLocation {
id: number;
code: string;
name: string;
description: string | null;
is_active: boolean;
}
export interface WarehouseSupplier {
id: number;
name: string;
ico: string | null;
dic: string | null;
contact_person: string | null;
email: string | null;
phone: string | null;
address: string | null;
notes: string | null;
is_active: boolean;
}
export interface MovementLogRow {
date: string;
type: string;
document_number: string;
item_name: string;
quantity: number;
unit_price: number;
supplier_or_project: string;
}
// --- Query Options ---
export const warehouseItemListOptions = (filters: {
search?: string;
page?: number;
perPage?: number;
sort?: string;
order?: string;
category_id?: number;
}) =>
queryOptions({
queryKey: ["warehouse", "items", "list", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search);
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
if (filters.sort) params.set("sort", filters.sort);
if (filters.order) params.set("order", filters.order);
if (filters.category_id)
params.set("category_id", String(filters.category_id));
const qs = params.toString();
return paginatedJsonQuery<WarehouseItem>(
`/api/admin/warehouse/items${qs ? `?${qs}` : ""}`,
);
},
});
export const warehouseItemDetailOptions = (
id: string | undefined,
enabled?: boolean,
) =>
queryOptions({
queryKey: ["warehouse", "items", id],
queryFn: () => jsonQuery<WarehouseItem>(`/api/admin/warehouse/items/${id}`),
enabled: enabled ?? !!id,
});
export const warehouseCategoryListOptions = () =>
queryOptions({
queryKey: ["warehouse", "categories"],
queryFn: () =>
jsonQuery<WarehouseCategory[]>("/api/admin/warehouse/categories"),
});
export const warehouseSupplierListOptions = (filters: {
search?: string;
page?: number;
perPage?: number;
}) =>
queryOptions({
queryKey: ["warehouse", "suppliers", "list", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search);
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
const qs = params.toString();
return paginatedJsonQuery<WarehouseSupplier>(
`/api/admin/warehouse/suppliers${qs ? `?${qs}` : ""}`,
);
},
});
export const warehouseLocationListOptions = (filters?: {
active?: "true" | "false" | "all";
}) =>
queryOptions({
queryKey: ["warehouse", "locations", filters ?? { active: "true" }],
queryFn: () => {
const active = filters?.active ?? "true";
return jsonQuery<WarehouseLocation[]>(
`/api/admin/warehouse/locations?active=${active}`,
);
},
});
export const warehouseReceiptListOptions = (filters: {
search?: string;
page?: number;
perPage?: number;
status?: string;
supplier_id?: number;
date_from?: string;
date_to?: string;
}) =>
queryOptions({
queryKey: ["warehouse", "receipts", "list", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search);
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
if (filters.status) params.set("status", filters.status);
if (filters.supplier_id)
params.set("supplier_id", String(filters.supplier_id));
if (filters.date_from) params.set("date_from", filters.date_from);
if (filters.date_to) params.set("date_to", filters.date_to);
const qs = params.toString();
return paginatedJsonQuery<WarehouseReceipt>(
`/api/admin/warehouse/receipts${qs ? `?${qs}` : ""}`,
);
},
});
export const warehouseReceiptDetailOptions = (id: string | undefined) =>
queryOptions({
queryKey: ["warehouse", "receipts", id],
queryFn: () =>
jsonQuery<WarehouseReceipt>(`/api/admin/warehouse/receipts/${id}`),
enabled: !!id,
});
export const warehouseIssueListOptions = (filters: {
search?: string;
page?: number;
perPage?: number;
status?: string;
project_id?: number;
date_from?: string;
date_to?: string;
}) =>
queryOptions({
queryKey: ["warehouse", "issues", "list", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search);
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
if (filters.status) params.set("status", filters.status);
if (filters.project_id)
params.set("project_id", String(filters.project_id));
if (filters.date_from) params.set("date_from", filters.date_from);
if (filters.date_to) params.set("date_to", filters.date_to);
const qs = params.toString();
return paginatedJsonQuery<WarehouseIssue>(
`/api/admin/warehouse/issues${qs ? `?${qs}` : ""}`,
);
},
});
export const warehouseIssueDetailOptions = (id: string | undefined) =>
queryOptions({
queryKey: ["warehouse", "issues", id],
queryFn: () =>
jsonQuery<WarehouseIssue>(`/api/admin/warehouse/issues/${id}`),
enabled: !!id,
});
export const warehouseReservationListOptions = (filters: {
page?: number;
perPage?: number;
item_id?: number;
project_id?: number;
status?: string;
}) =>
queryOptions({
queryKey: ["warehouse", "reservations", "list", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
if (filters.item_id) params.set("item_id", String(filters.item_id));
if (filters.project_id)
params.set("project_id", String(filters.project_id));
if (filters.status) params.set("status", filters.status);
const qs = params.toString();
return paginatedJsonQuery<WarehouseReservation>(
`/api/admin/warehouse/reservations${qs ? `?${qs}` : ""}`,
);
},
});
export const warehouseInventoryListOptions = (filters: {
page?: number;
perPage?: number;
status?: string;
}) =>
queryOptions({
queryKey: ["warehouse", "inventory", "list", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
if (filters.status) params.set("status", filters.status);
const qs = params.toString();
return paginatedJsonQuery<WarehouseInventorySession>(
`/api/admin/warehouse/inventory-sessions${qs ? `?${qs}` : ""}`,
);
},
});
export const warehouseInventoryDetailOptions = (id: string | undefined) =>
queryOptions({
queryKey: ["warehouse", "inventory", id],
queryFn: () =>
jsonQuery<WarehouseInventorySession>(
`/api/admin/warehouse/inventory-sessions/${id}`,
),
enabled: !!id,
});
export const warehouseStockStatusOptions = (filters?: {
category_id?: number;
}) =>
queryOptions({
queryKey: ["warehouse", "stock-status", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters?.category_id)
params.set("category_id", String(filters.category_id));
const qs = params.toString();
return jsonQuery<WarehouseItem[]>(
`/api/admin/warehouse/reports/stock-status${qs ? `?${qs}` : ""}`,
);
},
});
export const warehouseBelowMinimumOptions = () =>
queryOptions({
queryKey: ["warehouse", "below-minimum"],
queryFn: () =>
jsonQuery<WarehouseItem[]>("/api/admin/warehouse/reports/below-minimum"),
});
export const warehouseMovementLogOptions = (filters?: {
limit?: number;
type?: string;
project_id?: number;
date_from?: string;
date_to?: string;
}) =>
queryOptions({
queryKey: ["warehouse", "movement-log", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters?.limit) params.set("limit", String(filters.limit));
if (filters?.type) params.set("type", filters.type);
if (filters?.project_id)
params.set("project_id", String(filters.project_id));
if (filters?.date_from) params.set("date_from", filters.date_from);
if (filters?.date_to) params.set("date_to", filters.date_to);
const qs = params.toString();
return jsonQuery<MovementLogRow[]>(
`/api/admin/warehouse/reports/movement-log${qs ? `?${qs}` : ""}`,
);
},
});

View File

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

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
@@ -16,8 +16,8 @@ import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import apiFetch from "../utils/api"; import apiFetch from "../utils/api";
import { jsonQuery } from "../lib/apiAdapter"; import { jsonQuery } from "../lib/apiAdapter";
import { Skeleton } from "boneyard-js/react"; import { useApiMutation } from "../lib/queries/mutations";
import AttendanceFixture from "../fixtures/AttendanceFixture"; import { czechPlural } from "../utils/formatters";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
@@ -65,7 +65,6 @@ interface MonthlyFund {
leave_hours: number; leave_hours: number;
vacation_hours: number; vacation_hours: number;
sick_hours: number; sick_hours: number;
holiday_hours: number;
unpaid_hours: number; unpaid_hours: number;
} }
@@ -79,12 +78,6 @@ interface AttendanceData {
active_project_id: number | null; active_project_id: number | null;
} }
function pluralizeDays(n: number) {
if (n === 1) return "den";
if (n >= 2 && n <= 4) return "dny";
return "dnů";
}
function getFundBarBackground(fund: MonthlyFund) { function getFundBarBackground(fund: MonthlyFund) {
if (fund.overtime > 0) if (fund.overtime > 0)
return "linear-gradient(135deg, var(--warning), #d97706)"; return "linear-gradient(135deg, var(--warning), #d97706)";
@@ -96,7 +89,6 @@ function getFundBarBackground(fund: MonthlyFund) {
export default function Attendance() { export default function Attendance() {
const alert = useAlert(); const alert = useAlert();
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const statusQuery = useQuery({ const statusQuery = useQuery({
queryKey: ["attendance", "status"], queryKey: ["attendance", "status"],
@@ -109,6 +101,41 @@ export default function Attendance() {
jsonQuery<Project[]>("/api/admin/attendance?action=projects"), jsonQuery<Project[]>("/api/admin/attendance?action=projects"),
}); });
const punchMutation = useApiMutation<
Record<string, unknown>,
{ message?: string }
>({
url: () => `${API_BASE}/attendance`,
method: () => "POST",
invalidate: ["attendance"],
});
const notesMutation = useApiMutation<{ notes: string }, { message?: string }>(
{
url: () => `${API_BASE}/attendance/notes`,
method: () => "POST",
invalidate: ["attendance"],
},
);
const switchProjectMutation = useApiMutation<
{ project_id: number | null },
{ message?: string }
>({
url: () => `${API_BASE}/attendance/switch-project`,
method: () => "POST",
invalidate: ["attendance"],
});
const leaveRequestMutation = useApiMutation<
{ leave_type: string; date_from: string; date_to: string; notes: string },
{ message?: string }
>({
url: () => `${API_BASE}/leave-requests`,
method: () => "POST",
invalidate: ["attendance", "leave-requests", "users"],
});
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false); const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
const [leaveForm, setLeaveForm] = useState({ const [leaveForm, setLeaveForm] = useState({
@@ -232,55 +259,29 @@ export default function Attendance() {
gpsData: Record<string, unknown> = {}, gpsData: Record<string, unknown> = {},
) => { ) => {
try { try {
const response = await apiFetch(`${API_BASE}/attendance`, { const result = await punchMutation.mutateAsync({
method: "POST", punch_action: action,
headers: { "Content-Type": "application/json" }, ...gpsData,
body: JSON.stringify({ punch_action: action, ...gpsData }),
}); });
if (response.status === 401) return;
const result = await response.json();
setSubmitting(false); setSubmitting(false);
punchTimeoutRef.current = setTimeout(() => {
if (result.success) { alert.success(result?.message || "Uloženo");
await queryClient.invalidateQueries({ }, 300);
queryKey: ["attendance"], } catch (e) {
});
punchTimeoutRef.current = setTimeout(() => {
alert.success(result.data?.message || result.message || "Uloženo");
}, 300);
} else {
alert.error(result.error);
}
} catch {
setSubmitting(false); setSubmitting(false);
alert.error("Chyba připojení"); alert.error(e instanceof Error ? e.message : "Chyba připojení");
} }
}; };
const handleBreak = async () => { const handleBreak = async () => {
setSubmitting(true); setSubmitting(true);
try { try {
const response = await apiFetch(`${API_BASE}/attendance`, { const result = await punchMutation.mutateAsync({
method: "POST", punch_action: "break_start",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ punch_action: "break_start" }),
}); });
if (response.status === 401) return; alert.success(result?.message || "Přestávka zaznamenána");
} catch (e) {
const result = await response.json(); alert.error(e instanceof Error ? e.message : "Chyba připojení");
if (result.success) {
await queryClient.invalidateQueries({
queryKey: ["attendance"],
});
alert.success(
result.data?.message || result.message || "Přestávka zaznamenána",
);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -288,48 +289,22 @@ export default function Attendance() {
const handleSaveNotes = async () => { const handleSaveNotes = async () => {
try { try {
const response = await apiFetch(`${API_BASE}/attendance/notes`, { await notesMutation.mutateAsync({ notes });
method: "POST", alert.success("Poznámka byla uložena");
headers: { "Content-Type": "application/json" }, } catch (e) {
body: JSON.stringify({ notes }), alert.error(e instanceof Error ? e.message : "Chyba připojení");
});
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
await queryClient.invalidateQueries({ queryKey: ["attendance"] });
alert.success("Poznámka byla uložena");
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
} }
}; };
const handleSwitchProject = async (newProjectId: string | null) => { const handleSwitchProject = async (newProjectId: string | null) => {
setSwitchingProject(true); setSwitchingProject(true);
try { try {
const response = await apiFetch(`${API_BASE}/attendance/switch-project`, { const result = await switchProjectMutation.mutateAsync({
method: "POST", project_id: newProjectId ? Number(newProjectId) : null,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ project_id: newProjectId || null }),
}); });
if (response.status === 401) return; alert.success(result?.message || "Projekt přepnut");
} catch (e) {
const result = await response.json(); alert.error(e instanceof Error ? e.message : "Chyba připojení");
if (result.success) {
await queryClient.invalidateQueries({
queryKey: ["attendance"],
});
alert.success(
result.data?.message || result.message || "Projekt přepnut",
);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
} finally { } finally {
setSwitchingProject(false); setSwitchingProject(false);
} }
@@ -353,56 +328,33 @@ export default function Attendance() {
const handleRequestSubmit = async () => { const handleRequestSubmit = async () => {
setRequestSubmitting(true); setRequestSubmitting(true);
try { try {
const response = await apiFetch(`${API_BASE}/leave-requests`, { const result = await leaveRequestMutation.mutateAsync(leaveForm);
method: "POST", setIsLeaveModalOpen(false);
headers: { "Content-Type": "application/json" }, await new Promise((resolve) => setTimeout(resolve, 300));
body: JSON.stringify(leaveForm), alert.success(result?.message || "Žádost odeslána");
setLeaveForm({
leave_type: "vacation",
date_from: new Date().toISOString().split("T")[0],
date_to: new Date().toISOString().split("T")[0],
notes: "",
}); });
if (response.status === 401) return; } catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
const result = await response.json();
if (result.success) {
setIsLeaveModalOpen(false);
await queryClient.invalidateQueries({
queryKey: ["attendance"],
});
await queryClient.invalidateQueries({
queryKey: ["leave-requests"],
});
await queryClient.invalidateQueries({
queryKey: ["leave"],
});
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
result.data?.message || result.message || "Žádost odeslána",
);
setLeaveForm({
leave_type: "vacation",
date_from: new Date().toISOString().split("T")[0],
date_to: new Date().toISOString().split("T")[0],
notes: "",
});
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
} finally { } finally {
setRequestSubmitting(false); setRequestSubmitting(false);
} }
}; };
if (!statusQuery.data) { if (statusQuery.isPending) {
return ( return (
<Skeleton <div className="admin-loading">
name="attendance" <div className="admin-spinner" />
loading={statusQuery.isPending} </div>
fixture={<AttendanceFixture />}
>
<div />
</Skeleton>
); );
} }
if (!statusQuery.data) {
return null;
}
const { const {
ongoing_shift: ongoingShift, ongoing_shift: ongoingShift,
@@ -671,7 +623,13 @@ export default function Attendance() {
{formatTime(shift.departure_time)} {formatTime(shift.departure_time)}
</td> </td>
<td className="admin-mono"> <td className="admin-mono">
{formatMinutes(calculateWorkMinutes(shift), true)} {formatMinutes(
calculateWorkMinutes({
...shift,
notes: shift.notes ?? undefined,
}),
true,
)}
</td> </td>
{projects.length > 0 && ( {projects.length > 0 && (
<td> <td>
@@ -741,7 +699,7 @@ export default function Attendance() {
{vacationDaysRemaining} {vacationDaysRemaining}
</span> </span>
<span className="attendance-balance-unit"> <span className="attendance-balance-unit">
{pluralizeDays(vacationDaysRemaining)} {czechPlural(vacationDaysRemaining, "den", "dny", "dnů")}
{vacationHoursRemaining > 0 && ` ${vacationHoursRemaining}h`} {vacationHoursRemaining > 0 && ` ${vacationHoursRemaining}h`}
</span> </span>
</div> </div>
@@ -831,8 +789,6 @@ export default function Attendance() {
` + dovolená ${data.monthly_fund.vacation_hours}h`} ` + dovolená ${data.monthly_fund.vacation_hours}h`}
{data.monthly_fund.sick_hours > 0 && {data.monthly_fund.sick_hours > 0 &&
` + nemoc ${data.monthly_fund.sick_hours}h`} ` + nemoc ${data.monthly_fund.sick_hours}h`}
{data.monthly_fund.holiday_hours > 0 &&
` + svátek ${data.monthly_fund.holiday_hours}h`}
{data.monthly_fund.unpaid_hours > 0 && {data.monthly_fund.unpaid_hours > 0 &&
` + neplacené ${data.monthly_fund.unpaid_hours}h`} ` + neplacené ${data.monthly_fund.unpaid_hours}h`}
) )
@@ -1069,15 +1025,15 @@ export default function Attendance() {
leaveForm.date_to, leaveForm.date_to,
)} )}
</strong>{" "} </strong>{" "}
{(() => { {czechPlural(
const d = calculateBusinessDays( calculateBusinessDays(
leaveForm.date_from, leaveForm.date_from,
leaveForm.date_to, leaveForm.date_to,
); ),
if (d === 1) return "pracovní den"; "pracovní den",
if (d >= 2 && d <= 4) return "pracovní dny"; "pracovní dny",
return "pracovních dnů"; "pracovních dnů",
})()} )}
</span> </span>
<span className="text-muted"> <span className="text-muted">
{calculateBusinessDays( {calculateBusinessDays(

View File

@@ -10,9 +10,7 @@ import AttendanceShiftTable from "../components/AttendanceShiftTable";
import useModalLock from "../hooks/useModalLock"; import useModalLock from "../hooks/useModalLock";
import useAttendanceAdmin from "../hooks/useAttendanceAdmin"; import useAttendanceAdmin from "../hooks/useAttendanceAdmin";
import FormField from "../components/FormField"; import FormField from "../components/FormField";
import { formatMinutes } from "../utils/attendanceHelpers"; import { formatMinutes, formatHoursDecimal } from "../utils/attendanceHelpers";
import { Skeleton } from "boneyard-js/react";
import AttendanceAdminFixture from "../fixtures/AttendanceAdminFixture";
interface UserTotalData { interface UserTotalData {
name: string; name: string;
@@ -20,20 +18,34 @@ interface UserTotalData {
working: boolean; working: boolean;
vacation_hours: number; vacation_hours: number;
sick_hours: number; sick_hours: number;
holiday_hours: number;
unpaid_hours: number; unpaid_hours: number;
fund: number | null; fund: number | null;
worked_hours: number; worked_hours: number;
covered: number; covered: number;
missing: number; missing: number;
overtime: number; overtime: number;
// mzda-style summary fields (set by computeUserTotals in useAttendanceAdmin)
odpracovano?: number;
vcetne_svatku?: number;
prescas?: number;
svatek?: number;
worked_holiday_hours?: number;
} }
function getFundBarBackground(data: UserTotalData) { function getFundBarBackground(data: UserTotalData) {
if (data.overtime > 0) // fondUsed mirrors the Fond "used" line in the IIFE below:
return "linear-gradient(135deg, var(--warning), #d97706)"; // real_worked + vacation + worked_holiday + free_holiday
if (data.covered >= (data.fund ?? 0)) // (delta is fondUsed - fund; 0 means exactly at the fund).
return "linear-gradient(135deg, var(--success), #059669)"; const realWorked = data.worked_hours_raw ?? data.worked_hours;
const fondUsed =
realWorked +
(data.vacation_hours ?? 0) +
(data.worked_holiday_hours ?? 0) +
(data.svatek ?? 0);
const fundVal = data.fund ?? 0;
const delta = fondUsed - fundVal;
if (delta > 0) return "linear-gradient(135deg, var(--warning), #d97706)";
if (delta >= 0) return "linear-gradient(135deg, var(--success), #059669)";
return "var(--gradient)"; return "var(--gradient)";
} }
@@ -89,7 +101,7 @@ export default function AttendanceAdmin() {
if (!hasPermission("attendance.manage")) return <Forbidden />; if (!hasPermission("attendance.manage")) return <Forbidden />;
// Show skeleton only on initial load (no data yet), not on filter changes // Show spinner only on initial load (no data yet), not on filter changes
const isInitialLoad = const isInitialLoad =
loading && loading &&
data.records.length === 0 && data.records.length === 0 &&
@@ -97,13 +109,9 @@ export default function AttendanceAdmin() {
if (isInitialLoad) { if (isInitialLoad) {
return ( return (
<Skeleton <div className="admin-loading">
name="attendance-admin" <div className="admin-spinner" />
loading={isInitialLoad} </div>
fixture={<AttendanceAdminFixture />}
>
<div />
</Skeleton>
); );
} }
@@ -212,8 +220,15 @@ export default function AttendanceAdmin() {
{Object.entries(data.user_totals).map(([uid, userData]) => { {Object.entries(data.user_totals).map(([uid, userData]) => {
const ut = userData as UserTotalData; const ut = userData as UserTotalData;
return ( return (
<div key={uid} className="admin-card"> <div key={uid} className="admin-card" style={{ display: "flex" }}>
<div className="admin-card-body"> <div
className="admin-card-body"
style={{
display: "flex",
flexDirection: "column",
flex: 1,
}}
>
<div className="flex-row gap-2 mb-2"> <div className="flex-row gap-2 mb-2">
<span style={{ fontWeight: 600 }}>{ut.name}</span> <span style={{ fontWeight: 600 }}>{ut.name}</span>
<span <span
@@ -229,9 +244,11 @@ export default function AttendanceAdmin() {
<div <div
style={{ style={{
marginTop: "0.5rem", marginTop: "0.5rem",
marginBottom: "0.75rem",
display: "flex", display: "flex",
flexWrap: "wrap", flexWrap: "wrap",
gap: "0.25rem", gap: "0.4rem",
minHeight: "2rem",
}} }}
> >
{ut.vacation_hours > 0 && ( {ut.vacation_hours > 0 && (
@@ -244,9 +261,9 @@ export default function AttendanceAdmin() {
Nem: {ut.sick_hours}h Nem: {ut.sick_hours}h
</span> </span>
)} )}
{ut.holiday_hours > 0 && ( {ut.svatek !== undefined && ut.svatek > 0 && (
<span className="attendance-leave-badge badge-holiday"> <span className="attendance-leave-badge badge-holiday">
Sv: {ut.holiday_hours}h Sv: {Math.round(ut.svatek)}h
</span> </span>
)} )}
{ut.unpaid_hours > 0 && ( {ut.unpaid_hours > 0 && (
@@ -255,62 +272,93 @@ export default function AttendanceAdmin() {
</span> </span>
)} )}
</div> </div>
{ut.fund !== null && ( {ut.fund !== null &&
<div className="mt-2"> (() => {
<div // Fond "used" = real_worked + vacation + worked_holiday + free_holiday
className="text-secondary" // (everything the user actually got paid for this month:
style={{ // raw worked time with sub-day remainders, plus
display: "flex", // vacation days, plus hours worked on holidays, plus
justifyContent: "space-between", // the 8h per unworked holiday from the fund).
alignItems: "center", const realWorked = ut.worked_hours_raw ?? ut.worked_hours;
fontSize: "0.8rem", const fondUsed =
}} realWorked +
> (ut.vacation_hours ?? 0) +
<span> (ut.worked_holiday_hours ?? 0) +
Fond: {ut.worked_hours}h / {ut.fund}h (ut.svatek ?? 0);
</span> const fundVal = ut.fund ?? 0;
{ut.overtime > 0 && ( const delta = fondUsed - fundVal;
<span className="text-warning fw-600"> return (
+{ut.overtime}h
</span>
)}
{ut.overtime <= 0 && ut.missing > 0 && (
<span className="text-danger fw-600">
-{ut.missing}h
</span>
)}
</div>
<div
style={{
marginTop: "0.375rem",
height: "4px",
background: "var(--bg-tertiary)",
borderRadius: "2px",
overflow: "hidden",
}}
>
<div <div
className="kpi-card-footer"
style={{ style={{
height: "100%", marginTop: "auto",
width: `${Math.min(100, (ut.covered / (ut.fund || 1)) * 100)}%`, paddingTop: "0.75rem",
background: getFundBarBackground(ut), borderTop: "1px solid var(--border-color)",
borderRadius: "2px",
transition: "width 0.3s ease",
}} }}
/> >
</div> <div
</div> className="text-secondary"
)} style={{
{data.leave_balances[uid] && ( display: "flex",
<div justifyContent: "space-between",
className="text-secondary" alignItems: "center",
style={{ marginTop: "0.5rem", fontSize: "0.8rem" }} fontSize: "0.8rem",
> }}
Zbývá dovolené:{" "} >
{data.leave_balances[uid].vacation_remaining.toFixed(1)}h <span>
/ {data.leave_balances[uid].vacation_total}h Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "}
</div> {formatHoursDecimal(fundVal * 60)}h
)} </span>
{delta > 0 && (
<span className="text-warning fw-600">
+{formatHoursDecimal(delta * 60)}h
</span>
)}
{delta <= 0 && delta < 0 && (
<span className="text-danger fw-600">
-{formatHoursDecimal(Math.abs(delta) * 60)}h
</span>
)}
</div>
<div
style={{
marginTop: "0.375rem",
height: "4px",
background: "var(--bg-tertiary)",
borderRadius: "2px",
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${Math.min(
100,
(fondUsed / (ut.fund || 1)) * 100,
)}%`,
background: getFundBarBackground(ut),
borderRadius: "2px",
transition: "width 0.3s ease",
}}
/>
</div>
</div>
);
})()}
<div
className="text-secondary"
style={{ marginTop: "0.75rem", fontSize: "0.8rem" }}
>
{data.leave_balances[uid] ? (
<>
Zbývá dovolené:{" "}
{data.leave_balances[uid].vacation_remaining.toFixed(1)}
h / {data.leave_balances[uid].vacation_total}h
</>
) : (
<span style={{ visibility: "hidden" }}></span>
)}
</div>
</div> </div>
</div> </div>
); );
@@ -374,7 +422,14 @@ export default function AttendanceAdmin() {
projectList={projectList} projectList={projectList}
users={data.users} users={data.users}
onShiftDateChange={handleCreateShiftDateChange} onShiftDateChange={handleCreateShiftDateChange}
editingRecord={editingRecord} editingRecord={
editingRecord
? {
user_name: editingRecord.user_name ?? "",
shift_date: editingRecord.shift_date,
}
: null
}
/> />
<ConfirmModal <ConfirmModal

View File

@@ -2,11 +2,11 @@ import { useState } from "react";
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 { motion, AnimatePresence } from "framer-motion"; import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal"; import ConfirmModal from "../components/ConfirmModal";
import useModalLock from "../hooks/useModalLock"; import FormModal from "../components/FormModal";
import FormField from "../components/FormField"; import FormField from "../components/FormField";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
attendanceBalancesOptions, attendanceBalancesOptions,
attendanceWorkFundOptions, attendanceWorkFundOptions,
@@ -20,9 +20,8 @@ import {
type UserShort, type UserShort,
} from "../lib/queries/attendance"; } from "../lib/queries/attendance";
import apiFetch from "../utils/api"; import { useApiMutation } from "../lib/queries/mutations";
import { Skeleton } from "boneyard-js/react";
import AttendanceBalancesFixture from "../fixtures/AttendanceBalancesFixture";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
const getVacationClass = (remaining: number): string => { const getVacationClass = (remaining: number): string => {
@@ -85,7 +84,6 @@ const getProgressBackground = (
export default function AttendanceBalances() { export default function AttendanceBalances() {
const alert = useAlert(); const alert = useAlert();
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const [year, setYear] = useState(new Date().getFullYear()); const [year, setYear] = useState(new Date().getFullYear());
const { data: balancesData, isPending: balancesPending } = useQuery( const { data: balancesData, isPending: balancesPending } = useQuery(
attendanceBalancesOptions(year), attendanceBalancesOptions(year),
@@ -114,10 +112,37 @@ export default function AttendanceBalances() {
userName: string; userName: string;
}>({ show: false, userId: null, userName: "" }); }>({ show: false, userId: null, userName: "" });
useModalLock(showEditModal);
if (!hasPermission("attendance.balances")) return <Forbidden />; if (!hasPermission("attendance.balances")) return <Forbidden />;
const editMutation = useApiMutation<
{
user_id: string;
year: number;
action_type: "edit";
vacation_total: number;
vacation_used: number;
sick_used: number;
},
{ message?: string; error?: string }
>({
url: () => `${API_BASE}/attendance?action=balances`,
method: () => "POST",
invalidate: ["attendance", "users"],
});
const resetMutation = useApiMutation<
{ user_id: string; year: number; action_type: "reset" },
{ message?: string; error?: string }
>({
url: () => `${API_BASE}/attendance?action=balances`,
method: () => "POST",
invalidate: ["attendance", "users"],
onSuccess: (data) => {
setResetConfirm({ show: false, userId: null, userName: "" });
alert.success(data?.message || "Operace dokončena");
},
});
const openEditModal = (userId: string, balance: BalanceEntry) => { const openEditModal = (userId: string, balance: BalanceEntry) => {
setEditingUser({ id: userId, name: balance.name }); setEditingUser({ id: userId, name: balance.name });
setEditForm({ setEditForm({
@@ -131,62 +156,29 @@ export default function AttendanceBalances() {
const handleEditSubmit = async () => { const handleEditSubmit = async () => {
if (!editingUser) return; if (!editingUser) return;
try { try {
const response = await apiFetch( const result = await editMutation.mutateAsync({
`${API_BASE}/attendance?action=balances`, user_id: editingUser.id,
{ year,
method: "POST", action_type: "edit",
headers: { "Content-Type": "application/json" }, ...editForm,
body: JSON.stringify({ });
user_id: editingUser.id, setShowEditModal(false);
year, alert.success(result?.message || "Upraveno");
action_type: "edit", } catch (e) {
...editForm, alert.error(e instanceof Error ? e.message : "Chyba připojení");
}),
},
);
const result = await response.json();
if (result.success) {
setShowEditModal(false);
await queryClient.invalidateQueries({ queryKey: ["attendance"] });
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
} }
}; };
const handleReset = async () => { const handleReset = async () => {
if (!resetConfirm.userId) return; if (!resetConfirm.userId) return;
try { try {
const response = await apiFetch( await resetMutation.mutateAsync({
`${API_BASE}/attendance?action=balances`, user_id: resetConfirm.userId,
{ year,
method: "POST", action_type: "reset",
headers: { "Content-Type": "application/json" }, });
body: JSON.stringify({ } catch (e) {
user_id: resetConfirm.userId, alert.error(e instanceof Error ? e.message : "Chyba připojení");
year,
action_type: "reset",
}),
},
);
const result = await response.json();
if (result.success) {
setResetConfirm({ show: false, userId: null, userName: "" });
await queryClient.invalidateQueries({ queryKey: ["attendance"] });
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
} }
}; };
@@ -263,11 +255,11 @@ export default function AttendanceBalances() {
transition={{ duration: 0.25, delay: 0.06 }} transition={{ duration: 0.25, delay: 0.06 }}
> >
<div className="admin-card-body"> <div className="admin-card-body">
<Skeleton {balancesPending ? (
name="attendance-balances" <div className="admin-loading">
loading={balancesPending} <div className="admin-spinner" />
fixture={<AttendanceBalancesFixture />} </div>
> ) : (
<> <>
{balancesData && {balancesData &&
Object.keys(balancesData.balances).length === 0 && ( Object.keys(balancesData.balances).length === 0 && (
@@ -387,7 +379,7 @@ export default function AttendanceBalances() {
</div> </div>
)} )}
</> </>
</Skeleton> )}
</div> </div>
</motion.div> </motion.div>
@@ -557,13 +549,9 @@ export default function AttendanceBalances() {
)} )}
{fundPending && ( {fundPending && (
<Skeleton <div className="admin-loading">
name="attendance-balances-fund" <div className="admin-spinner" />
loading={fundPending} </div>
fixture={<AttendanceBalancesFixture />}
>
<div className="mt-6" />
</Skeleton>
)} )}
{/* Monthly Project Overview */} {/* Monthly Project Overview */}
@@ -761,118 +749,82 @@ export default function AttendanceBalances() {
)} )}
{projectPending && ( {projectPending && (
<Skeleton <div className="admin-loading">
name="attendance-balances-projects" <div className="admin-spinner" />
loading={projectPending} </div>
fixture={<AttendanceBalancesFixture />}
>
<div className="mt-6" />
</Skeleton>
)} )}
{/* Edit Modal */} {/* Edit Modal */}
<AnimatePresence> <FormModal
{showEditModal && editingUser && ( isOpen={showEditModal && !!editingUser}
<motion.div onClose={() => setShowEditModal(false)}
className="admin-modal-overlay" onSubmit={handleEditSubmit}
initial={{ opacity: 0 }} title="Upravit dovolenou"
animate={{ opacity: 1 }} loading={editMutation.isPending}
exit={{ opacity: 0 }} >
transition={{ duration: 0.2 }} {editingUser && (
> <>
<div <p
className="admin-modal-backdrop" className="text-secondary"
onClick={() => setShowEditModal(false)} style={{ marginTop: "0", marginBottom: "0.75rem" }}
/>
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
> >
<div className="admin-modal-header"> {editingUser.name}
<h2 className="admin-modal-title">Upravit dovolenou</h2> </p>
<p className="text-secondary" style={{ marginTop: "0.25rem" }}> <div className="admin-form">
{editingUser.name} <FormField label="Nárok na dovolenou (hodiny)">
</p> <input
</div> type="number"
value={editForm.vacation_total}
onChange={(e) =>
setEditForm({
...editForm,
vacation_total: parseFloat(e.target.value),
})
}
min="0"
max="500"
step="1"
className="admin-form-input"
/>
</FormField>
<div className="admin-modal-body"> <FormField label="Čerpáno dovolené (hodiny)">
<div className="admin-form"> <input
<FormField label="Nárok na dovolenou (hodiny)"> type="number"
<input value={editForm.vacation_used}
type="number" onChange={(e) =>
value={editForm.vacation_total} setEditForm({
onChange={(e) => ...editForm,
setEditForm({ vacation_used: parseFloat(e.target.value),
...editForm, })
vacation_total: parseFloat(e.target.value), }
}) min="0"
} max="500"
min="0" step="0.5"
max="500" className="admin-form-input"
step="1" />
className="admin-form-input" </FormField>
/>
</FormField>
<FormField label="Čerpáno dovolené (hodiny)"> <FormField label="Čerpáno nemocenské (hodiny)">
<input <input
type="number" type="number"
value={editForm.vacation_used} value={editForm.sick_used}
onChange={(e) => onChange={(e) =>
setEditForm({ setEditForm({
...editForm, ...editForm,
vacation_used: parseFloat(e.target.value), sick_used: parseFloat(e.target.value),
}) })
} }
min="0" min="0"
max="500" max="500"
step="0.5" step="0.5"
className="admin-form-input" className="admin-form-input"
/> />
</FormField> </FormField>
</div>
<FormField label="Čerpáno nemocenské (hodiny)"> </>
<input
type="number"
value={editForm.sick_used}
onChange={(e) =>
setEditForm({
...editForm,
sick_used: parseFloat(e.target.value),
})
}
min="0"
max="500"
step="0.5"
className="admin-form-input"
/>
</FormField>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowEditModal(false)}
className="admin-btn admin-btn-secondary"
>
Zrušit
</button>
<button
type="button"
onClick={handleEditSubmit}
className="admin-btn admin-btn-primary"
>
Uložit
</button>
</div>
</motion.div>
</motion.div>
)} )}
</AnimatePresence> </FormModal>
{/* Reset Confirmation */} {/* Reset Confirmation */}
<ConfirmModal <ConfirmModal
@@ -885,6 +837,7 @@ export default function AttendanceBalances() {
message={`Opravdu chcete vynulovat čerpání dovolené a nemocenské pro ${resetConfirm.userName} za rok ${year}?`} message={`Opravdu chcete vynulovat čerpání dovolené a nemocenské pro ${resetConfirm.userName} za rok ${year}?`}
confirmText="Resetovat" confirmText="Resetovat"
confirmVariant="danger" confirmVariant="danger"
loading={resetMutation.isPending}
/> />
</div> </div>
); );

View File

@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery } 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";
@@ -9,9 +9,7 @@ import { motion } from "framer-motion";
import AdminDatePicker from "../components/AdminDatePicker"; import AdminDatePicker from "../components/AdminDatePicker";
import FormField from "../components/FormField"; import FormField from "../components/FormField";
import apiFetch from "../utils/api"; import { useApiMutation } from "../lib/queries/mutations";
import { Skeleton } from "boneyard-js/react";
import AttendanceCreateFixture from "../fixtures/AttendanceCreateFixture";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
interface CreateForm { interface CreateForm {
@@ -34,7 +32,6 @@ 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 ?? []).map((u) => ({ const users = (usersData ?? []).map((u) => ({
id: u.id, id: u.id,
@@ -42,6 +39,12 @@ export default function AttendanceCreate() {
})); }));
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const createMutation = useApiMutation<CreateForm, { message?: string }>({
url: () => `${API_BASE}/attendance`,
method: () => "POST",
invalidate: ["attendance", "users"],
});
const [form, setForm] = useState<CreateForm>(() => { const [form, setForm] = useState<CreateForm>(() => {
const today = new Date().toISOString().split("T")[0]; const today = new Date().toISOString().split("T")[0];
return { return {
@@ -72,23 +75,11 @@ export default function AttendanceCreate() {
setSubmitting(true); setSubmitting(true);
try { try {
const response = await apiFetch(`${API_BASE}/attendance`, { const result = await createMutation.mutateAsync(form);
method: "POST", alert.success(result?.message || "Uloženo");
headers: { "Content-Type": "application/json" }, navigate(`/attendance/admin?month=${form.shift_date.substring(0, 7)}`);
body: JSON.stringify(form), } catch (e) {
}); alert.error(e instanceof Error ? e.message : "Chyba připojení");
const result = await response.json();
if (result.success) {
alert.success(result.message);
await queryClient.invalidateQueries({ queryKey: ["attendance"] });
navigate(`/attendance/admin?month=${form.shift_date.substring(0, 7)}`);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -109,223 +100,224 @@ export default function AttendanceCreate() {
if (!hasPermission("attendance.manage")) return <Forbidden />; if (!hasPermission("attendance.manage")) return <Forbidden />;
if (loading) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
return ( return (
<Skeleton <div>
name="attendance-create" <motion.div
loading={loading} className="admin-page-header"
fixture={<AttendanceCreateFixture />} initial={{ opacity: 0, y: 12 }}
> animate={{ opacity: 1, y: 0 }}
<div> transition={{ duration: 0.25 }}
<motion.div >
className="admin-page-header" <div>
initial={{ opacity: 0, y: 12 }} <h1 className="admin-page-title">Přidat záznam docházky</h1>
animate={{ opacity: 1, y: 0 }} </div>
transition={{ duration: 0.25 }} <div className="admin-page-actions">
> <Link
<div> to="/attendance/admin"
<h1 className="admin-page-title">Přidat záznam docházky</h1> className="admin-btn admin-btn-secondary"
</div> >
<div className="admin-page-actions"> &larr; Zpět na správu
<Link </Link>
to="/attendance/admin" </div>
className="admin-btn admin-btn-secondary" </motion.div>
>
&larr; Zpět na správu
</Link>
</div>
</motion.div>
<motion.div <motion.div
className="admin-card" className="admin-card"
style={{ maxWidth: "600px" }} style={{ maxWidth: "600px" }}
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 }}
> >
<div className="admin-card-body"> <div className="admin-card-body">
<form onSubmit={handleSubmit} className="admin-form"> <form onSubmit={handleSubmit} className="admin-form">
<div className="admin-form-row"> <div className="admin-form-row">
<FormField label="Zaměstnanec" required> <FormField label="Zaměstnanec" required>
<select
value={form.user_id}
onChange={(e) =>
setForm({ ...form, user_id: e.target.value })
}
className="admin-form-select"
required
>
<option value="">Vyberte zaměstnance</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</FormField>
<FormField label="Datum směny" required>
<AdminDatePicker
mode="date"
value={form.shift_date}
onChange={(val: string) => handleShiftDateChange(val)}
required
/>
</FormField>
</div>
<FormField label="Typ záznamu" required>
<select <select
value={form.leave_type} value={form.user_id}
onChange={(e) => onChange={(e) =>
setForm({ ...form, leave_type: e.target.value }) setForm({ ...form, user_id: e.target.value })
} }
className="admin-form-select" className="admin-form-select"
required
> >
<option value="work">Práce</option> <option value="">Vyberte zaměstnance</option>
<option value="vacation">Dovolená</option> {users.map((user) => (
<option value="sick">Nemoc</option> <option key={user.id} value={user.id}>
<option value="holiday">Svátek</option> {user.name}
<option value="unpaid">Neplacené volno</option> </option>
))}
</select> </select>
</FormField> </FormField>
<FormField label="Datum směny" required>
{!isWorkType && ( <AdminDatePicker
<FormField label="Počet hodin"> mode="date"
<input value={form.shift_date}
type="number" onChange={(val: string) => handleShiftDateChange(val)}
value={form.leave_hours} required
onChange={(e) =>
setForm({
...form,
leave_hours: parseFloat(e.target.value),
})
}
min="0.5"
max="24"
step="0.5"
className="admin-form-input"
/>
<small className="admin-form-hint">
Výchozí 8 hodin pro celý den
</small>
</FormField>
)}
{isWorkType && (
<>
<div className="admin-form-row">
<FormField label="Příchod - datum">
<AdminDatePicker
mode="date"
value={form.arrival_date}
onChange={(val: string) =>
setForm({ ...form, arrival_date: val })
}
/>
</FormField>
<FormField label="Příchod - čas">
<AdminDatePicker
mode="time"
value={form.arrival_time}
onChange={(val: string) =>
setForm({ ...form, arrival_time: val })
}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Začátek pauzy - datum">
<AdminDatePicker
mode="date"
value={form.break_start_date}
onChange={(val: string) =>
setForm({ ...form, break_start_date: val })
}
/>
</FormField>
<FormField label="Začátek pauzy - čas">
<AdminDatePicker
mode="time"
value={form.break_start_time}
onChange={(val: string) =>
setForm({ ...form, break_start_time: val })
}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Konec pauzy - datum">
<AdminDatePicker
mode="date"
value={form.break_end_date}
onChange={(val: string) =>
setForm({ ...form, break_end_date: val })
}
/>
</FormField>
<FormField label="Konec pauzy - čas">
<AdminDatePicker
mode="time"
value={form.break_end_time}
onChange={(val: string) =>
setForm({ ...form, break_end_time: val })
}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Odchod - datum">
<AdminDatePicker
mode="date"
value={form.departure_date}
onChange={(val: string) =>
setForm({ ...form, departure_date: val })
}
/>
</FormField>
<FormField label="Odchod - čas">
<AdminDatePicker
mode="time"
value={form.departure_time}
onChange={(val: string) =>
setForm({ ...form, departure_time: val })
}
/>
</FormField>
</div>
</>
)}
<FormField label="Poznámka">
<textarea
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
className="admin-form-textarea"
rows={3}
/> />
</FormField> </FormField>
</div>
<div className="admin-form-actions"> <FormField label="Typ záznamu" required>
<Link <select
to="/attendance/admin" value={form.leave_type}
className="admin-btn admin-btn-secondary" onChange={(e) =>
> setForm({ ...form, leave_type: e.target.value })
Zrušit }
</Link> className="admin-form-select"
<button >
type="submit" <option value="work">Práce</option>
disabled={submitting} <option value="vacation">Dovolená</option>
className="admin-btn admin-btn-primary" <option value="sick">Nemoc</option>
> <option value="unpaid">Neplacené volno</option>
{submitting ? "Ukládám..." : "Uložit"} </select>
</button> </FormField>
</div>
</form> {!isWorkType && (
</div> <FormField label="Počet hodin">
</motion.div> <input
</div> type="number"
</Skeleton> value={form.leave_hours}
onChange={(e) =>
setForm({
...form,
leave_hours: parseFloat(e.target.value),
})
}
min="0.5"
max="24"
step="0.5"
className="admin-form-input"
/>
<small className="admin-form-hint">
Výchozí 8 hodin pro celý den
</small>
</FormField>
)}
{isWorkType && (
<>
<div className="admin-form-row">
<FormField label="Příchod - datum">
<AdminDatePicker
mode="date"
value={form.arrival_date}
onChange={(val: string) =>
setForm({ ...form, arrival_date: val })
}
/>
</FormField>
<FormField label="Příchod - čas">
<AdminDatePicker
mode="time"
value={form.arrival_time}
onChange={(val: string) =>
setForm({ ...form, arrival_time: val })
}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Začátek pauzy - datum">
<AdminDatePicker
mode="date"
value={form.break_start_date}
onChange={(val: string) =>
setForm({ ...form, break_start_date: val })
}
/>
</FormField>
<FormField label="Začátek pauzy - čas">
<AdminDatePicker
mode="time"
value={form.break_start_time}
onChange={(val: string) =>
setForm({ ...form, break_start_time: val })
}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Konec pauzy - datum">
<AdminDatePicker
mode="date"
value={form.break_end_date}
onChange={(val: string) =>
setForm({ ...form, break_end_date: val })
}
/>
</FormField>
<FormField label="Konec pauzy - čas">
<AdminDatePicker
mode="time"
value={form.break_end_time}
onChange={(val: string) =>
setForm({ ...form, break_end_time: val })
}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Odchod - datum">
<AdminDatePicker
mode="date"
value={form.departure_date}
onChange={(val: string) =>
setForm({ ...form, departure_date: val })
}
/>
</FormField>
<FormField label="Odchod - čas">
<AdminDatePicker
mode="time"
value={form.departure_time}
onChange={(val: string) =>
setForm({ ...form, departure_time: val })
}
/>
</FormField>
</div>
</>
)}
<FormField label="Poznámka">
<textarea
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
className="admin-form-textarea"
rows={3}
/>
</FormField>
<div className="admin-form-actions">
<Link
to="/attendance/admin"
className="admin-btn admin-btn-secondary"
>
Zrušit
</Link>
<button
type="submit"
disabled={submitting}
className="admin-btn admin-btn-primary"
>
{submitting ? "Ukládám..." : "Uložit"}
</button>
</div>
</form>
</div>
</motion.div>
</div>
); );
} }

View File

@@ -20,10 +20,12 @@ import {
getLeaveTypeBadgeClass, getLeaveTypeBadgeClass,
calculateWorkMinutesPrint, calculateWorkMinutesPrint,
formatTimeOrDatetimePrint, formatTimeOrDatetimePrint,
calculateFreeHolidayHours,
holidaysInMonth,
normalizeDateStr,
formatHoursDecimal,
} from "../utils/attendanceHelpers"; } from "../utils/attendanceHelpers";
import FormField from "../components/FormField"; import FormField from "../components/FormField";
import { Skeleton } from "boneyard-js/react";
import AttendanceHistoryFixture from "../fixtures/AttendanceHistoryFixture";
const MONTH_NAMES = [ const MONTH_NAMES = [
"Leden", "Leden",
@@ -196,7 +198,6 @@ export default function AttendanceHistory() {
let totalMinutes = 0; let totalMinutes = 0;
let vacationHours = 0; let vacationHours = 0;
let sickHours = 0; let sickHours = 0;
let holidayHours = 0;
let unpaidHours = 0; let unpaidHours = 0;
for (const record of records) { for (const record of records) {
@@ -208,26 +209,51 @@ export default function AttendanceHistory() {
record.leave_hours != null ? Number(record.leave_hours) : 8; record.leave_hours != null ? Number(record.leave_hours) : 8;
if (leaveType === "vacation") vacationHours += hours; if (leaveType === "vacation") vacationHours += hours;
else if (leaveType === "sick") sickHours += hours; else if (leaveType === "sick") sickHours += hours;
else if (leaveType === "holiday") holidayHours += hours;
else if (leaveType === "unpaid") unpaidHours += hours; else if (leaveType === "unpaid") unpaidHours += hours;
// "holiday" is no longer a user-selectable leave_type — it is
// auto-computed from Czech public holidays by the print/KPI code.
// Old records may still exist; skip so they don't double-count.
} }
} }
const yr = parseInt(yearStr, 10); const yr = parseInt(yearStr, 10);
const mo = parseInt(monthStr, 10) - 1; const mo = parseInt(monthStr, 10) - 1;
const businessDays = getBusinessDaysInMonth(yr, mo); const businessDays = getBusinessDaysInMonth(yr, mo);
const fund = businessDays * 8; // Fund counts Mon-Fri + holidays (holidays are paid via the fond).
const holidayDates = holidaysInMonth(yr, mo + 1);
const holidayCount = holidayDates.length;
const fund = (businessDays + holidayCount) * 8;
const worked = Math.round((totalMinutes / 60) * 100) / 100; const worked = Math.round((totalMinutes / 60) * 100) / 100;
// Covered = worked + vacation + sick (NOT holiday/unpaid — holiday is excluded from fund, unpaid is voluntary) // Covered = worked + vacation + sick (NOT unpaid — unpaid is voluntary).
const leaveHours = vacationHours + sickHours; const leaveHours = vacationHours + sickHours;
const covered = Math.round((worked + leaveHours) * 100) / 100; const covered = Math.round((worked + leaveHours) * 100) / 100;
const remaining = Math.max(0, Math.round((fund - covered) * 100) / 100);
const overtime = Math.max(0, Math.round((covered - fund) * 100) / 100); // fondUsed = real_worked + vacation + worked_holiday + free_holiday.
// (the all-in number: everything the user got paid for this month).
const realWorked = totalMinutes / 60;
const freeHolidayHours = calculateFreeHolidayHours(
records as unknown as Parameters<typeof calculateFreeHolidayHours>[0],
holidayDates,
);
const workedHolidayHours = records
.filter(
(r) =>
(r.leave_type || "work") === "work" &&
holidayDates.includes(normalizeDateStr(r.shift_date)),
)
.reduce((sum, r) => sum + calculateWorkMinutesPrint(r) / 60, 0);
const fondUsed =
realWorked + vacationHours + workedHolidayHours + freeHolidayHours;
const delta = fondUsed - fund;
const missing = Math.max(0, Math.round(-delta * 100) / 100);
const overtime = Math.max(0, Math.round(delta * 100) / 100);
const remaining = missing;
const monthlyFund = { const monthlyFund = {
fund, fund,
business_days: businessDays, business_days: businessDays,
worked, holiday_count: holidayCount,
worked: Math.round(fondUsed * 100) / 100,
covered, covered,
remaining, remaining,
overtime, overtime,
@@ -238,9 +264,12 @@ export default function AttendanceHistory() {
totalMinutes, totalMinutes,
vacationHours, vacationHours,
sickHours, sickHours,
holidayHours,
unpaidHours, unpaidHours,
monthlyFund, monthlyFund,
fondUsed,
freeHolidayHours,
workedHolidayHours,
delta,
}; };
}, [records, month]); }, [records, month]);
@@ -324,7 +353,6 @@ export default function AttendanceHistory() {
} }
.badge-vacation { background: #dbeafe; color: #1d4ed8; } .badge-vacation { background: #dbeafe; color: #1d4ed8; }
.badge-sick { background: #fee2e2; color: #dc2626; } .badge-sick { background: #fee2e2; color: #dc2626; }
.badge-holiday { background: #dcfce7; color: #16a34a; }
.badge-unpaid { background: #f3f4f6; color: #6b7280; } .badge-unpaid { background: #f3f4f6; color: #6b7280; }
.badge-overtime { background: #fef3c7; color: #d97706; } .badge-overtime { background: #fef3c7; color: #d97706; }
@media print { @media print {
@@ -411,11 +439,11 @@ export default function AttendanceHistory() {
transition={{ duration: 0.25, delay: 0.08 }} transition={{ duration: 0.25, delay: 0.08 }}
> >
<div className="admin-card-body"> <div className="admin-card-body">
<Skeleton {isPending ? (
name="attendance-history-fund" <div className="admin-loading">
loading={isPending} <div className="admin-spinner" />
fixture={<AttendanceHistoryFixture />} </div>
> ) : (
<> <>
{computed.monthlyFund && ( {computed.monthlyFund && (
<div <div
@@ -465,51 +493,90 @@ export default function AttendanceHistory() {
style={{ fontSize: "0.8125rem" }} style={{ fontSize: "0.8125rem" }}
> >
{computed.monthlyFund.business_days} prac. dnů {computed.monthlyFund.business_days} prac. dnů
{computed.monthlyFund.holiday_count > 0 &&
` + ${computed.monthlyFund.holiday_count} svátky`}
</span> </span>
</div> </div>
<div className="attendance-balance-bar"> <div className="attendance-balance-bar">
<div <div
className="attendance-balance-progress" className="attendance-balance-progress"
style={{ style={{
width: `${Math.min(100, computed.monthlyFund.fund > 0 ? (computed.monthlyFund.covered / computed.monthlyFund.fund) * 100 : 0)}%`, width: `${Math.min(100, computed.monthlyFund.fund > 0 ? (computed.monthlyFund.worked / computed.monthlyFund.fund) * 100 : 0)}%`,
background: background:
computed.monthlyFund.covered >= computed.delta > 0
computed.monthlyFund.fund ? "linear-gradient(135deg, var(--warning), #d97706)"
? "linear-gradient(135deg, var(--success), #059669)" : computed.delta >= 0
: "var(--gradient)", ? "linear-gradient(135deg, var(--success), #059669)"
: "var(--gradient)",
}} }}
/> />
</div> </div>
<div <div
className="text-muted"
style={{ style={{
display: "flex", display: "flex",
flexWrap: "wrap",
gap: "0.4rem",
marginTop: "0.5rem",
minHeight: "1.5rem",
alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
fontSize: "0.75rem",
marginTop: "0.375rem",
}} }}
> >
<span> <div
{"Pokryto: "} style={{
{computed.monthlyFund.covered}h (práce{" "} display: "flex",
{computed.monthlyFund.worked}h flexWrap: "wrap",
{computed.vacationHours > 0 && gap: "0.4rem",
` + dovolená ${computed.vacationHours}h`} }}
{computed.sickHours > 0 && >
` + nemoc ${computed.sickHours}h`} {computed.totalMinutes > 0 && (
{computed.holidayHours > 0 && <span
` + svátek ${computed.holidayHours}h`} className="attendance-leave-badge"
{computed.unpaidHours > 0 && title="Odpracováno (reálně)"
` + neplacené ${computed.unpaidHours}h`} >
) Práce: {formatHoursDecimal(computed.totalMinutes)}h
</span>
)}
{computed.vacationHours > 0 && (
<span className="attendance-leave-badge badge-vacation">
Dov: {computed.vacationHours}h
</span>
)}
{computed.sickHours > 0 && (
<span className="attendance-leave-badge badge-sick">
Nem: {computed.sickHours}h
</span>
)}
{computed.freeHolidayHours > 0 && (
<span className="attendance-leave-badge badge-holiday">
Sv: {Math.round(computed.freeHolidayHours)}h
</span>
)}
{computed.unpaidHours > 0 && (
<span className="attendance-leave-badge badge-unpaid">
Nep: {computed.unpaidHours}h
</span>
)}
</div>
<span
style={{
fontSize: "0.75rem",
color: "var(--text-muted)",
}}
className={
computed.delta > 0
? "text-warning fw-600"
: computed.delta < 0
? "text-danger fw-600"
: "text-success fw-600"
}
>
{computed.delta > 0
? `Přesčas: +${formatHoursDecimal(computed.delta * 60)}h`
: computed.delta < 0
? `Zbývá: ${formatHoursDecimal(-computed.delta * 60)}h`
: "Fond splněn"}
</span> </span>
{computed.monthlyFund.overtime > 0 ? (
<span className="text-warning fw-600">
Přesčas: +{computed.monthlyFund.overtime}h
</span>
) : (
<span>Zbývá: {computed.monthlyFund.remaining}h</span>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -527,7 +594,7 @@ export default function AttendanceHistory() {
</div> </div>
)} )}
</> </>
</Skeleton> )}
</div> </div>
</motion.div> </motion.div>
@@ -539,11 +606,11 @@ export default function AttendanceHistory() {
transition={{ duration: 0.25, delay: 0.12 }} transition={{ duration: 0.25, delay: 0.12 }}
> >
<div className="admin-card-body"> <div className="admin-card-body">
<Skeleton {isPending ? (
name="attendance-history-table" <div className="admin-loading">
loading={isPending} <div className="admin-spinner" />
fixture={<AttendanceHistoryFixture />} </div>
> ) : (
<> <>
{records.length === 0 && ( {records.length === 0 && (
<div className="admin-empty-state"> <div className="admin-empty-state">
@@ -622,7 +689,7 @@ export default function AttendanceHistory() {
</div> </div>
)} )}
</> </>
</Skeleton> )}
</div> </div>
</motion.div> </motion.div>
@@ -670,9 +737,7 @@ export default function AttendanceHistory() {
</span> </span>
</div> </div>
{(computed.vacationHours > 0 || {(computed.vacationHours > 0 || computed.sickHours > 0) && (
computed.sickHours > 0 ||
computed.holidayHours > 0) && (
<div className="leave-summary"> <div className="leave-summary">
{computed.vacationHours > 0 && ( {computed.vacationHours > 0 && (
<> <>
@@ -688,13 +753,6 @@ export default function AttendanceHistory() {
</span>{" "} </span>{" "}
</> </>
)} )}
{computed.holidayHours > 0 && (
<>
<span className="leave-badge badge-holiday">
Svátek: {computed.holidayHours}h
</span>{" "}
</>
)}
</div> </div>
)} )}

View File

@@ -14,8 +14,6 @@ import {
attendanceLocationOptions, attendanceLocationOptions,
type LocationRecord, type LocationRecord,
} from "../lib/queries/attendance"; } from "../lib/queries/attendance";
import { Skeleton } from "boneyard-js/react";
import AttendanceLocationFixture from "../fixtures/AttendanceLocationFixture";
export default function AttendanceLocation() { export default function AttendanceLocation() {
const alert = useAlert(); const alert = useAlert();
@@ -168,71 +166,111 @@ export default function AttendanceLocation() {
: record.shift_date; : record.shift_date;
const month = shiftDateStr.substring(0, 7); const month = shiftDateStr.substring(0, 7);
if (isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
return ( return (
<Skeleton <div>
name="attendance-location" <motion.div
loading={isPending} className="admin-page-header"
fixture={<AttendanceLocationFixture />} initial={{ opacity: 0, y: 12 }}
> animate={{ opacity: 1, y: 0 }}
<div> transition={{ duration: 0.25 }}
<motion.div >
className="admin-page-header" <div>
initial={{ opacity: 0, y: 12 }} <h1 className="admin-page-title">Poloha záznamu</h1>
animate={{ opacity: 1, y: 0 }} </div>
transition={{ duration: 0.25 }} <div className="admin-page-actions">
> <Link
<div> to={`/attendance/admin?month=${month}`}
<h1 className="admin-page-title">Poloha záznamu</h1> className="admin-btn admin-btn-secondary"
</div> >
<div className="admin-page-actions"> &larr; Zpět na správu
<Link </Link>
to={`/attendance/admin?month=${month}`} </div>
className="admin-btn admin-btn-secondary" </motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-header">
<h2 className="admin-card-title">
{record.user_name} {formatDate(record.shift_date)}
</h2>
</div>
<div className="admin-card-body">
{hasAnyLocation && (
<div ref={mapRef} className="attendance-location-map" />
)}
<div className="attendance-location-grid">
{/* Arrival */}
<div
className={`attendance-location-card ${!hasArrivalLocation ? "empty" : ""}`}
> >
&larr; Zpět na správu <h3 className="attendance-location-title">Příchod</h3>
</Link> <div className="attendance-location-time">
</div> {record.arrival_time
</motion.div> ? formatDatetimeLocal(record.arrival_time)
: "—"}
</div>
{hasArrivalLocation ? (
<>
<div className="attendance-location-address">
{record.arrival_address || <em>Adresa nezjištěna</em>}
</div>
<div className="attendance-location-coords">
GPS: {record.arrival_lat}, {record.arrival_lng}
{record.arrival_accuracy &&
` (přesnost: ${Math.round(Number(record.arrival_accuracy))}m)`}
</div>
<a
href={`https://www.google.com/maps?q=${record.arrival_lat},${record.arrival_lng}`}
target="_blank"
rel="noopener noreferrer"
className="admin-btn admin-btn-secondary admin-btn-sm mt-2"
>
Otevřít v Google Maps
</a>
</>
) : (
<div className="attendance-location-address">
<em>Poloha nebyla zaznamenána</em>
</div>
)}
</div>
<motion.div {/* Departure */}
className="admin-card" {(hasDepartureLocation || record.departure_time) && (
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-header">
<h2 className="admin-card-title">
{record.user_name} {formatDate(record.shift_date)}
</h2>
</div>
<div className="admin-card-body">
{hasAnyLocation && (
<div ref={mapRef} className="attendance-location-map" />
)}
<div className="attendance-location-grid">
{/* Arrival */}
<div <div
className={`attendance-location-card ${!hasArrivalLocation ? "empty" : ""}`} className={`attendance-location-card ${!hasDepartureLocation ? "empty" : ""}`}
> >
<h3 className="attendance-location-title">Příchod</h3> <h3 className="attendance-location-title">Odchod</h3>
<div className="attendance-location-time"> <div className="attendance-location-time">
{record.arrival_time {record.departure_time
? formatDatetimeLocal(record.arrival_time) ? formatDatetimeLocal(record.departure_time)
: "—"} : "—"}
</div> </div>
{hasArrivalLocation ? ( {hasDepartureLocation ? (
<> <>
<div className="attendance-location-address"> <div className="attendance-location-address">
{record.arrival_address || <em>Adresa nezjištěna</em>} {record.departure_address || <em>Adresa nezjištěna</em>}
</div> </div>
<div className="attendance-location-coords"> <div className="attendance-location-coords">
GPS: {record.arrival_lat}, {record.arrival_lng} GPS: {record.departure_lat}, {record.departure_lng}
{record.arrival_accuracy && {record.departure_accuracy &&
` (přesnost: ${Math.round(Number(record.arrival_accuracy))}m)`} ` (přesnost: ${Math.round(Number(record.departure_accuracy))}m)`}
</div> </div>
<a <a
href={`https://www.google.com/maps?q=${record.arrival_lat},${record.arrival_lng}`} href={`https://www.google.com/maps?q=${record.departure_lat},${record.departure_lng}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="admin-btn admin-btn-secondary admin-btn-sm mt-2" className="admin-btn admin-btn-secondary admin-btn-sm mt-2"
@@ -246,48 +284,10 @@ export default function AttendanceLocation() {
</div> </div>
)} )}
</div> </div>
)}
{/* Departure */}
{(hasDepartureLocation || record.departure_time) && (
<div
className={`attendance-location-card ${!hasDepartureLocation ? "empty" : ""}`}
>
<h3 className="attendance-location-title">Odchod</h3>
<div className="attendance-location-time">
{record.departure_time
? formatDatetimeLocal(record.departure_time)
: "—"}
</div>
{hasDepartureLocation ? (
<>
<div className="attendance-location-address">
{record.departure_address || <em>Adresa nezjištěna</em>}
</div>
<div className="attendance-location-coords">
GPS: {record.departure_lat}, {record.departure_lng}
{record.departure_accuracy &&
` (přesnost: ${Math.round(Number(record.departure_accuracy))}m)`}
</div>
<a
href={`https://www.google.com/maps?q=${record.departure_lat},${record.departure_lng}`}
target="_blank"
rel="noopener noreferrer"
className="admin-btn admin-btn-secondary admin-btn-sm mt-2"
>
Otevřít v Google Maps
</a>
</>
) : (
<div className="attendance-location-address">
<em>Poloha nebyla zaznamenána</em>
</div>
)}
</div>
)}
</div>
</div> </div>
</motion.div> </div>
</div> </motion.div>
</Skeleton> </div>
); );
} }

View File

@@ -1,16 +1,16 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import Pagination from "../components/Pagination"; import Pagination from "../components/Pagination";
import FormField from "../components/FormField"; import FormField from "../components/FormField";
import FormModal from "../components/FormModal";
import AdminDatePicker from "../components/AdminDatePicker"; import AdminDatePicker from "../components/AdminDatePicker";
import { czechPlural } from "../utils/formatters"; import { czechPlural } from "../utils/formatters";
import apiFetch from "../utils/api"; import apiFetch from "../utils/api";
import { Skeleton } from "boneyard-js/react"; import { useApiMutation } from "../lib/queries/mutations";
import AuditLogFixture from "../fixtures/AuditLogFixture";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
@@ -91,7 +91,6 @@ interface Filters {
export default function AuditLog() { export default function AuditLog() {
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const alert = useAlert(); const alert = useAlert();
const queryClient = useQueryClient();
const [filters, setFilters] = useState<Filters>({ const [filters, setFilters] = useState<Filters>({
search: "", search: "",
action: "", action: "",
@@ -103,7 +102,6 @@ export default function AuditLog() {
const [perPage, setPerPage] = useState(50); const [perPage, setPerPage] = useState(50);
const [showCleanup, setShowCleanup] = useState(false); const [showCleanup, setShowCleanup] = useState(false);
const [cleanupDays, setCleanupDays] = useState(90); const [cleanupDays, setCleanupDays] = useState(90);
const [cleaning, setCleaning] = useState(false);
const { data: logsData, isPending } = useQuery({ const { data: logsData, isPending } = useQuery({
queryKey: [ queryKey: [
@@ -148,13 +146,26 @@ export default function AuditLog() {
}, },
}); });
const logs = logsData?.data ?? []; const logs: AuditLogEntry[] = logsData?.data ?? [];
const pagination = logsData?.pagination ?? null; const pagination = logsData?.pagination ?? null;
if (!hasPermission("settings.audit")) { if (!hasPermission("settings.audit")) {
return <Forbidden />; return <Forbidden />;
} }
const cleanupMutation = useApiMutation<
{ days: number; confirm?: string },
{ message?: string; error?: string }
>({
url: () => `${API_BASE}/audit-log/cleanup`,
method: () => "POST",
invalidate: ["audit-log"],
onSuccess: (data) => {
alert.success(data?.message || "Hotovo");
setShowCleanup(false);
},
});
const handleFilterChange = (key: keyof Filters, value: string) => { const handleFilterChange = (key: keyof Filters, value: string) => {
setFilters((prev) => ({ ...prev, [key]: value })); setFilters((prev) => ({ ...prev, [key]: value }));
setPage(1); setPage(1);
@@ -170,25 +181,14 @@ export default function AuditLog() {
}; };
const handleCleanup = async () => { const handleCleanup = async () => {
setCleaning(true);
try { try {
const response = await apiFetch(`${API_BASE}/audit-log/cleanup`, { // Backend requires this literal confirmation token when wiping everything.
method: "POST", await cleanupMutation.mutateAsync({
headers: { "Content-Type": "application/json" }, days: cleanupDays,
body: JSON.stringify({ days: cleanupDays }), ...(cleanupDays === 0 ? { confirm: "DELETE_ALL_AUDIT" } : {}),
}); });
const data = await response.json(); } catch (e) {
if (data.success) { alert.error(e instanceof Error ? e.message : "Chyba připojení");
alert.success(data.message);
setShowCleanup(false);
queryClient.invalidateQueries({ queryKey: ["audit-log"] });
} else {
alert.error(data.error);
}
} catch {
alert.error("Chyba připojení");
} finally {
setCleaning(false);
} }
}; };
@@ -199,13 +199,9 @@ export default function AuditLog() {
if (isPending && logs.length === 0) { if (isPending && logs.length === 0) {
return ( return (
<Skeleton <div className="admin-loading">
name="audit-log" <div className="admin-spinner" />
loading={isPending && logs.length === 0} </div>
fixture={<AuditLogFixture />}
>
<div />
</Skeleton>
); );
} }
@@ -245,78 +241,36 @@ export default function AuditLog() {
</button> </button>
</motion.div> </motion.div>
{showCleanup && ( <FormModal
<div className="admin-modal-overlay" style={{ opacity: 1 }}> isOpen={showCleanup}
<div onClose={() => !cleanupMutation.isPending && setShowCleanup(false)}
className="admin-modal-backdrop" onSubmit={handleCleanup}
onClick={() => !cleaning && setShowCleanup(false)} title="Vyčistit audit log"
/> submitLabel="Smazat"
<motion.div loading={cleanupMutation.isPending}
className="admin-modal admin-confirm-modal" >
initial={{ opacity: 0, scale: 0.95, y: 20 }} <p className="admin-confirm-message">Smazat záznamy starší než:</p>
animate={{ opacity: 1, scale: 1, y: 0 }} <div style={{ margin: "0.75rem 0", maxWidth: "200px" }}>
transition={{ duration: 0.2 }} <select
className="admin-form-select"
value={cleanupDays}
onChange={(e) => setCleanupDays(parseInt(e.target.value))}
> >
<div className="admin-modal-body admin-confirm-content"> <option value={30}>30 dní</option>
<div className="admin-confirm-icon admin-confirm-icon-danger"> <option value={60}>60 dní</option>
<svg <option value={90}>90 dní</option>
width="24" <option value={180}>180 dní</option>
height="24" <option value={365}>1 rok</option>
viewBox="0 0 24 24" <option value={0}>Vše</option>
fill="none" </select>
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</div>
<h2 className="admin-confirm-title">Vyčistit audit log</h2>
<p className="admin-confirm-message">
Smazat záznamy starší než:
</p>
<div style={{ margin: "0.75rem auto", maxWidth: "200px" }}>
<select
className="admin-form-select"
value={cleanupDays}
onChange={(e) => setCleanupDays(parseInt(e.target.value))}
>
<option value={30}>30 dní</option>
<option value={60}>60 dní</option>
<option value={90}>90 dní</option>
<option value={180}>180 dní</option>
<option value={365}>1 rok</option>
<option value={0}>Vše</option>
</select>
</div>
<p
className="admin-confirm-message"
style={{ fontSize: "12px", opacity: 0.6 }}
>
Tato akce je nevratná.
</p>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowCleanup(false)}
className="admin-btn admin-btn-secondary"
disabled={cleaning}
>
Zrušit
</button>
<button
type="button"
onClick={handleCleanup}
className="admin-btn admin-btn-primary"
disabled={cleaning}
>
{cleaning ? "Mažu..." : "Smazat"}
</button>
</div>
</motion.div>
</div> </div>
)} <p
className="admin-confirm-message"
style={{ fontSize: "12px", opacity: 0.6 }}
>
Tato akce je nevratná.
</p>
</FormModal>
<motion.div <motion.div
className="admin-card mb-4" className="admin-card mb-4"
@@ -391,90 +345,11 @@ export default function AuditLog() {
> >
<div className="admin-card-body"> <div className="admin-card-body">
<div className="admin-table-responsive"> <div className="admin-table-responsive">
<Skeleton {isPending ? (
name="audit-log-rows" <div className="admin-loading">
loading={isPending} <div className="admin-spinner" />
fixture={ </div>
<table className="admin-table"> ) : (
<thead>
<tr>
<th>Čas</th>
<th>Uživatel</th>
<th>Akce</th>
<th>Typ entity</th>
<th>Popis</th>
<th>IP</th>
</tr>
</thead>
<tbody>
{Array.from({ length: 10 }, (_, i) => (
<tr key={i}>
<td>
<div
style={{
width: 110,
height: 14,
background: "var(--bg-tertiary)",
borderRadius: 4,
}}
/>
</td>
<td>
<div
style={{
width: 80,
height: 14,
background: "var(--bg-tertiary)",
borderRadius: 4,
}}
/>
</td>
<td>
<div
style={{
width: 70,
height: 22,
background: "var(--bg-tertiary)",
borderRadius: 10,
}}
/>
</td>
<td>
<div
style={{
width: 80,
height: 14,
background: "var(--bg-tertiary)",
borderRadius: 4,
}}
/>
</td>
<td>
<div
style={{
width: "100%",
height: 14,
background: "var(--bg-tertiary)",
borderRadius: 4,
}}
/>
</td>
<td>
<div
style={{
width: 90,
height: 14,
background: "var(--bg-tertiary)",
borderRadius: 4,
}}
/>
</td>
</tr>
))}
</tbody>
</table>
}
>
<table className="admin-table"> <table className="admin-table">
<thead> <thead>
<tr> <tr>
@@ -536,7 +411,7 @@ export default function AuditLog() {
))} ))}
</tbody> </tbody>
</table> </table>
</Skeleton> )}
</div> </div>
<Pagination <Pagination

View File

@@ -13,8 +13,7 @@ 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";
import { Skeleton } from "boneyard-js/react"; import { useApiMutation } from "../lib/queries/mutations";
import CompanySettingsFixture from "../fixtures/CompanySettingsFixture";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
const DEFAULT_FIELD_ORDER = [ const DEFAULT_FIELD_ORDER = [
@@ -54,6 +53,12 @@ interface CompanyForm {
quotation_prefix: string; quotation_prefix: string;
order_type_code: string; order_type_code: string;
invoice_type_code: string; invoice_type_code: string;
warehouse_receipt_prefix: string;
warehouse_receipt_number_pattern: string;
warehouse_issue_prefix: string;
warehouse_issue_number_pattern: string;
warehouse_inventory_prefix: string;
warehouse_inventory_number_pattern: string;
default_currency: string; default_currency: string;
default_vat_rate: number; default_vat_rate: number;
available_currencies: string[]; available_currencies: string[];
@@ -99,6 +104,12 @@ export default function CompanySettings({
quotation_prefix: "", quotation_prefix: "",
order_type_code: "", order_type_code: "",
invoice_type_code: "", invoice_type_code: "",
warehouse_receipt_prefix: "",
warehouse_receipt_number_pattern: "",
warehouse_issue_prefix: "",
warehouse_issue_number_pattern: "",
warehouse_inventory_prefix: "",
warehouse_inventory_number_pattern: "",
default_currency: "CZK", default_currency: "CZK",
default_vat_rate: 21, default_vat_rate: 21,
available_currencies: ["CZK", "EUR", "USD", "GBP"], available_currencies: ["CZK", "EUR", "USD", "GBP"],
@@ -124,6 +135,40 @@ export default function CompanySettings({
is_default: false, is_default: false,
}); });
const saveSettingsMutation = useApiMutation<
Record<string, unknown>,
{ message?: string }
>({
url: () => `${API_BASE}/company-settings`,
method: () => "PUT",
invalidate: [
"settings",
"company-settings",
"attendance",
"offers",
"orders",
"invoices",
],
});
const bankMutation = useApiMutation<
{ id?: number; bank: BankForm },
{ message?: string }
>({
url: (input) =>
input.id != null
? `${API_BASE}/bank-accounts/${input.id}`
: `${API_BASE}/bank-accounts`,
method: (input) => (input.id != null ? "PUT" : "POST"),
invalidate: ["settings", "bank-accounts", "invoices"],
});
const bankDeleteMutation = useApiMutation<number, { message?: string }>({
url: (id) => `${API_BASE}/bank-accounts/${id}`,
method: () => "DELETE",
invalidate: ["settings", "bank-accounts", "invoices"],
});
const getFullFieldOrder = useCallback((): string[] => { const getFullFieldOrder = useCallback((): string[] => {
const allBuiltIn = [...DEFAULT_FIELD_ORDER]; const allBuiltIn = [...DEFAULT_FIELD_ORDER];
const order = [...fieldOrder].filter((k) => k !== "company_name"); const order = [...fieldOrder].filter((k) => k !== "company_name");
@@ -224,6 +269,15 @@ export default function CompanySettings({
quotation_prefix: settingsData.quotation_prefix || "", quotation_prefix: settingsData.quotation_prefix || "",
order_type_code: settingsData.order_type_code || "", order_type_code: settingsData.order_type_code || "",
invoice_type_code: settingsData.invoice_type_code || "", invoice_type_code: settingsData.invoice_type_code || "",
warehouse_receipt_prefix: settingsData.warehouse_receipt_prefix || "",
warehouse_receipt_number_pattern:
settingsData.warehouse_receipt_number_pattern || "",
warehouse_issue_prefix: settingsData.warehouse_issue_prefix || "",
warehouse_issue_number_pattern:
settingsData.warehouse_issue_number_pattern || "",
warehouse_inventory_prefix: settingsData.warehouse_inventory_prefix || "",
warehouse_inventory_number_pattern:
settingsData.warehouse_inventory_number_pattern || "",
default_currency: settingsData.default_currency || "CZK", default_currency: settingsData.default_currency || "CZK",
default_vat_rate: settingsData.default_vat_rate ?? 21, default_vat_rate: settingsData.default_vat_rate ?? 21,
available_currencies: available_currencies:
@@ -283,26 +337,14 @@ export default function CompanySettings({
} }
setBankSaving(true); setBankSaving(true);
try { try {
const isEdit = editingBank !== null; const result = await bankMutation.mutateAsync({
const url = isEdit id: editingBank ?? undefined,
? `${API_BASE}/bank-accounts/${editingBank}` bank: bankForm,
: `${API_BASE}/bank-accounts`;
const response = await apiFetch(url, {
method: isEdit ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(bankForm),
}); });
const result = await response.json(); alert.success(result?.message || "Uloženo");
if (result.success) { resetBankForm();
alert.success(result.message); } catch (e) {
resetBankForm(); alert.error(e instanceof Error ? e.message : "Chyba připojení");
queryClient.invalidateQueries({ queryKey: ["bank-accounts"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Chyba při ukládání");
}
} catch {
alert.error("Chyba připojení");
} finally { } finally {
setBankSaving(false); setBankSaving(false);
} }
@@ -315,23 +357,11 @@ export default function CompanySettings({
const confirmBankDelete = async () => { const confirmBankDelete = async () => {
if (bankDeleteConfirm.id == null) return; if (bankDeleteConfirm.id == null) return;
try { try {
const response = await apiFetch( const result = await bankDeleteMutation.mutateAsync(bankDeleteConfirm.id);
`${API_BASE}/bank-accounts/${bankDeleteConfirm.id}`, alert.success(result?.message || "Smazáno");
{ if (editingBank === bankDeleteConfirm.id) resetBankForm();
method: "DELETE", } catch (e) {
}, alert.error(e instanceof Error ? e.message : "Chyba připojení");
);
const result = await response.json();
if (result.success) {
alert.success(result.message);
if (editingBank === bankDeleteConfirm.id) resetBankForm();
queryClient.invalidateQueries({ queryKey: ["bank-accounts"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Chyba při mazání");
}
} catch {
alert.error("Chyba připojení");
} finally { } finally {
setBankDeleteConfirm({ isOpen: false, id: null }); setBankDeleteConfirm({ isOpen: false, id: null });
} }
@@ -368,23 +398,12 @@ export default function CompanySettings({
), ),
supplier_field_order: getFullFieldOrder(), supplier_field_order: getFullFieldOrder(),
}; };
const response = await apiFetch(`${API_BASE}/company-settings`, { const result = await saveSettingsMutation.mutateAsync(payload);
method: "PUT", alert.success(result?.message || "Nastavení bylo uloženo");
headers: { "Content-Type": "application/json" }, } catch (e) {
body: JSON.stringify(payload), alert.error(
}); e instanceof Error ? e.message : "Nepodařilo se uložit nastavení",
const result = await response.json(); );
if (result.success) {
alert.success(result.message || "Nastavení bylo uloženo");
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Nepodařilo se uložit nastavení");
}
} catch {
alert.error("Chyba připojení");
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -438,13 +457,9 @@ export default function CompanySettings({
if (settingsLoading) { if (settingsLoading) {
return ( return (
<Skeleton <div className="admin-loading">
name="company-settings" <div className="admin-spinner" />
loading={settingsLoading} </div>
fixture={<CompanySettingsFixture />}
>
<div />
</Skeleton>
); );
} }
@@ -755,13 +770,9 @@ export default function CompanySettings({
</div> </div>
<div className="admin-card-body"> <div className="admin-card-body">
{bankLoading ? ( {bankLoading ? (
<Skeleton <div className="admin-loading">
name="company-settings-bank" <div className="admin-spinner" />
loading={bankLoading} </div>
fixture={<CompanySettingsFixture />}
>
<div />
</Skeleton>
) : ( ) : (
<> <>
{bankAccountsList.length > 0 && ( {bankAccountsList.length > 0 && (
@@ -1106,7 +1117,7 @@ export default function CompanySettings({
> >
<strong>Dostupné zástupné znaky:</strong>{" "} <strong>Dostupné zástupné znaky:</strong>{" "}
<code>{"{YYYY}"}</code> rok, <code>{"{YY}"}</code> rok (2 <code>{"{YYYY}"}</code> rok, <code>{"{YY}"}</code> rok (2
číslice), <code>{"{PREFIX}"}</code> prefix nabídky,{" "} číslice), <code>{"{PREFIX}"}</code> prefix (nabídky / sklad),{" "}
<code>{"{CODE}"}</code> typový kód, <code>{"{NNN}"}</code>{" "} <code>{"{CODE}"}</code> typový kód, <code>{"{NNN}"}</code>{" "}
pořadí (3 číslice), <code>{"{NNNN}"}</code> pořadí (4 pořadí (3 číslice), <code>{"{NNNN}"}</code> pořadí (4
číslice), <code>{"{NNNNN}"}</code> pořadí (5 číslic) číslice), <code>{"{NNNNN}"}</code> pořadí (5 číslic)
@@ -1136,6 +1147,30 @@ export default function CompanySettings({
codeKey: "invoice_type_code" as const, codeKey: "invoice_type_code" as const,
codeLabel: "Typový kód", codeLabel: "Typový kód",
}, },
{
label: "Sklad — Příjmy",
patternKey: "warehouse_receipt_number_pattern" as const,
defaultPattern: "{PREFIX}-{YYYY}-{NNN}",
prefixKey: "warehouse_receipt_prefix" as const,
prefixLabel: "Prefix",
codeKey: null,
},
{
label: "Sklad — Výdeje",
patternKey: "warehouse_issue_number_pattern" as const,
defaultPattern: "{PREFIX}-{YYYY}-{NNN}",
prefixKey: "warehouse_issue_prefix" as const,
prefixLabel: "Prefix",
codeKey: null,
},
{
label: "Sklad — Inventarizace",
patternKey: "warehouse_inventory_number_pattern" as const,
defaultPattern: "{PREFIX}-{YYYY}-{NNN}",
prefixKey: "warehouse_inventory_prefix" as const,
prefixLabel: "Prefix",
codeKey: null,
},
].map((cfg, idx) => { ].map((cfg, idx) => {
const pattern = form[cfg.patternKey] || cfg.defaultPattern; const pattern = form[cfg.patternKey] || cfg.defaultPattern;
const yyyy = String(new Date().getFullYear()); const yyyy = String(new Date().getFullYear());

View File

@@ -9,14 +9,13 @@ import apiFetch from "../utils/api";
import { dashboardOptions } from "../lib/queries/dashboard"; import { dashboardOptions } from "../lib/queries/dashboard";
import { require2FAOptions } from "../lib/queries/settings"; import { require2FAOptions } from "../lib/queries/settings";
import { getCzechDate } from "../utils/dashboardHelpers"; import { getCzechDate } from "../utils/dashboardHelpers";
import { useApiMutation } from "../lib/queries/mutations";
import DashKpiCards from "../components/dashboard/DashKpiCards"; import DashKpiCards from "../components/dashboard/DashKpiCards";
import DashQuickActions from "../components/dashboard/DashQuickActions"; import DashQuickActions from "../components/dashboard/DashQuickActions";
import DashActivityFeed from "../components/dashboard/DashActivityFeed"; import DashActivityFeed from "../components/dashboard/DashActivityFeed";
import DashAttendanceToday from "../components/dashboard/DashAttendanceToday"; import DashAttendanceToday from "../components/dashboard/DashAttendanceToday";
import DashProfile from "../components/dashboard/DashProfile"; import DashProfile from "../components/dashboard/DashProfile";
import DashSessions from "../components/dashboard/DashSessions"; import DashSessions from "../components/dashboard/DashSessions";
import { Skeleton } from "boneyard-js/react";
import DashboardFixture from "../fixtures/DashboardFixture";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
@@ -84,6 +83,15 @@ export default function Dashboard() {
useQuery(require2FAOptions()); useQuery(require2FAOptions());
const totpEnabled = totpData?.require_2fa ?? !!user?.totpEnabled; const totpEnabled = totpData?.require_2fa ?? !!user?.totpEnabled;
const punchMutation = useApiMutation<
Record<string, unknown>,
{ message?: string }
>({
url: () => `${API_BASE}/attendance`,
method: () => "POST",
invalidate: ["attendance", "dashboard"],
});
// 2FA state - sdileny mezi profilem a bannerem // 2FA state - sdileny mezi profilem a bannerem
const [show2FASetup, setShow2FASetup] = useState(false); const [show2FASetup, setShow2FASetup] = useState(false);
const [show2FADisable, setShow2FADisable] = useState(false); const [show2FADisable, setShow2FADisable] = useState(false);
@@ -104,21 +112,13 @@ export default function Dashboard() {
const submitPunch = async (gpsData: Record<string, unknown> = {}) => { const submitPunch = async (gpsData: Record<string, unknown> = {}) => {
try { try {
const response = await apiFetch(`${API_BASE}/attendance`, { const result = await punchMutation.mutateAsync({
method: "POST", punch_action: action,
headers: { "Content-Type": "application/json" }, ...gpsData,
body: JSON.stringify({ punch_action: action, ...gpsData }),
}); });
const result = await response.json(); alert.success(result?.message || "Docházka zaznamenána");
if (result.success) { } catch (e) {
alert.success(result.data?.message || "Docházka zaznamenána"); alert.error(e instanceof Error ? e.message : "Chyba pripojeni");
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
} else {
alert.error(result.error || "Chyba při záznamu docházky");
}
} catch {
alert.error("Chyba pripojeni");
} finally { } finally {
setPunching(false); setPunching(false);
} }
@@ -137,7 +137,7 @@ export default function Dashboard() {
() => submitPunch({}), () => submitPunch({}),
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }, { enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 },
); );
}, [dashData, alert, queryClient]); }, [dashData, alert, punchMutation]);
// 2FA handlery // 2FA handlery
const handleStart2FASetup = async () => { const handleStart2FASetup = async () => {
@@ -309,15 +309,11 @@ export default function Dashboard() {
</motion.div> </motion.div>
)} )}
{/* Skeleton loading */} {/* Loading spinner */}
{dashLoading && ( {dashLoading && (
<Skeleton <div className="admin-loading">
name="dashboard" <div className="admin-spinner" />
loading={dashLoading} </div>
fixture={<DashboardFixture />}
>
<div />
</Skeleton>
)} )}
{/* KPI cards — only show if user has any admin-level permissions */} {/* KPI cards — only show if user has any admin-level permissions */}
@@ -325,12 +321,14 @@ export default function Dashboard() {
(hasPermission("offers.view") || (hasPermission("offers.view") ||
hasPermission("invoices.view") || hasPermission("invoices.view") ||
hasPermission("projects.view") || hasPermission("projects.view") ||
hasPermission("orders.view")) && <DashKpiCards dashData={dashData} />} hasPermission("orders.view")) && (
<DashKpiCards dashData={dashData ?? null} />
)}
{/* Quick actions */} {/* Quick actions */}
{!dashLoading && ( {!dashLoading && (
<DashQuickActions <DashQuickActions
dashData={dashData} dashData={dashData ?? null}
punching={punching} punching={punching}
onPunch={handleQuickPunch} onPunch={handleQuickPunch}
/> />

File diff suppressed because it is too large Load Diff

View File

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

Some files were not shown because too many files have changed in this diff Show More