23 Commits

Author SHA1 Message Date
BOHA
746d17e182 fix: parse YYYY-MM month filter correctly in attendance history
The frontend sends month as "YYYY-MM" but the route handler was passing
it through Number() which parsed only the year portion, causing the
service to ignore the month filter entirely.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 09:29:47 +02:00
BOHA
e96e51598a v1.5.8: fix audit log table layout (Skeleton outside tbody)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 09:08:15 +02:00
BOHA
9abec36f07 v1.5.7: fix Settings system tab crash and OffersTemplates tab gap
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 08:29:10 +02:00
BOHA
ecd8e3679f fix: replace stray role reference in system settings tab with inline placeholder
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 08:04:22 +02:00
BOHA
ba95723b61 v1.5.6: boneyard-js skeleton migration, TanStack Query refactor, rate-limit config
- Replace hand-coded skeleton CSS/JSX with boneyard-js auto-generated bones
- Remove skeleton.css and @keyframes shimmer from base.css
- Add <Skeleton> wrappers with fixtures to all 25+ page components
- Generate 20 bone captures via boneyard CLI (CDP auth-gated capture)
- Refactor data fetching from useEffect+useState to TanStack Query
- Extract query hooks into src/admin/lib/queries/ and apiAdapter
- Add usePaginatedQuery hook replacing useApiCall/useListData
- Fix parseFloat || 0 anti-pattern in OfferDetail and OffersTemplates inputs
- Fix customer_id mandatory validation on offer creation
- Fix leave-requests comma-separated status filter (Prisma enum in: [])
- Add cross-entity cache invalidation for orders/offers/invoices/projects
- Make rate limits configurable via env vars (RATE_LIMIT_MAX, RATE_LIMIT_REFRESH, etc.)
- Add boneyard.config.json with routes and breakpoints

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 22:35:43 +02:00
BOHA
12289bdce3 fix: only show session-expired alert when user had a valid session
Added hadValidSessionRef to track whether the user was ever
authenticated during this page load. setSessionExpired() in
silentRefresh now only fires when the ref is true, preventing
the alert on direct visits by unauthenticated users.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 12:16:26 +02:00
BOHA
d1c5234a03 fix: allow logo endpoint without auth for <img> tag loading
Logo images are loaded via <img src> which doesn't carry auth cookies
reliably during login transitions. Changed from requireAuth to
optionalAuth — logos are not sensitive data.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 11:52:24 +02:00
BOHA
27cc876e82 fix: add missing migration for totp_last_used_counter column
Column existed in Prisma schema but had no migration, causing 500 on
TOTP login in production where the column was absent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 11:45:11 +02:00
BOHA
82919d39f6 fix: remove manual project creation, smart sequence release, received-invoices schema fix
- Remove ProjectCreate page, POST /projects endpoint, and next-number endpoint
- Projects can only be created through orders (shared numbering sequence)
- Remove dead CreateProjectSchema and createProject service function
- Delete 'order' row from number_sequences (unused; code uses 'shared')
- Smart sequence release: decrement last_number only when deleting the highest number
- Fix received-invoices stats referencing non-existent is_deleted and amount_czk columns
- Update deploy instructions in CLAUDE.md (npm install, prisma migrate deploy)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 11:36:08 +02:00
BOHA
3481b97d47 fix: useEffect anti-patterns, attendance permissions, and received-invoices schema mismatch
- Remove ref-mirror useEffect in AuthContext (cachedUserRef already written at mutation sites)
- Replace useEffect slide direction in ReceivedInvoices with render-time computation
- Fix Login.tsx useEffect dependency array (mount-only alert should have [] deps)
- Move "project created" alert to navigation source in ProjectCreate, remove useEffect in ProjectDetail
- Move companySettings defaults into fetch callbacks in InvoiceDetail and OfferDetail
- Replace due_date useEffect with useMemo in InvoiceDetail
- Capture initial snapshots from API data instead of useEffect in InvoiceDetail, OfferDetail, OrderDetail
- Replace localStorage draft useEffect with lazy useState initializer in OfferDetail
- Fix attendance dropdown to filter by attendance.record permission only
- Fix clock-out 404 on update-address (remove departure_time filter for departure action)
- Fix received-invoices stats endpoint referencing non-existent is_deleted and amount_czk columns

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 10:28:15 +02:00
BOHA
d7c7fbad88 fix: security, validation, and data integrity fixes across 53 files
- Auth: HS256 algorithm restriction on JWT verify, timing-safe bcrypt
  for inactive/locked users, locked_until check in loadAuthData, TOTP
  fixes (async bcrypt, BigInt conversion, future-code counter fix)
- Validation: Zod enums for leave_type/status, numeric transforms on
  foreign keys, VAT 0% coercion fix (Number(v)||21 → v!=null checks)
- Permissions: requirePermission on attendance PUT, attendance_users
  and project_logs access checks, trips users filtered by trips.record
- Prisma queries: fixed roles.is:{OR} pattern (doesn't work on to-one
  relations), attendance_users now filters by attendance.record only
- Transactions: wrapped deleteOrder, createOrder, updateUser, deleteUser,
  duplicateOffer, bulkCreateAttendance, createLeave, scope-templates,
  leave-requests, company-settings, profile updates
- Frontend: mountedRef reset in useListData, blob URL cleanup on unmount,
  null checks on date fields, AdminDatePicker min/max for HH:mm
- Security headers: COOP, CORP, CSP frame-ancestors/form-action/base-uri
- Other: exchange-rate cache TTL, invoice-alert midnight comparison fix,
  numbering.service releaseSequence no-op, nas-offers filename sanitize,
  Content-Disposition header injection fix, mojibake Czech strings

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 08:40:38 +02:00
BOHA
7f07032bf2 fix: attendance clock-in silently aborted by broken mountedRef guard
mountedRef was initialized to true but never reset on mount. The
cleanup function (useEffect return) set it to false on unmount. In
React 18 Strict Mode, components mount-unmount-remount during dev.
After the first cleanup, mountedRef stayed false forever.

Result: handlePunch set submitting=true, geolocation callbacks fired,
but every callback returned early at `if (!mountedRef.current) return`
before calling submitPunch. No server request, button stuck.

Fix: add `mountedRef.current = true` inside the useEffect body.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 08:34:01 +02:00
BOHA
d873c96ae3 fix: attendance clock-in hanging after geolocation confirmation
On desktop browsers without GPS hardware, getCurrentPosition with
enableHighAccuracy:true can silently hang after the user grants
permission — neither success nor error callback fires.

Previous safety timeout (12s) only reset the button without sending
the punch request, leaving users stuck. Now:
- enableHighAccuracy: false (faster fallback to IP-based location)
- Browser timeout reduced to 5s
- Safety timeout reduced to 6s and automatically calls submitPunch
  without GPS data instead of just showing an error
- Wrapped success callback in try/catch as additional safeguard

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 08:23:12 +02:00
BOHA
c4f6723042 fix: attendance clock-in button stuck when geolocation fails or hangs
handlePunch set submitting=true before calling geolocation, but the
error callback never reset it. When geolocation was denied or timed out:
- Error alert showed
- GPS confirm modal opened
- Button stayed disabled showing "Zpracovávám..."
- User thought it was stuck; no server request appeared to happen

Also added a 12s safety timeout fallback because some browsers silently
hang on getCurrentPosition without calling either callback.

Fix: call setSubmitting(false) in the error callback and clear the
safety timeout in both success and error paths.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 11:40:51 +02:00
BOHA
9e699c4dd4 fix: React hooks rules violation in Login.tsx causing crash on load
useCallback hooks were placed AFTER conditional early returns.
When authLoading toggled from true -> false, the hook count changed
between renders (14 hooks vs 17 hooks), triggering React's
"Rendered more hooks than during the previous render" error.

Moved all useCallback definitions before the conditional returns.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 11:31:00 +02:00
BOHA
ea81380225 fix: dashboard $queryRaw Date serialization with custom toJSON override
Prisma $queryRaw template literal interpolation fails when Date objects
are passed directly and Date.prototype.toJSON is overridden (returns
local time string instead of UTC ISO). MySQL driver receives a nested
JSON object instead of a flat parameter array.

Fix: convert monthStart/monthEnd to strings via toJSON() before
interpolating into the $queryRaw template literal.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 11:23:27 +02:00
BOHA
a9bc82fac5 fix: Prisma $queryRaw MySQL type coercion for BigInt and Boolean
$queryRaw on MySQL returns BigInt for integer columns and 0/1 for booleans.
Passing these raw values back to Prisma client methods causes validation errors:
- Expected Int, provided BigInt
- Expected Boolean, provided Int

Fixed in auth refresh, TOTP login, and TOTP backup code flows by wrapping
storedToken.id, storedToken.user_id with Number() and remember_me with Boolean().

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 11:18:38 +02:00
BOHA
8c278be941 test: add regression tests for Critical+High FLAWS_REPORT fixes
- Tests caught 2 real bugs:
  - Zod NaN bypass in orders/offers schemas (Number(v) || fallback)
  - invoiceTotalWithVat using Number() on { toNumber() } objects
- 7 new test files covering auth, env, exchange rates, NAS paths,
  schema NaN rejection, invoice VAT calculation, customer validation
- 45 tests passing, build clean

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 11:04:20 +02:00
BOHA
aa6c1b5094 refactor: fix all Low findings from FLAWS_REPORT audit
- Auth: TOTP params from config, JWT error logging, audit log failure
  logging, replaced_by_hash validation on token rotation
- Invoices: remove dead VAT code, consistent PDF permissions,
  WebP magic-byte detection, deduped exchange-rate fetches
- Orders/Offers: multipart limit from config, use paginated() helper,
  payment method from DB in PDF
- Projects: verify project exists before creating note
- Attendance: action_type enum validation, consistent local-time
  shift_date construction, holiday attendance in work fund,
  trips.view permission on last-km query
- Users: paginated() helper usage, remove duplicate dashboard keys,
  parallel currency conversion, single hashToken implementation
- Frontend: memoized customInput, reliable print onload, modal prop
  standardization (isOpen), ConfirmModal type icons, id===0 key
  fallback, Login useCallback, CompanySettings ConfirmModal,
  Attendance timeout cleanup, Dashboard memoization, beforeunload
  dirty-state warnings on Invoice/Offer/Order detail
- Schema: invoice_alert_log timestamp, config/env comment on
  Date.prototype.toJSON override
- Utils: exchange-rate inflight dedup

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 08:45:37 +02:00
BOHA
4f4b12f039 security: fix all Medium findings from FLAWS_REPORT audit
- Auth: TOTP replay protection with counter tracking, constant-time
  backup code comparison, atomic lockout increment, per-token logout
- Invoices/PDFs: net-based VAT calculation, dangerous URL scheme
  stripping in cleanQuillHtml, orders-pdf error handling
- Orders: reject item changes on status transition, cascading
  delete cleanup, take:1 with orderBy
- Projects: atomic rename collision handling, MIME/extension
  validation, empty customer name rejection
- Attendance: Czech public holiday awareness in frontend fund
  calculation, leave_hours 0 handling, invalid date NaN guard,
  bounded per-month queries in workfund
- Users/Admin: profile audit logging + password validation, session
  revocation guard, session ID validation, dashboard DB aggregation,
  soft-deleted record protection in scope templates
- Frontend: FormField label linkage, Pagination ARIA, error
  handling in OrderConfirmationModal, 401 propagation, GPS emoji
  hidden from screen readers, table sort state fix, geolocation
  race/abort cleanup, Leaflet popup DOM safety, Vehicles toggleActive
  minimal body, CompanySettings ref mutation fix, OfferDetail unlock
  abort, AttendanceBalances combined fetches
- Utils: env validation, Puppeteer concurrency mutex, invoice alert
  cron cleanup on shutdown, body limit alignment, TOTP error logging,
  trustProxy from env, symlink rejection, rate cache Map usage

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 08:24:14 +02:00
BOHA
528e55991b security: fix all Critical and High findings from FLAWS_REPORT audit
- Auth: pessimistic locking on login tokens and refresh token rotation,
  backup code attempt counter, rate limiting verification
- Schema: unique constraints on business numbers, FK relations,
  unsigned/signed alignment, attendance duplicate prevention
- Invoices/PDFs: DOMPurify sanitization, bounded queries in stats
  and alerts, VAT rounding, Puppeteer error handling
- Orders/Offers: transactional parent+child creation, Zod NaN
  refinement, status enums, uniqueness checks
- Projects/Files: path traversal protection, streamed uploads,
  permission guards, query param validation
- Attendance/HR: duplicate checks, ownership validation, GPS
  restrictions, trip distance validation
- Frontend: modal lock reference counting, XSS escaping in print
  HTML, ref mutation fixes, accessibility attributes

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 00:58:35 +02:00
BOHA
122eee175e docs: update CLAUDE.md release process and file count
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:36:49 +02:00
BOHA
5a28f75303 1.5.3
- feat: manual VAT override in order confirmation modal
- feat: order confirmation PDF respects user-selected applyVat toggle

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 18:17:20 +02:00
191 changed files with 30962 additions and 12543 deletions

View File

@@ -7,13 +7,13 @@ HOST=127.0.0.1
APP_ENV=local APP_ENV=local
# Auth — MUST regenerate for production: openssl rand -hex 32 # Auth — MUST regenerate for production: openssl rand -hex 32
JWT_SECRET=generate-with-openssl-rand-hex-32 JWT_SECRET=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32
ACCESS_TOKEN_EXPIRY=900 ACCESS_TOKEN_EXPIRY=900
REFRESH_TOKEN_SESSION_EXPIRY=3600 REFRESH_TOKEN_SESSION_EXPIRY=3600
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000 REFRESH_TOKEN_REMEMBER_EXPIRY=2592000
# TOTP — MUST regenerate for production: openssl rand -hex 32 # TOTP — MUST regenerate for production: openssl rand -hex 32
TOTP_ENCRYPTION_KEY=generate-with-openssl-rand-hex-32 TOTP_ENCRYPTION_KEY=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32
# File storage # File storage
NAS_PATH=Z:/02_PROJEKTY NAS_PATH=Z:/02_PROJEKTY

View File

@@ -35,7 +35,7 @@ src/
├── utils/ # totp.ts, pdf.ts, email.ts, audit.ts, formatters, etc. ├── utils/ # totp.ts, pdf.ts, email.ts, audit.ts, formatters, etc.
├── config/ # env.ts (config singleton, Date.toJSON override) ├── config/ # env.ts (config singleton, Date.toJSON override)
├── types/ # index.ts (AuthData, JwtPayload, ApiResponse, re-exports from Prisma) ├── types/ # index.ts (AuthData, JwtPayload, ApiResponse, re-exports from Prisma)
├── admin/ # React 18 frontend (56 .tsx files) ├── admin/ # React 18 frontend (57 .tsx files)
│ ├── AdminApp.tsx # Router + lazy-loaded pages │ ├── AdminApp.tsx # Router + lazy-loaded pages
│ ├── contexts/ # AuthContext, AlertContext │ ├── contexts/ # AuthContext, AlertContext
│ ├── components/ # Layout, modals, tables, editors │ ├── components/ # Layout, modals, tables, editors
@@ -288,8 +288,16 @@ When adding new features, add tests in `src/__tests__/`. Name test files `<featu
1. Bump version in `package.json` 1. Bump version in `package.json`
2. `npm run build` 2. `npm run build`
3. Create a tarball 3. Commit and tag (`git tag -a vX.Y.Z`)
4. Tag the release in Gitea 4. Push to Gitea (`git push origin master && git push origin vX.Y.Z`)
5. Deploy via SSH to production server (`boha_admin@192.168.50.100`) 5. Create tarball: `tar -czf app-ts-X.Y.Z.tar.gz dist dist-client prisma package.json package-lock.json scripts`
6. Deploy via SSH to production server (`boha_admin@192.168.50.100`):
- Path: `/var/www/app-ts`
- Remove old files: `rm -rf dist dist-client prisma scripts package.json package-lock.json`
- Copy tarball to server: `scp app-ts-X.Y.Z.tar.gz boha_admin@192.168.50.100:/tmp/`
- Extract tarball: `tar -xzf /tmp/app-ts-X.Y.Z.tar.gz`
- Install dependencies: `npm install --omit=dev`
- Apply Prisma migrations: `npx prisma migrate deploy`
- Restart: `pm2 restart app-ts --update-env`
Do not push directly to production or restart services without confirmation. Do not push directly to production or restart services without confirmation.

37
boneyard.config.json Normal file
View File

@@ -0,0 +1,37 @@
{
"breakpoints": [375, 768, 1280],
"out": "./src/admin/bones",
"color": "#e0e0e0",
"animate": "shimmer",
"shimmerColor": "#f0f0f0",
"speed": "1.2s",
"shimmerAngle": 110,
"wait": 3000,
"routes": [
"/",
"/users",
"/attendance",
"/attendance/history",
"/attendance/admin",
"/attendance/balances",
"/attendance/requests",
"/attendance/approval",
"/attendance/create",
"/trips",
"/trips/history",
"/trips/admin",
"/vehicles",
"/offers",
"/offers/new",
"/offers/customers",
"/offers/templates",
"/orders",
"/orders/1",
"/projects",
"/projects/80",
"/invoices",
"/invoices/new",
"/settings",
"/audit-log"
]
}

749
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.5.2", "version": "1.5.9",
"description": "", "description": "",
"main": "dist/server.js", "main": "dist/server.js",
"scripts": { "scripts": {
@@ -17,7 +17,8 @@
"db:push": "prisma db push", "db:push": "prisma db push",
"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"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
@@ -34,7 +35,10 @@
"@fastify/rate-limit": "^10.3.0", "@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^9.0.0", "@fastify/static": "^9.0.0",
"@prisma/client": "^6.19.2", "@prisma/client": "^6.19.2",
"@tanstack/react-query": "^5.100.5",
"@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",
@@ -42,6 +46,7 @@
"file-type": "^16.5.4", "file-type": "^16.5.4",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"hi-base32": "^0.5.1", "hi-base32": "^0.5.1",
"jsdom": "^29.0.2",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",

View File

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

View File

@@ -32,7 +32,7 @@ model attendance {
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "attendance_ibfk_1") users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "attendance_ibfk_1")
attendance_project_logs attendance_project_logs[] attendance_project_logs attendance_project_logs[]
@@index([user_id, shift_date], map: "idx_attendance_user_date") @@unique([user_id, shift_date], map: "idx_attendance_user_date")
@@index([user_id, departure_time], map: "idx_attendance_user_departure") @@index([user_id, departure_time], map: "idx_attendance_user_departure")
@@index([project_id], map: "idx_project_id") @@index([project_id], map: "idx_project_id")
} }
@@ -46,6 +46,7 @@ model attendance_project_logs {
hours Int? @db.UnsignedInt hours Int? @db.UnsignedInt
minutes Int? @db.UnsignedInt minutes Int? @db.UnsignedInt
attendance attendance @relation(fields: [attendance_id], references: [id], onDelete: Cascade, onUpdate: NoAction) attendance attendance @relation(fields: [attendance_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
projects projects? @relation(fields: [project_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@index([attendance_id], map: "idx_attendance_project_logs_aid") @@index([attendance_id], map: "idx_attendance_project_logs_aid")
@@index([project_id], map: "idx_project_id") @@index([project_id], map: "idx_project_id")
@@ -104,7 +105,7 @@ model company_settings {
quotation_prefix String? @db.VarChar(20) quotation_prefix String? @db.VarChar(20)
default_currency String? @default("CZK") @db.VarChar(10) default_currency String? @default("CZK") @db.VarChar(10)
default_vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) default_vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
uuid String? @db.VarChar(36) uuid String? @unique @db.VarChar(36)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
is_deleted Boolean? @default(false) is_deleted Boolean? @default(false)
sync_version Int? @default(0) sync_version Int? @default(0)
@@ -165,7 +166,7 @@ model invoice_items {
model invoices { model invoices {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
invoice_number String? @db.VarChar(50) invoice_number String? @unique(map: "idx_invoices_number_unique") @db.VarChar(50)
order_id Int? order_id Int?
customer_id Int? customer_id Int?
status String? @default("issued") @db.VarChar(30) status String? @default("issued") @db.VarChar(30)
@@ -196,6 +197,7 @@ model invoices {
@@index([customer_id], map: "customer_id") @@index([customer_id], map: "customer_id")
@@index([due_date], map: "idx_invoices_due_date") @@index([due_date], map: "idx_invoices_due_date")
@@index([status, issue_date], map: "idx_invoices_status_issue") @@index([status, issue_date], map: "idx_invoices_status_issue")
@@index([status, due_date], map: "idx_invoices_status_due")
@@index([order_id], map: "order_id") @@index([order_id], map: "order_id")
} }
@@ -288,7 +290,7 @@ model order_sections {
model orders { model orders {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
order_number String? @db.VarChar(50) order_number String? @unique(map: "idx_orders_number_unique") @db.VarChar(50)
customer_order_number String? @db.VarChar(100) customer_order_number String? @db.VarChar(100)
attachment_data Bytes? attachment_data Bytes?
attachment_name String? @db.VarChar(255) attachment_name String? @db.VarChar(255)
@@ -340,7 +342,7 @@ model project_notes {
model projects { model projects {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
project_number String? @db.VarChar(50) project_number String? @unique @db.VarChar(50)
name String? @db.VarChar(255) name String? @db.VarChar(255)
customer_id Int? customer_id Int?
responsible_user_id Int? responsible_user_id Int?
@@ -352,6 +354,7 @@ model projects {
notes String? @db.Text notes String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
attendance_project_logs attendance_project_logs[]
project_notes project_notes[] project_notes project_notes[]
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")
@@ -385,7 +388,7 @@ model quotation_items {
model quotations { model quotations {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
quotation_number String? @db.VarChar(50) quotation_number String? @unique @db.VarChar(50)
project_code String? @db.VarChar(50) project_code String? @db.VarChar(50)
customer_id Int? customer_id Int?
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
@@ -434,7 +437,7 @@ model received_invoices {
file_mime String? @db.VarChar(100) file_mime String? @db.VarChar(100)
file_size Int? @db.UnsignedInt file_size Int? @db.UnsignedInt
notes String? @db.Text notes String? @db.Text
uploaded_by Int? @db.UnsignedInt uploaded_by Int?
created_at DateTime @default(now()) @db.DateTime(0) created_at DateTime @default(now()) @db.DateTime(0)
modified_at DateTime @default(now()) @db.DateTime(0) modified_at DateTime @default(now()) @db.DateTime(0)
@@ -446,7 +449,7 @@ model received_invoices {
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments /// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model refresh_tokens { model refresh_tokens {
id Int @id @default(autoincrement()) @db.UnsignedInt id Int @id @default(autoincrement()) @db.UnsignedInt
user_id Int @db.UnsignedInt user_id Int
token_hash String @unique(map: "token_hash") @db.VarChar(64) token_hash String @unique(map: "token_hash") @db.VarChar(64)
expires_at DateTime @db.DateTime(0) expires_at DateTime @db.DateTime(0)
replaced_at DateTime? @db.DateTime(0) replaced_at DateTime? @db.DateTime(0)
@@ -580,6 +583,7 @@ model users {
totp_secret String? @db.VarChar(255) totp_secret String? @db.VarChar(255)
totp_enabled Boolean @default(false) totp_enabled Boolean @default(false)
totp_backup_codes String? @db.Text totp_backup_codes String? @db.Text
totp_last_used_counter Int?
attendance attendance[] attendance attendance[]
leave_balances leave_balances[] leave_balances leave_balances[]
leave_requests_leave_requests_user_idTousers leave_requests[] @relation("leave_requests_user_idTousers") leave_requests_leave_requests_user_idTousers leave_requests[] @relation("leave_requests_user_idTousers")
@@ -626,6 +630,7 @@ model invoice_alert_log {
invoice_id Int invoice_id Int
alert_type String @db.VarChar(20) // "3days" or "due" alert_type String @db.VarChar(20) // "3days" or "due"
sent_at DateTime @default(now()) @db.DateTime(0) sent_at DateTime @default(now()) @db.DateTime(0)
created_at DateTime @default(now()) @db.DateTime(0)
@@unique([invoice_type, invoice_id, alert_type]) @@unique([invoice_type, invoice_id, alert_type])
} }

View File

@@ -0,0 +1,49 @@
import { describe, it, expect, vi } from "vitest";
import { verifyAccessToken, hashToken } from "../services/auth";
import jwt from "jsonwebtoken";
import { config } from "../config/env";
describe("auth service", () => {
describe("verifyAccessToken", () => {
it("returns null and logs error for invalid JWT", async () => {
const consoleSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
const result = await verifyAccessToken("invalid-token");
expect(result).toBeNull();
expect(consoleSpy).toHaveBeenCalled();
expect(consoleSpy.mock.calls[0][0]).toMatch(/JWT verification error/);
consoleSpy.mockRestore();
});
it("returns null for expired JWT", async () => {
const consoleSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
const expiredToken = jwt.sign(
{ sub: 1, username: "test", role: "user" },
config.jwt.secret,
{ expiresIn: -1 },
);
const result = await verifyAccessToken(expiredToken);
expect(result).toBeNull();
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe("hashToken", () => {
it("produces deterministic SHA-256 hex output", () => {
const t1 = hashToken("hello");
const t2 = hashToken("hello");
expect(t1).toBe(t2);
expect(t1).toMatch(/^[a-f0-9]{64}$/);
});
it("produces different hashes for different inputs", () => {
const t1 = hashToken("a");
const t2 = hashToken("b");
expect(t1).not.toBe(t2);
});
});
});

View File

@@ -0,0 +1,19 @@
import { describe, it, expect } from "vitest";
import { UpdateCustomerSchema } from "../schemas/customers.schema";
describe("UpdateCustomerSchema", () => {
it("rejects empty name", () => {
const result = UpdateCustomerSchema.safeParse({ name: "" });
expect(result.success).toBe(false);
});
it("accepts valid name", () => {
const result = UpdateCustomerSchema.safeParse({ name: "Acme Corp" });
expect(result.success).toBe(true);
});
it("accepts partial updates without name", () => {
const result = UpdateCustomerSchema.safeParse({ street: "Main St" });
expect(result.success).toBe(true);
});
});

34
src/__tests__/env.test.ts Normal file
View File

@@ -0,0 +1,34 @@
import { describe, it, expect } from "vitest";
import { config } from "../config/env";
describe("env validation", () => {
it("has numeric port within valid range", () => {
expect(typeof config.port).toBe("number");
expect(config.port).toBeGreaterThan(0);
expect(config.port).toBeLessThanOrEqual(65535);
});
it("has JWT_SECRET defined", () => {
expect(config.jwt.secret).toBeTruthy();
expect(config.jwt.secret.length).toBeGreaterThanOrEqual(32);
});
it("has TOTP_ENCRYPTION_KEY defined", () => {
expect(config.totp.encryptionKey).toBeTruthy();
expect(config.totp.encryptionKey.length).toBeGreaterThanOrEqual(32);
});
it("has positive JWT expiry values", () => {
expect(config.jwt.accessTokenExpiry).toBeGreaterThan(0);
expect(config.jwt.refreshTokenSessionExpiry).toBeGreaterThan(0);
expect(config.jwt.refreshTokenRememberExpiry).toBeGreaterThan(0);
});
it("has positive maxUploadSize", () => {
expect(config.nas.maxUploadSize).toBeGreaterThan(0);
});
it("has DATABASE_URL defined", () => {
expect(config.db.url).toBeTruthy();
});
});

View File

@@ -0,0 +1,64 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { toCzk, getRate } from "../services/exchange-rates";
// Mock global fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe("exchange-rates", () => {
beforeEach(() => {
mockFetch.mockReset();
});
describe("toCzk", () => {
it("returns amount unchanged for CZK", async () => {
const result = await toCzk(123.45, "CZK");
expect(result).toBe(123.45);
});
it("throws for unknown currency when API fails and no cache", async () => {
mockFetch.mockRejectedValue(new Error("Network error"));
await expect(toCzk(100, "XYZ")).rejects.toThrow(
/Nepodařilo se získat aktuální kurzy/,
);
});
it("throws for unknown currency even when API succeeds", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({
rates: [{ currencyCode: "EUR", rate: 25, amount: 1 }],
}),
});
await expect(toCzk(100, "XYZ")).rejects.toThrow(/Neznámá měna: XYZ/);
});
it("converts EUR using fetched rate", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({
rates: [{ currencyCode: "EUR", rate: 25, amount: 1 }],
}),
});
const result = await toCzk(100, "EUR");
expect(result).toBe(2500);
});
});
describe("getRate", () => {
it("returns 1 for CZK", async () => {
const result = await getRate("CZK");
expect(result).toBe(1);
});
it("throws for unknown currency", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({
rates: [{ currencyCode: "EUR", rate: 25, amount: 1 }],
}),
});
await expect(getRate("XYZ")).rejects.toThrow(/Neznámá měna: XYZ/);
});
});
});

View File

@@ -0,0 +1,55 @@
import { describe, it, expect } from "vitest";
import { invoiceTotalWithVat } from "../services/invoices.service";
describe("invoiceTotalWithVat", () => {
it("calculates subtotal without VAT when apply_vat is false", () => {
const inv = {
apply_vat: false,
vat_rate: { toNumber: () => 21 },
currency: "CZK",
invoice_items: [
{
quantity: { toNumber: () => 2 },
unit_price: { toNumber: () => 100 },
vat_rate: { toNumber: () => 21 },
},
],
};
expect(invoiceTotalWithVat(inv)).toBe(200);
});
it("rounds each line VAT to 2 decimals before accumulation", () => {
// 3 items @ 33.33 with 21% VAT
// Line VAT = 33.33 * 0.21 = 6.9993 -> rounded to 7.00
// Total VAT = 7.00 * 3 = 21.00
// Subtotal = 33.33 * 3 = 99.99
// Total = 99.99 + 21.00 = 120.99
const inv = {
apply_vat: true,
vat_rate: { toNumber: () => 21 },
currency: "CZK",
invoice_items: Array.from({ length: 3 }, () => ({
quantity: { toNumber: () => 1 },
unit_price: { toNumber: () => 33.33 },
vat_rate: { toNumber: () => 21 },
})),
};
expect(invoiceTotalWithVat(inv)).toBe(120.99);
});
it("handles null quantity and unit_price gracefully", () => {
const inv = {
apply_vat: true,
vat_rate: { toNumber: () => 21 },
currency: "CZK",
invoice_items: [
{
quantity: { toNumber: () => 0 },
unit_price: { toNumber: () => 0 },
vat_rate: { toNumber: () => 21 },
},
],
};
expect(invoiceTotalWithVat(inv)).toBe(0);
});
});

View File

@@ -0,0 +1,55 @@
import { describe, it, expect } from "vitest";
import { NasFileManager } from "../services/nas-file-manager";
describe("NasFileManager path traversal", () => {
const nas = new NasFileManager();
describe("deleteItem", () => {
it("rejects empty path", async () => {
const result = await nas.deleteItem("PRJ-001", "");
expect(result).toContain("kořenovou složku");
});
it("rejects root path /", async () => {
const result = await nas.deleteItem("PRJ-001", "/");
expect(result).toContain("kořenovou složku");
});
it("rejects current directory .", async () => {
const result = await nas.deleteItem("PRJ-001", ".");
expect(result).toContain("kořenovou složku");
});
it("rejects current directory ./", async () => {
const result = await nas.deleteItem("PRJ-001", "./");
expect(result).toContain("kořenovou složku");
});
it("rejects path traversal ..", async () => {
const result = await nas.deleteItem("PRJ-001", "../etc/passwd");
expect(result).toContain("Neplatná cesta");
});
});
describe("moveItem", () => {
it("rejects empty fromPath", async () => {
const result = await nas.moveItem("PRJ-001", "", "dest");
expect(result).toContain("kořenovou složku");
});
it("rejects root fromPath /", async () => {
const result = await nas.moveItem("PRJ-001", "/", "dest");
expect(result).toContain("kořenovou složku");
});
it("rejects current directory .", async () => {
const result = await nas.moveItem("PRJ-001", ".", "dest");
expect(result).toContain("kořenovou složku");
});
it("rejects path traversal in fromPath", async () => {
const result = await nas.moveItem("PRJ-001", "../secret", "dest");
expect(result).toContain("Neplatná cesta");
});
});
});

View File

@@ -0,0 +1,57 @@
import { describe, it, expect } from "vitest";
import { CreateOrderSchema } from "../schemas/orders.schema";
import { CreateQuotationSchema } from "../schemas/offers.schema";
describe("Zod NaN rejection", () => {
describe("CreateOrderSchema", () => {
it("rejects NaN string in quantity", () => {
const result = CreateOrderSchema.safeParse({
customer_id: 1,
items: [{ quantity: "not-a-number" }],
});
expect(result.success).toBe(false);
});
it("rejects NaN string in unit_price", () => {
const result = CreateOrderSchema.safeParse({
customer_id: 1,
items: [{ unit_price: "abc" }],
});
expect(result.success).toBe(false);
});
it("accepts valid numbers", () => {
const result = CreateOrderSchema.safeParse({
customer_id: 1,
items: [{ quantity: 2, unit_price: 100 }],
});
expect(result.success).toBe(true);
});
});
describe("CreateQuotationSchema", () => {
it("rejects NaN string in top-level vat_rate", () => {
const result = CreateQuotationSchema.safeParse({
customer_id: 1,
vat_rate: "bad",
});
expect(result.success).toBe(false);
});
it("accepts valid vat_rate", () => {
const result = CreateQuotationSchema.safeParse({
customer_id: 1,
vat_rate: 21,
});
expect(result.success).toBe(true);
});
it("rejects NaN string in item quantity", () => {
const result = CreateQuotationSchema.safeParse({
customer_id: 1,
items: [{ quantity: "bad" }],
});
expect(result.success).toBe(false);
});
});
});

View File

@@ -1,7 +1,9 @@
import { lazy, Suspense } from "react"; import { lazy, Suspense } from "react";
import { Routes, Route } from "react-router-dom"; import { Routes, Route } from "react-router-dom";
import { QueryClientProvider } from "@tanstack/react-query";
import { AuthProvider } from "./context/AuthContext"; import { AuthProvider } from "./context/AuthContext";
import { AlertProvider } from "./context/AlertContext"; import { AlertProvider } from "./context/AlertContext";
import { queryClient } from "./lib/queryClient";
import ErrorBoundary from "./components/ErrorBoundary"; import ErrorBoundary from "./components/ErrorBoundary";
import AdminLayout from "./components/AdminLayout"; import AdminLayout from "./components/AdminLayout";
import AlertContainer from "./components/AlertContainer"; import AlertContainer from "./components/AlertContainer";
@@ -14,8 +16,8 @@ import "./buttons.css";
import "./layout.css"; import "./layout.css";
import "./components.css"; import "./components.css";
import "./tables.css"; import "./tables.css";
import "./skeleton.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";
@@ -46,7 +48,6 @@ const OffersTemplates = lazy(() => import("./pages/OffersTemplates"));
const Orders = lazy(() => import("./pages/Orders")); const Orders = lazy(() => import("./pages/Orders"));
const OrderDetail = lazy(() => import("./pages/OrderDetail")); const OrderDetail = lazy(() => import("./pages/OrderDetail"));
const Projects = lazy(() => import("./pages/Projects")); const Projects = lazy(() => import("./pages/Projects"));
const ProjectCreate = lazy(() => import("./pages/ProjectCreate"));
const ProjectDetail = lazy(() => import("./pages/ProjectDetail")); const ProjectDetail = lazy(() => import("./pages/ProjectDetail"));
const Invoices = lazy(() => import("./pages/Invoices")); const Invoices = lazy(() => import("./pages/Invoices"));
const InvoiceDetail = lazy(() => import("./pages/InvoiceDetail")); const InvoiceDetail = lazy(() => import("./pages/InvoiceDetail"));
@@ -58,64 +59,80 @@ export default function AdminApp() {
return ( return (
<AuthProvider> <AuthProvider>
<AlertProvider> <AlertProvider>
<AlertContainer /> <QueryClientProvider client={queryClient}>
<ErrorBoundary> <AlertContainer />
<Suspense <ErrorBoundary>
fallback={ <Suspense
<div className="admin-loading"> fallback={
<div className="admin-spinner" /> <div className="admin-loading">
</div> <div className="admin-spinner" />
} </div>
> }
<Routes> >
<Route path="login" element={<Login />} /> <Routes>
<Route element={<AdminLayout />}> <Route path="login" element={<Login />} />
<Route index element={<Dashboard />} /> <Route element={<AdminLayout />}>
<Route path="users" element={<Users />} /> <Route index element={<Dashboard />} />
<Route path="attendance" element={<Attendance />} /> <Route path="users" element={<Users />} />
<Route <Route path="attendance" element={<Attendance />} />
path="attendance/history" <Route
element={<AttendanceHistory />} path="attendance/history"
/> element={<AttendanceHistory />}
<Route path="attendance/admin" element={<AttendanceAdmin />} /> />
<Route <Route
path="attendance/balances" path="attendance/admin"
element={<AttendanceBalances />} element={<AttendanceAdmin />}
/> />
<Route path="attendance/requests" element={<LeaveRequests />} /> <Route
<Route path="attendance/approval" element={<LeaveApproval />} /> path="attendance/balances"
<Route element={<AttendanceBalances />}
path="attendance/create" />
element={<AttendanceCreate />} <Route
/> path="attendance/requests"
<Route element={<LeaveRequests />}
path="attendance/location/:id" />
element={<AttendanceLocation />} <Route
/> path="attendance/approval"
<Route path="trips" element={<Trips />} /> element={<LeaveApproval />}
<Route path="trips/history" element={<TripsHistory />} /> />
<Route path="trips/admin" element={<TripsAdmin />} /> <Route
<Route path="vehicles" element={<Vehicles />} /> path="attendance/create"
<Route path="offers" element={<Offers />} /> element={<AttendanceCreate />}
<Route path="offers/new" element={<OfferDetail />} /> />
<Route path="offers/:id" element={<OfferDetail />} /> <Route
<Route path="offers/customers" element={<OffersCustomers />} /> path="attendance/location/:id"
<Route path="offers/templates" element={<OffersTemplates />} /> element={<AttendanceLocation />}
<Route path="orders" element={<Orders />} /> />
<Route path="orders/:id" element={<OrderDetail />} /> <Route path="trips" element={<Trips />} />
<Route path="projects" element={<Projects />} /> <Route path="trips/history" element={<TripsHistory />} />
<Route path="projects/new" element={<ProjectCreate />} /> <Route path="trips/admin" element={<TripsAdmin />} />
<Route path="projects/:id" element={<ProjectDetail />} /> <Route path="vehicles" element={<Vehicles />} />
<Route path="invoices" element={<Invoices />} /> <Route path="offers" element={<Offers />} />
<Route path="invoices/new" element={<InvoiceDetail />} /> <Route path="offers/new" element={<OfferDetail />} />
<Route path="invoices/:id" element={<InvoiceDetail />} /> <Route path="offers/:id" element={<OfferDetail />} />
<Route path="settings" element={<Settings />} /> <Route
<Route path="audit-log" element={<AuditLog />} /> path="offers/customers"
</Route> element={<OffersCustomers />}
<Route path="*" element={<NotFound />} /> />
</Routes> <Route
</Suspense> path="offers/templates"
</ErrorBoundary> element={<OffersTemplates />}
/>
<Route path="orders" element={<Orders />} />
<Route path="orders/:id" element={<OrderDetail />} />
<Route path="projects" element={<Projects />} />
<Route path="projects/:id" element={<ProjectDetail />} />
<Route path="invoices" element={<Invoices />} />
<Route path="invoices/new" element={<InvoiceDetail />} />
<Route path="invoices/:id" element={<InvoiceDetail />} />
<Route path="settings" element={<Settings />} />
<Route path="audit-log" element={<AuditLog />} />
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</ErrorBoundary>
</QueryClientProvider>
</AlertProvider> </AlertProvider>
</AuthProvider> </AuthProvider>
); );

View File

@@ -330,15 +330,6 @@ img {
} }
} }
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
/* ── Additional Utilities ─────────────────────────────────────────── */ /* ── Additional Utilities ─────────────────────────────────────────── */
/* Font sizes */ /* Font sizes */

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,263 @@
{
"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

@@ -0,0 +1,506 @@
{
"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

@@ -0,0 +1,707 @@
{
"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

@@ -0,0 +1,599 @@
{
"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

@@ -0,0 +1,704 @@
{
"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

@@ -0,0 +1,620 @@
{
"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

@@ -0,0 +1,641 @@
{
"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

@@ -0,0 +1,452 @@
{
"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

@@ -0,0 +1,872 @@
{
"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

@@ -0,0 +1,998 @@
{
"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

@@ -0,0 +1,371 @@
{
"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

@@ -0,0 +1,746 @@
{
"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

@@ -0,0 +1,50 @@
"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

@@ -0,0 +1,725 @@
{
"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

@@ -0,0 +1,725 @@
{
"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

@@ -0,0 +1,767 @@
{
"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

@@ -0,0 +1,746 @@
{
"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

@@ -75,6 +75,19 @@ function NativeInput({
disabled, disabled,
}: NativeInputProps) { }: NativeInputProps) {
const type = modeToInputType[mode] || "date"; const type = modeToInputType[mode] || "date";
// For time inputs, min/max must be in HH:mm format, not date format
const formatTimeMinMax = (val: string | undefined): string | undefined => {
if (!val) return undefined;
// If it looks like a date string (yyyy-MM-dd), extract time portion if present,
// otherwise it's not a valid time min/max — return undefined
if (val.includes("T")) return val.split("T")[1]?.substring(0, 5);
if (val.includes(":")) return val.substring(0, 5);
return undefined;
};
const minProp =
mode === "time" ? formatTimeMinMax(minDate) : minDate || undefined;
const maxProp =
mode === "time" ? formatTimeMinMax(maxDate) : maxDate || undefined;
return ( return (
<input <input
type={type} type={type}
@@ -84,14 +97,14 @@ function NativeInput({
className="admin-form-input" className="admin-form-input"
required={required} required={required}
disabled={disabled} disabled={disabled}
min={minDate || undefined} min={minProp}
max={maxDate || undefined} max={maxProp}
/> />
); );
} }
interface AdminDatePickerProps { interface AdminDatePickerProps {
mode?: "date" | "month" | "datetime" | "time"; mode?: "date" | "month" | "time";
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
minDate?: string; minDate?: string;
@@ -165,17 +178,22 @@ export default function AdminDatePicker({
return undefined; return undefined;
}; };
const commonProps = { const customInput = useMemo(
selected: toDate(value), () => (
onChange: handleChange,
locale: "cs",
customInput: (
<CustomInput <CustomInput
required={required} required={required}
placeholder={placeholder} placeholder={placeholder}
disabled={disabled} disabled={disabled}
/> />
), ),
[required, placeholder, disabled],
);
const commonProps = {
selected: toDate(value),
onChange: handleChange,
locale: "cs",
customInput,
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,
@@ -64,24 +64,30 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
let h: number, let h: number,
m: number, m: number,
isActive = false; isActive = false;
let durationValid = true;
if (log.hours !== null && log.hours !== undefined) { if (log.hours !== null && log.hours !== undefined) {
h = parseInt(String(log.hours)) || 0; h = parseInt(String(log.hours)) || 0;
m = parseInt(String(log.minutes)) || 0; m = parseInt(String(log.minutes)) || 0;
} else { } else {
isActive = !log.ended_at; isActive = !log.ended_at;
const end = log.ended_at ? new Date(log.ended_at) : new Date(); const end = log.ended_at ? new Date(log.ended_at) : new Date();
const mins = Math.max( const start = log.started_at ? new Date(log.started_at) : null;
0, if (start && !isNaN(start.getTime()) && !isNaN(end.getTime())) {
Math.floor( const mins = Math.max(
(end.getTime() - new Date(log.started_at!).getTime()) / 60000, 0,
), Math.floor((end.getTime() - start.getTime()) / 60000),
); );
h = Math.floor(mins / 60); h = Math.floor(mins / 60);
m = mins % 60; m = mins % 60;
} else {
durationValid = false;
h = 0;
m = 0;
}
} }
return ( return (
<span <span
key={log.id || i} key={log.id ?? i}
className="admin-badge" className="admin-badge"
style={{ style={{
fontSize: "0.7rem", fontSize: "0.7rem",
@@ -89,8 +95,10 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
background: isActive ? "var(--accent-light)" : undefined, background: isActive ? "var(--accent-light)" : undefined,
}} }}
> >
{log.project_name || `#${log.project_id}`} ({h}: {log.project_name || `#${log.project_id}`}{" "}
{String(m).padStart(2, "0")}h{isActive ? " \u25B8" : ""}) {durationValid
? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " \u25B8" : ""})`
: "—"}
</span> </span>
); );
})} })}
@@ -146,7 +154,8 @@ export default function AttendanceShiftTable({
const leaveType = record.leave_type || "work"; const leaveType = record.leave_type || "work";
const isLeave = leaveType !== "work"; const isLeave = leaveType !== "work";
const workMinutes = isLeave const workMinutes = isLeave
? (Number(record.leave_hours) || 8) * 60 ? (record.leave_hours != null ? Number(record.leave_hours) : 8) *
60
: calculateWorkMinutes(record); : calculateWorkMinutes(record);
const hasLocation = const hasLocation =
(record.arrival_lat && record.arrival_lng) || (record.arrival_lat && record.arrival_lng) ||
@@ -186,7 +195,7 @@ export default function AttendanceShiftTable({
title="Zobrazit polohu" title="Zobrazit polohu"
aria-label="Zobrazit polohu" aria-label="Zobrazit polohu"
> >
{"\uD83D\uDCCD"} <span aria-hidden="true">{"\uD83D\uDCCD"}</span>
</Link> </Link>
) : ( ) : (
"\u2014" "\u2014"

View File

@@ -17,7 +17,7 @@ interface BulkAttendanceUser {
} }
interface BulkAttendanceModalProps { interface BulkAttendanceModalProps {
show: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
form: BulkAttendanceForm; form: BulkAttendanceForm;
setForm: (form: BulkAttendanceForm) => void; setForm: (form: BulkAttendanceForm) => void;
@@ -29,7 +29,7 @@ interface BulkAttendanceModalProps {
} }
export default function BulkAttendanceModal({ export default function BulkAttendanceModal({
show, isOpen,
onClose, onClose,
form, form,
setForm, setForm,
@@ -39,11 +39,11 @@ export default function BulkAttendanceModal({
toggleUser, toggleUser,
toggleAllUsers, toggleAllUsers,
}: BulkAttendanceModalProps) { }: BulkAttendanceModalProps) {
useModalLock(show); useModalLock(isOpen);
return ( return (
<AnimatePresence> <AnimatePresence>
{show && ( {isOpen && (
<motion.div <motion.div
className="admin-modal-overlay" className="admin-modal-overlay"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@@ -57,13 +57,21 @@ export default function BulkAttendanceModal({
/> />
<motion.div <motion.div
className="admin-modal admin-modal-lg" className="admin-modal admin-modal-lg"
role="dialog"
aria-modal="true"
aria-labelledby="bulk-attendance-modal-title"
initial={{ opacity: 0, scale: 0.95, y: 20 }} initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }} animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }} exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
> >
<div className="admin-modal-header"> <div className="admin-modal-header">
<h2 className="admin-modal-title">Vyplnit docházku za měsíc</h2> <h2
id="bulk-attendance-modal-title"
className="admin-modal-title"
>
Vyplnit docházku za měsíc
</h2>
<p <p
style={{ style={{
color: "var(--text-secondary)", color: "var(--text-secondary)",

View File

@@ -14,6 +14,71 @@ interface ConfirmModalProps {
loading?: boolean; loading?: boolean;
} }
function ConfirmIcon({ type }: { type: string }) {
switch (type) {
case "danger":
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
);
case "info":
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
);
case "warning":
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
);
default:
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
);
}
}
export default function ConfirmModal({ export default function ConfirmModal({
isOpen, isOpen,
onClose, onClose,
@@ -39,6 +104,9 @@ export default function ConfirmModal({
<div className="admin-modal-backdrop" onClick={onClose} /> <div className="admin-modal-backdrop" onClick={onClose} />
<motion.div <motion.div
className="admin-modal admin-confirm-modal" className="admin-modal admin-confirm-modal"
role="dialog"
aria-modal="true"
aria-labelledby="confirm-modal-title"
initial={{ opacity: 0, scale: 0.95, y: 20 }} initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }} animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }} exit={{ opacity: 0, scale: 0.95, y: 20 }}
@@ -46,20 +114,11 @@ export default function ConfirmModal({
> >
<div className="admin-modal-body admin-confirm-content"> <div className="admin-modal-body admin-confirm-content">
<div className={`admin-confirm-icon admin-confirm-icon-${type}`}> <div className={`admin-confirm-icon admin-confirm-icon-${type}`}>
<svg <ConfirmIcon type={type} />
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
</div> </div>
<h2 className="admin-confirm-title">{title}</h2> <h2 id="confirm-modal-title" className="admin-confirm-title">
{title}
</h2>
<p className="admin-confirm-message">{message}</p> <p className="admin-confirm-message">{message}</p>
</div> </div>
<div className="admin-modal-footer"> <div className="admin-modal-footer">

View File

@@ -1,4 +1,10 @@
import type { CSSProperties, ReactNode } from "react"; import {
type CSSProperties,
type ReactNode,
isValidElement,
cloneElement,
useId,
} from "react";
interface FormFieldProps { interface FormFieldProps {
label: ReactNode; label: ReactNode;
@@ -15,13 +21,22 @@ export default function FormField({
required, required,
style, style,
}: FormFieldProps) { }: FormFieldProps) {
const generatedId = useId();
const childProps = isValidElement(children)
? (children.props as Record<string, unknown>)
: null;
const childId = childProps?.id ? String(childProps.id) : generatedId;
const childWithId = isValidElement(children)
? cloneElement(children, { id: childId } as React.Attributes)
: children;
return ( return (
<div className="admin-form-group" style={style}> <div className="admin-form-group" style={style}>
<label className="admin-form-label"> <label className="admin-form-label" htmlFor={childId}>
{label} {label}
{required && <span className="admin-form-required"> *</span>} {required && <span className="admin-form-required"> *</span>}
</label> </label>
{children} {childWithId}
{error && <span className="admin-form-error">{error}</span>} {error && <span className="admin-form-error">{error}</span>}
</div> </div>
); );

View File

@@ -1,5 +1,6 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { useAlert } from "../context/AlertContext";
interface ConfirmationItem { interface ConfirmationItem {
description: string; description: string;
@@ -13,10 +14,15 @@ interface ConfirmationItem {
interface OrderConfirmationModalProps { interface OrderConfirmationModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onGenerate: (lang: string, items?: ConfirmationItem[]) => Promise<void>; onGenerate: (
lang: string,
applyVat: boolean,
items?: ConfirmationItem[],
) => Promise<void>;
initialItems: ConfirmationItem[]; initialItems: ConfirmationItem[];
orderNumber: string; orderNumber: string;
defaultVatRate: number; defaultVatRate: number;
applyVat: boolean;
} }
export default function OrderConfirmationModal({ export default function OrderConfirmationModal({
@@ -26,16 +32,22 @@ export default function OrderConfirmationModal({
initialItems, initialItems,
orderNumber, orderNumber,
defaultVatRate, defaultVatRate,
applyVat,
}: OrderConfirmationModalProps) { }: OrderConfirmationModalProps) {
const alert = useAlert();
const [step, setStep] = useState<"choose" | "edit">("choose"); const [step, setStep] = useState<"choose" | "edit">("choose");
const [lang, setLang] = useState<string>("cs"); const [lang, setLang] = useState<string>("cs");
const [applyVatState, setApplyVatState] = useState(applyVat);
const [items, setItems] = useState<ConfirmationItem[]>(initialItems); const [items, setItems] = useState<ConfirmationItem[]>(initialItems);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const handleUseExisting = async () => { const handleUseExisting = async () => {
setLoading(true); setLoading(true);
try { try {
await onGenerate(lang, undefined); await onGenerate(lang, applyVatState, undefined);
} catch (err) {
console.error("Chyba při generování potvrzení:", err);
alert.error("Nepodařilo se vygenerovat potvrzení");
} finally { } finally {
setLoading(false); setLoading(false);
setStep("choose"); setStep("choose");
@@ -46,7 +58,10 @@ export default function OrderConfirmationModal({
const handleEditGenerate = async () => { const handleEditGenerate = async () => {
setLoading(true); setLoading(true);
try { try {
await onGenerate(lang, items); await onGenerate(lang, applyVatState, items);
} catch (err) {
console.error("Chyba při generování potvrzení:", err);
alert.error("Nepodařilo se vygenerovat potvrzení");
} finally { } finally {
setLoading(false); setLoading(false);
setStep("choose"); setStep("choose");
@@ -102,13 +117,19 @@ export default function OrderConfirmationModal({
className={ className={
step === "edit" ? "admin-modal admin-modal-lg" : "admin-modal" step === "edit" ? "admin-modal admin-modal-lg" : "admin-modal"
} }
role="dialog"
aria-modal="true"
aria-labelledby="order-confirmation-modal-title"
initial={{ opacity: 0, scale: 0.95, y: 20 }} initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }} animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }} exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
> >
<div className="admin-modal-header"> <div className="admin-modal-header">
<h2 className="admin-modal-title"> <h2
id="order-confirmation-modal-title"
className="admin-modal-title"
>
Potvrzení objednávky {orderNumber} Potvrzení objednávky {orderNumber}
</h2> </h2>
</div> </div>
@@ -144,6 +165,34 @@ export default function OrderConfirmationModal({
</div> </div>
</div> </div>
<div className="admin-form-group">
<label className="admin-form-label">DPH</label>
<div className="flex-row gap-2">
<button
type="button"
onClick={() => setApplyVatState(true)}
className={
applyVatState
? "admin-btn admin-btn-primary admin-btn-sm"
: "admin-btn admin-btn-secondary admin-btn-sm"
}
>
S DPH
</button>
<button
type="button"
onClick={() => setApplyVatState(false)}
className={
!applyVatState
? "admin-btn admin-btn-primary admin-btn-sm"
: "admin-btn admin-btn-secondary admin-btn-sm"
}
>
Bez DPH
</button>
</div>
</div>
<div className="admin-form-group"> <div className="admin-form-group">
<label className="admin-form-label">Obsah potvrzení</label> <label className="admin-form-label">Obsah potvrzení</label>
<p <p

View File

@@ -36,13 +36,18 @@ export default function Pagination({
}; };
return ( return (
<div className="admin-pagination"> <div
className="admin-pagination"
role="navigation"
aria-label="Stránkování"
>
<div className="admin-pagination-info">{total} záznamů</div> <div className="admin-pagination-info">{total} záznamů</div>
<div className="admin-pagination-controls"> <div className="admin-pagination-controls">
<button <button
disabled={page <= 1} disabled={page <= 1}
onClick={() => onPageChange(page - 1)} onClick={() => onPageChange(page - 1)}
className="admin-pagination-page" className="admin-pagination-page"
aria-label="Předchozí stránka"
> >
<svg <svg
width="16" width="16"
@@ -65,6 +70,8 @@ export default function Pagination({
key={p} key={p}
onClick={() => onPageChange(p)} onClick={() => onPageChange(p)}
className={`admin-pagination-page ${p === page ? "active" : ""}`} className={`admin-pagination-page ${p === page ? "active" : ""}`}
aria-label={`Stránka ${p}`}
aria-current={p === page ? "page" : undefined}
> >
{p} {p}
</button> </button>
@@ -74,6 +81,7 @@ export default function Pagination({
disabled={page >= total_pages} disabled={page >= total_pages}
onClick={() => onPageChange(page + 1)} onClick={() => onPageChange(page + 1)}
className="admin-pagination-page" className="admin-pagination-page"
aria-label="Další stránka"
> >
<svg <svg
width="16" width="16"

View File

@@ -1,7 +1,11 @@
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
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";
@@ -196,13 +200,11 @@ export default function ProjectFileManager({
hasNasFolder, hasNasFolder,
}: ProjectFileManagerProps) { }: ProjectFileManagerProps) {
const alert = useAlert(); const alert = useAlert();
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const isCancelling = useRef(false);
const [items, setItems] = useState<FileItem[]>([]);
const [loading, setLoading] = useState(true);
const [currentPath, setCurrentPath] = useState(""); const [currentPath, setCurrentPath] = useState("");
const [breadcrumb, setBreadcrumb] = useState<string[]>([""]);
const [fullPath, setFullPath] = useState("");
const [dragOver, setDragOver] = useState(false); const [dragOver, setDragOver] = useState(false);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
@@ -216,59 +218,25 @@ export default function ProjectFileManager({
const [deleteTarget, setDeleteTarget] = useState<FileItem | null>(null); const [deleteTarget, setDeleteTarget] = useState<FileItem | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const canManage = hasPermission("projects.files"); const canManage = hasPermission("projects.files");
const fetchFiles = useCallback( const {
async (path = "", options: { ignore?: boolean } = {}) => { data: filesData,
setLoading(true); isPending: filesLoading,
setErrorMessage(null); error: filesError,
try { } = useQuery(projectFilesOptions(projectId, currentPath));
const params = new URLSearchParams({ project_id: String(projectId) }); const items = filesData?.items ?? [];
if (path) { const breadcrumb = filesData?.breadcrumb ?? [""];
params.set("path", path); const fullPath = filesData?.full_path ?? "";
} const errorMessage = filesError
const res = await apiFetch(`${API_BASE}/project-files?${params}`); ? filesError.message || "Nepodařilo se načíst soubory"
if (options.ignore) return; : null;
if (res.status === 401) return;
const data = await res.json();
if (data.success) {
setItems(data.data.items || []);
setBreadcrumb(data.data.breadcrumb || [""]);
setCurrentPath(data.data.path || "");
setFullPath(data.data.full_path || "");
} else if (res.status === 404) {
setItems([]);
setBreadcrumb([""]);
} else {
setErrorMessage(data.error || "Nepodařilo se načíst soubory");
}
} catch {
if (!options.ignore) {
setErrorMessage("Chyba připojení");
}
} finally {
if (!options.ignore) {
setLoading(false);
}
}
},
[projectId],
);
useEffect(() => {
const opts = { ignore: false };
fetchFiles("", opts);
return () => {
opts.ignore = true;
};
}, [fetchFiles]);
const navigateTo = (path: string) => { const navigateTo = (path: string) => {
setNewFolderMode(false); setNewFolderMode(false);
setRenamingItem(null); setRenamingItem(null);
fetchFiles(path); setCurrentPath(path);
}; };
const handleBreadcrumbClick = (index: number) => { const handleBreadcrumbClick = (index: number) => {
@@ -331,7 +299,9 @@ export default function ProjectFileManager({
? "Soubor byl nahrán" ? "Soubor byl nahrán"
: `Nahráno ${successCount} souborů`; : `Nahráno ${successCount} souborů`;
alert.success(msg); alert.success(msg);
fetchFiles(currentPath); queryClient.invalidateQueries({
queryKey: ["projects", String(projectId), "files"],
});
} }
if (errorMsg) { if (errorMsg) {
alert.error(errorMsg); alert.error(errorMsg);
@@ -382,7 +352,9 @@ export default function ProjectFileManager({
alert.success("Složka byla vytvořena"); alert.success("Složka byla vytvořena");
setNewFolderMode(false); setNewFolderMode(false);
setNewFolderName(""); setNewFolderName("");
fetchFiles(currentPath); queryClient.invalidateQueries({
queryKey: ["projects", String(projectId), "files"],
});
} else { } else {
alert.error(data.error || "Nepodařilo se vytvořit složku"); alert.error(data.error || "Nepodařilo se vytvořit složku");
} }
@@ -443,7 +415,9 @@ export default function ProjectFileManager({
? "Složka byla smazána" ? "Složka byla smazána"
: "Soubor byl smazán", : "Soubor byl smazán",
); );
fetchFiles(currentPath); queryClient.invalidateQueries({
queryKey: ["projects", String(projectId), "files"],
});
} else { } else {
alert.error(data.error || "Nepodařilo se smazat"); alert.error(data.error || "Nepodařilo se smazat");
} }
@@ -478,7 +452,9 @@ export default function ProjectFileManager({
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
alert.success("Přejmenováno"); alert.success("Přejmenováno");
fetchFiles(currentPath); queryClient.invalidateQueries({
queryKey: ["projects", String(projectId), "files"],
});
} else { } else {
alert.error(data.error || "Nepodařilo se přejmenovat"); alert.error(data.error || "Nepodařilo se přejmenovat");
} }
@@ -494,32 +470,15 @@ export default function ProjectFileManager({
setRenameValue(item.name); setRenameValue(item.name);
}; };
if (loading && items.length === 0 && !errorMessage) { if (filesLoading && items.length === 0 && !errorMessage) {
return ( return (
<div className="admin-card"> <Skeleton
<div className="admin-card-body"> name="project-file-manager"
<h3 className="admin-card-title">Soubory</h3> loading={filesLoading && items.length === 0}
<div className="admin-skeleton" style={{ padding: 0, gap: "0.5rem" }}> fixture={<ProjectFileManagerFixture />}
{[0, 1, 2, 3].map((i) => ( >
<div key={i} className="admin-skeleton-row"> <div />
<div </Skeleton>
className="admin-skeleton-line"
style={{
width: "18px",
height: "18px",
borderRadius: "4px",
flexShrink: 0,
}}
/>
<div
className="admin-skeleton-line"
style={{ width: `${60 + i * 10}%` }}
/>
</div>
))}
</div>
</div>
</div>
); );
} }
@@ -709,7 +668,7 @@ export default function ProjectFileManager({
</div> </div>
)} )}
{items.length === 0 && !loading ? ( {items.length === 0 && !filesLoading ? (
<div className="fm-empty"> <div className="fm-empty">
<svg <svg
width="32" width="32"
@@ -768,10 +727,26 @@ export default function ProjectFileManager({
}} }}
autoFocus autoFocus
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") handleRename(item); if (e.key === "Enter") {
if (e.key === "Escape") setRenamingItem(null); e.preventDefault();
handleRename(item);
}
if (e.key === "Escape") {
e.preventDefault();
isCancelling.current = true;
setRenamingItem(null);
setRenameValue(item.name);
setTimeout(() => {
isCancelling.current = false;
}, 0);
}
}}
onBlur={() => {
if (isCancelling.current) {
return;
}
handleRename(item);
}} }}
onBlur={() => handleRename(item)}
/> />
) : ( ) : (
<FileNameCell <FileNameCell

View File

@@ -68,7 +68,7 @@ interface ProjectLogRowProps {
export interface ShiftFormModalProps { export interface ShiftFormModalProps {
mode: "create" | "edit"; mode: "create" | "edit";
show: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onSubmit: () => void; onSubmit: () => void;
form: ShiftFormData; form: ShiftFormData;
@@ -196,7 +196,7 @@ function ProjectLogRow({
export default function ShiftFormModal({ export default function ShiftFormModal({
mode, mode,
show, isOpen,
onClose, onClose,
onSubmit, onSubmit,
form, form,
@@ -208,7 +208,7 @@ export default function ShiftFormModal({
onShiftDateChange, onShiftDateChange,
editingRecord, editingRecord,
}: ShiftFormModalProps) { }: ShiftFormModalProps) {
useModalLock(show); useModalLock(isOpen);
const isCreate = mode === "create"; const isCreate = mode === "create";
const isWorkType = form.leave_type === "work"; const isWorkType = form.leave_type === "work";
@@ -240,7 +240,7 @@ export default function ShiftFormModal({
return ( return (
<AnimatePresence> <AnimatePresence>
{show && ( {isOpen && (
<motion.div <motion.div
className="admin-modal-overlay" className="admin-modal-overlay"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@@ -251,13 +251,16 @@ export default function ShiftFormModal({
<div className="admin-modal-backdrop" onClick={onClose} /> <div className="admin-modal-backdrop" onClick={onClose} />
<motion.div <motion.div
className="admin-modal admin-modal-lg" className="admin-modal admin-modal-lg"
role="dialog"
aria-modal="true"
aria-labelledby="shift-form-modal-title"
initial={{ opacity: 0, scale: 0.95, y: 20 }} initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }} animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }} exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
> >
<div className="admin-modal-header"> <div className="admin-modal-header">
<h2 className="admin-modal-title"> <h2 id="shift-form-modal-title" className="admin-modal-title">
{isCreate ? "Přidat záznam docházky" : "Upravit docházku"} {isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
</h2> </h2>
{!isCreate && editingRecord && ( {!isCreate && editingRecord && (

View File

@@ -1,27 +1,17 @@
import { useState, useEffect, useCallback } from "react"; import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { useAlert } from "../../context/AlertContext"; import { useAlert } from "../../context/AlertContext";
import ConfirmModal from "../ConfirmModal"; import ConfirmModal from "../ConfirmModal";
import useModalLock from "../../hooks/useModalLock"; 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 { Skeleton } from "boneyard-js/react";
import DashSessionsFixture from "../../fixtures/DashSessionsFixture";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
interface DeviceInfo {
icon?: string;
browser?: string;
os?: string;
}
interface Session {
id: number | string;
is_current: boolean;
device_info?: DeviceInfo;
ip_address: string;
created_at: string;
}
interface DeleteModalState { interface DeleteModalState {
isOpen: boolean; isOpen: boolean;
session: Session | null; session: Session | null;
@@ -77,9 +67,10 @@ function getDeviceIcon(iconType?: string) {
export default function DashSessions() { export default function DashSessions() {
const alert = useAlert(); const alert = useAlert();
const queryClient = useQueryClient();
const [sessions, setSessions] = useState<Session[]>([]); const { data: sessions = [], isPending: sessionsLoading } =
const [sessionsLoading, setSessionsLoading] = useState(true); useQuery(sessionsOptions());
const [deleteModal, setDeleteModal] = useState<DeleteModalState>({ const [deleteModal, setDeleteModal] = useState<DeleteModalState>({
isOpen: false, isOpen: false,
session: null, session: null,
@@ -89,26 +80,6 @@ export default function DashSessions() {
useModalLock(deleteAllModal); useModalLock(deleteAllModal);
const fetchSessions = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/sessions`);
const data = await response.json();
if (data.success) {
setSessions(
Array.isArray(data.data) ? data.data : data.data?.sessions || [],
);
}
} catch {
// session fetch failed silently
} finally {
setSessionsLoading(false);
}
}, []);
useEffect(() => {
fetchSessions();
}, [fetchSessions]);
const handleDeleteSession = async () => { const handleDeleteSession = async () => {
if (!deleteModal.session) { if (!deleteModal.session) {
return; return;
@@ -122,7 +93,7 @@ export default function DashSessions() {
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
setDeleteModal({ isOpen: false, session: null }); setDeleteModal({ isOpen: false, session: null });
setSessions((prev) => prev.filter((s) => s.id !== sessionId)); queryClient.invalidateQueries({ queryKey: ["sessions"] });
alert.success("Relace byla ukončena"); alert.success("Relace byla ukončena");
} else { } else {
alert.error(data.error || "Nepodařilo se ukončit relaci"); alert.error(data.error || "Nepodařilo se ukončit relaci");
@@ -143,7 +114,7 @@ export default function DashSessions() {
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
setDeleteAllModal(false); setDeleteAllModal(false);
setSessions((prev) => prev.filter((s) => s.is_current)); queryClient.invalidateQueries({ queryKey: ["sessions"] });
alert.success(data.message || "Ostatní relace byly ukončeny"); alert.success(data.message || "Ostatní relace byly ukončeny");
} else { } else {
alert.error(data.error || "Nepodařilo se ukončit relace"); alert.error(data.error || "Nepodařilo se ukončit relace");
@@ -183,98 +154,84 @@ export default function DashSessions() {
)} )}
</div> </div>
<div className="admin-card-body" style={{ padding: 0 }}> <div className="admin-card-body" style={{ padding: 0 }}>
{sessionsLoading && ( <Skeleton
<div name="dash-sessions"
className="admin-skeleton" loading={sessionsLoading}
style={{ padding: "1rem", gap: "1rem" }} fixture={<DashSessionsFixture />}
> >
{[0, 1, 2].map((i) => ( <>
<div key={i} className="admin-skeleton-row"> {sessions.length === 0 && (
<div className="admin-skeleton-line circle" />
<div className="flex-1">
<div
className="admin-skeleton-line w-1/2"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-1/3"
style={{ height: "10px" }}
/>
</div>
</div>
))}
</div>
)}
{!sessionsLoading && sessions.length === 0 && (
<div
className="text-secondary"
style={{
padding: "1.5rem",
textAlign: "center",
fontSize: "0.875rem",
}}
>
Žádné aktivní relace
</div>
)}
{!sessionsLoading && sessions.length > 0 && (
<div className="dash-sessions-list">
{sessions.map((session) => (
<div <div
key={session.id} className="text-secondary"
className={`dash-session-item ${session.is_current ? "dash-session-item-current" : ""}`} style={{
padding: "1.5rem",
textAlign: "center",
fontSize: "0.875rem",
}}
> >
<div className="dash-session-icon"> Žádné aktivní relace
{getDeviceIcon(session.device_info?.icon)}
</div>
<div className="dash-session-info">
<div className="dash-session-device">
{session.device_info?.browser} na{" "}
{session.device_info?.os}
{session.is_current && (
<span
className="admin-badge admin-badge-success"
style={{ marginLeft: "0.5rem" }}
>
Aktuální
</span>
)}
</div>
<div className="dash-session-meta">
<span>{session.ip_address}</span>
<span className="dash-session-meta-separator">|</span>
<span>{formatSessionDate(session.created_at)}</span>
</div>
</div>
<div className="dash-session-actions">
{!session.is_current && (
<button
onClick={() =>
setDeleteModal({ isOpen: true, session })
}
className="admin-btn-icon danger"
title="Ukončit relaci"
aria-label="Ukončit relaci"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</button>
)}
</div>
</div> </div>
))} )}
</div> {sessions.length > 0 && (
)} <div className="dash-sessions-list">
{sessions.map((session) => (
<div
key={session.id}
className={`dash-session-item ${session.is_current ? "dash-session-item-current" : ""}`}
>
<div className="dash-session-icon">
{getDeviceIcon(session.device_info?.icon)}
</div>
<div className="dash-session-info">
<div className="dash-session-device">
{session.device_info?.browser} na{" "}
{session.device_info?.os}
{session.is_current && (
<span
className="admin-badge admin-badge-success"
style={{ marginLeft: "0.5rem" }}
>
Aktuální
</span>
)}
</div>
<div className="dash-session-meta">
<span>{session.ip_address}</span>
<span className="dash-session-meta-separator">|</span>
<span>{formatSessionDate(session.created_at)}</span>
</div>
</div>
<div className="dash-session-actions">
{!session.is_current && (
<button
onClick={() =>
setDeleteModal({ isOpen: true, session })
}
className="admin-btn-icon danger"
title="Ukončit relaci"
aria-label="Ukončit relaci"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</button>
)}
</div>
</div>
))}
</div>
)}
</>
</Skeleton>
</div> </div>
</motion.div> </motion.div>

View File

@@ -5,6 +5,7 @@ import {
useCallback, useCallback,
useMemo, useMemo,
useRef, useRef,
useEffect,
type ReactNode, type ReactNode,
} from "react"; } from "react";
@@ -39,6 +40,15 @@ export function AlertProvider({ children }: { children: ReactNode }) {
}, []); }, []);
const counterRef = useRef(0); const counterRef = useRef(0);
const timeoutsRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
useEffect(() => {
return () => {
timeoutsRef.current.forEach(clearTimeout);
timeoutsRef.current.clear();
};
}, []);
const addAlert = useCallback( const addAlert = useCallback(
(message: string, type = "success", duration = 4000) => { (message: string, type = "success", duration = 4000) => {
const id = `${Date.now()}-${counterRef.current++}`; const id = `${Date.now()}-${counterRef.current++}`;
@@ -47,7 +57,11 @@ export function AlertProvider({ children }: { children: ReactNode }) {
{ id, message, type: type as Alert["type"] }, { id, message, type: type as Alert["type"] },
]); ]);
if (duration > 0) { if (duration > 0) {
setTimeout(() => removeAlert(id), duration); const timeoutId = setTimeout(() => {
timeoutsRef.current.delete(timeoutId);
removeAlert(id);
}, duration);
timeoutsRef.current.add(timeoutId);
} }
return id; return id;
}, },

View File

@@ -84,32 +84,32 @@ function mapUser(u: Record<string, unknown> | null): User | null {
} as User; } as User;
} }
let accessToken: string | null = null;
let tokenExpiresAt: number | null = null;
let cachedUser: User | null = null;
let sessionFetched = false;
let silentRefreshInFlight: Promise<boolean> | null = null;
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(cachedUser); const accessTokenRef = useRef<string | null>(null);
const [loading, setLoading] = useState(!sessionFetched); const tokenExpiresAtRef = useRef<number | null>(null);
const cachedUserRef = useRef<User | null>(null);
const sessionFetchedRef = useRef(false);
const silentRefreshInFlightRef = useRef<Promise<boolean> | null>(null);
const hadValidSessionRef = useRef(false);
const [user, setUser] = useState<User | null>(cachedUserRef.current);
const [loading, setLoading] = useState(!sessionFetchedRef.current);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
cachedUser = user;
}, [user]);
const getAccessTokenFn = useCallback((): string | null => { const getAccessTokenFn = useCallback((): string | null => {
if (!tokenExpiresAt || Date.now() > tokenExpiresAt - 30000) return null; if (
return accessToken; !tokenExpiresAtRef.current ||
Date.now() > tokenExpiresAtRef.current - 30000
)
return null;
return accessTokenRef.current;
}, []); }, []);
const setAccessTokenFn = useCallback( const setAccessTokenFn = useCallback(
(token: string | null, expiresIn?: number) => { (token: string | null, expiresIn?: number) => {
const ttl = expiresIn ?? 900; // default 15 min matching backend config const ttl = expiresIn ?? 900; // default 15 min matching backend config
accessToken = token; accessTokenRef.current = token;
tokenExpiresAt = token ? Date.now() + ttl * 1000 : null; tokenExpiresAtRef.current = token ? Date.now() + ttl * 1000 : null;
if (refreshTimeoutRef.current) { if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current); clearTimeout(refreshTimeoutRef.current);
refreshTimeoutRef.current = null; refreshTimeoutRef.current = null;
@@ -126,7 +126,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const silentRefresh = useCallback(async (): Promise<boolean> => { const silentRefresh = useCallback(async (): Promise<boolean> => {
// Deduplicate concurrent refresh calls — token rotation means only one call can succeed // Deduplicate concurrent refresh calls — token rotation means only one call can succeed
if (silentRefreshInFlight) return silentRefreshInFlight; if (silentRefreshInFlightRef.current)
return silentRefreshInFlightRef.current;
const promise = (async (): Promise<boolean> => { const promise = (async (): Promise<boolean> => {
try { try {
@@ -138,23 +139,24 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (data.success && data.data?.access_token) { if (data.success && data.data?.access_token) {
setAccessTokenFn(data.data.access_token, data.data.expires_in); setAccessTokenFn(data.data.access_token, data.data.expires_in);
setUser(mapUser(data.data.user)); setUser(mapUser(data.data.user));
hadValidSessionRef.current = true;
return true; return true;
} }
accessToken = null; accessTokenRef.current = null;
tokenExpiresAt = null; tokenExpiresAtRef.current = null;
setUser(null); setUser(null);
cachedUser = null; cachedUserRef.current = null;
setSessionExpired(); if (hadValidSessionRef.current) setSessionExpired();
return false; return false;
} catch { } catch {
// Network error — don't kick the user out, just return false // Network error — don't kick the user out, just return false
return false; return false;
} finally { } finally {
silentRefreshInFlight = null; silentRefreshInFlightRef.current = null;
} }
})(); })();
silentRefreshInFlight = promise; silentRefreshInFlightRef.current = promise;
return promise; return promise;
}, [setAccessTokenFn]); }, [setAccessTokenFn]);
@@ -172,12 +174,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
headers, headers,
}); });
if (response.status === 429 || response.status >= 500) if (response.status === 429 || response.status >= 500)
return !!cachedUser; return !!cachedUserRef.current;
const data = await response.json(); const data = await response.json();
if (data.success && data.data?.user) { if (data.success && data.data?.user) {
if (data.data.access_token) setAccessTokenFn(data.data.access_token); if (data.data.access_token) setAccessTokenFn(data.data.access_token);
setUser(mapUser(data.data.user)); setUser(mapUser(data.data.user));
cachedUser = mapUser(data.data.user); cachedUserRef.current = mapUser(data.data.user);
hadValidSessionRef.current = true;
return true; return true;
} }
} }
@@ -185,15 +188,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const refreshed = await silentRefresh(); const refreshed = await silentRefresh();
if (refreshed) return true; if (refreshed) return true;
setUser(null); setUser(null);
cachedUser = null; cachedUserRef.current = null;
accessToken = null; accessTokenRef.current = null;
tokenExpiresAt = null; tokenExpiresAtRef.current = null;
return false; return false;
} catch { } catch {
return !!cachedUser; return !!cachedUserRef.current;
} finally { } finally {
setLoading(false); setLoading(false);
sessionFetched = true; sessionFetchedRef.current = true;
} }
}, [getAccessTokenFn, setAccessTokenFn, silentRefresh]); }, [getAccessTokenFn, setAccessTokenFn, silentRefresh]);
@@ -231,8 +234,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} }
setAccessTokenFn(data.data.access_token, data.data.expires_in); setAccessTokenFn(data.data.access_token, data.data.expires_in);
setUser(mapUser(data.data.user)); setUser(mapUser(data.data.user));
cachedUser = mapUser(data.data.user); cachedUserRef.current = mapUser(data.data.user);
sessionFetched = true; sessionFetchedRef.current = true;
hadValidSessionRef.current = true;
return { success: true }; return { success: true };
} }
setError(data.error); setError(data.error);
@@ -264,14 +268,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
login_token: loginToken, login_token: loginToken,
totp_code: code, totp_code: code,
remember_me: remember, remember_me: remember,
isBackup,
}), }),
}); });
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
setAccessTokenFn(data.data.access_token, data.data.expires_in); setAccessTokenFn(data.data.access_token, data.data.expires_in);
setUser(mapUser(data.data.user)); setUser(mapUser(data.data.user));
cachedUser = mapUser(data.data.user); cachedUserRef.current = mapUser(data.data.user);
sessionFetched = true; sessionFetchedRef.current = true;
hadValidSessionRef.current = true;
return { success: true }; return { success: true };
} }
setError(data.error); setError(data.error);
@@ -296,11 +302,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} catch { } catch {
/* ignore */ /* ignore */
} finally { } finally {
accessToken = null; accessTokenRef.current = null;
tokenExpiresAt = null; tokenExpiresAtRef.current = null;
setUser(null); setUser(null);
cachedUser = null; cachedUserRef.current = null;
sessionFetched = false; sessionFetchedRef.current = false;
hadValidSessionRef.current = false;
if (refreshTimeoutRef.current) { if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current); clearTimeout(refreshTimeoutRef.current);
refreshTimeoutRef.current = null; refreshTimeoutRef.current = null;

View File

@@ -0,0 +1,69 @@
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

@@ -0,0 +1,104 @@
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

@@ -0,0 +1,43 @@
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

@@ -0,0 +1,79 @@
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

@@ -0,0 +1,102 @@
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

@@ -0,0 +1,49 @@
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

@@ -0,0 +1,52 @@
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

@@ -0,0 +1,69 @@
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

@@ -0,0 +1,63 @@
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

@@ -0,0 +1,94 @@
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

@@ -0,0 +1,86 @@
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

@@ -0,0 +1,83 @@
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

@@ -0,0 +1,51 @@
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

@@ -0,0 +1,53 @@
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

@@ -0,0 +1,60 @@
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

@@ -0,0 +1,49 @@
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

@@ -0,0 +1,54 @@
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

@@ -0,0 +1,36 @@
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

@@ -0,0 +1,89 @@
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

@@ -0,0 +1,55 @@
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

@@ -0,0 +1,70 @@
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

@@ -0,0 +1,53 @@
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

@@ -0,0 +1,51 @@
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

@@ -0,0 +1,80 @@
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

@@ -0,0 +1,70 @@
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

@@ -0,0 +1,44 @@
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

@@ -0,0 +1,132 @@
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

@@ -0,0 +1,40 @@
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

@@ -0,0 +1,66 @@
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

@@ -0,0 +1,48 @@
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

@@ -23,8 +23,9 @@ export default function useApiCall() {
abortRef.current = controller; abortRef.current = controller;
try { try {
const { signal: _, ...restOptions } = options;
const response = await apiFetch(url, { const response = await apiFetch(url, {
...options, ...restOptions,
signal: controller.signal, signal: controller.signal,
}); });
const data = await response.json(); const data = await response.json();

View File

@@ -224,11 +224,20 @@ function computeUserTotals(
// Print helpers // Print helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function renderFundStatus(userData: Record<string, any>): string { function renderFundStatus(userData: Record<string, any>): string {
if (userData.overtime > 0) if (userData.overtime > 0)
return `<span class="leave-badge badge-overtime">+${userData.overtime}h přesčas</span>`; return `<span class="leave-badge badge-overtime">+${escapeHtml(String(userData.overtime))}h přesčas</span>`;
if (userData.missing > 0) if (userData.missing > 0)
return `<span style="color:#dc2626">${userData.missing}h</span>`; return `<span style="color:#dc2626">${escapeHtml(String(userData.missing))}h</span>`;
return '<span style="color:#16a34a">splněno</span>'; return '<span style="color:#16a34a">splněno</span>';
} }
@@ -255,11 +264,11 @@ function buildProjectLogsHtml(record: Record<string, any>): string {
h = 0; h = 0;
m = 0; m = 0;
} }
return `<div>${log.project_name || `#${log.project_id}`} (${h}:${String(m).padStart(2, "0")}h)</div>`; return `<div>${escapeHtml(log.project_name || `#${log.project_id}`)} (${h}:${String(m).padStart(2, "0")}h)</div>`;
}) })
.join(""); .join("");
} }
return record.project_name || "—"; return escapeHtml(record.project_name || "—");
} }
function buildLeaveSummaryHtml( function buildLeaveSummaryHtml(
@@ -268,15 +277,15 @@ function buildLeaveSummaryHtml(
printData: Record<string, any>, printData: Record<string, any>,
): string { ): string {
const bal = printData.leave_balances[userId]; const bal = printData.leave_balances[userId];
let parts = `<strong>Dovolená ${printData.year}:</strong> Zbývá ${bal.vacation_remaining.toFixed(1)}h z ${bal.vacation_total}h`; 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) if (userData.vacation_hours > 0)
parts += ` | <span class="leave-badge badge-vacation">Tento měsíc: ${userData.vacation_hours}h</span>`; parts += ` | <span class="leave-badge badge-vacation">Tento měsíc: ${escapeHtml(String(userData.vacation_hours))}h</span>`;
if (userData.sick_hours > 0) if (userData.sick_hours > 0)
parts += ` | <span class="leave-badge badge-sick">Nemoc: ${userData.sick_hours}h</span>`; parts += ` | <span class="leave-badge badge-sick">Nemoc: ${escapeHtml(String(userData.sick_hours))}h</span>`;
if (userData.holiday_hours > 0) if (userData.holiday_hours > 0)
parts += ` | <span class="leave-badge badge-holiday">Svátek: ${userData.holiday_hours}h</span>`; parts += ` | <span class="leave-badge badge-holiday">Svátek: ${escapeHtml(String(userData.holiday_hours))}h</span>`;
if (userData.overtime > 0) if (userData.overtime > 0)
parts += ` | <span class="leave-badge badge-overtime">Přesčas: +${userData.overtime}h</span>`; parts += ` | <span class="leave-badge badge-overtime">Přesčas: +${escapeHtml(String(userData.overtime))}h</span>`;
return `<div class="leave-summary">${parts}</div>`; return `<div class="leave-summary">${parts}</div>`;
} }
@@ -299,17 +308,17 @@ function buildUserSectionHtml(
const breakCell = const breakCell =
isLeave || !record.break_start || !record.break_end isLeave || !record.break_start || !record.break_end
? "—" ? "—"
: `${formatTimeOrDatetimePrint(record.break_start, record.shift_date)} - ${formatTimeOrDatetimePrint(record.break_end, record.shift_date)}`; : `${escapeHtml(formatTimeOrDatetimePrint(record.break_start, record.shift_date))} - ${escapeHtml(formatTimeOrDatetimePrint(record.break_end, record.shift_date))}`;
return `<tr> return `<tr>
<td>${formatDate(record.shift_date)}</td> <td>${escapeHtml(formatDate(record.shift_date))}</td>
<td><span class="leave-badge ${getLeaveTypeBadgeClass(leaveType)}">${getLeaveTypeName(leaveType)}</span></td> <td><span class="leave-badge ${escapeHtml(getLeaveTypeBadgeClass(leaveType))}">${escapeHtml(getLeaveTypeName(leaveType))}</span></td>
<td class="text-center">${isLeave ? "—" : 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 ? "—" : 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">${workMinutes > 0 ? `${hours}:${String(mins).padStart(2, "0")}` : "—"}</td>
<td style="font-size:8px">${buildProjectLogsHtml(record)}</td> <td style="font-size:8px">${buildProjectLogsHtml(record)}</td>
<td>${record.notes || ""}</td> <td>${escapeHtml(record.notes || "")}</td>
</tr>`; </tr>`;
}) })
.join(""); .join("");
@@ -318,15 +327,15 @@ function buildUserSectionHtml(
userData.fund !== null userData.fund !== null
? `<tr> ? `<tr>
<td colspan="6" class="text-right">Fond měsíce:</td> <td colspan="6" class="text-right">Fond měsíce:</td>
<td class="text-center">${userData.covered}h / ${userData.fund}h</td> <td class="text-center">${escapeHtml(String(userData.covered))}h / ${escapeHtml(String(userData.fund))}h</td>
<td colspan="2">${renderFundStatus(userData)}</td> <td colspan="2">${renderFundStatus(userData)}</td>
</tr>` </tr>`
: ""; : "";
return `<div class="user-section"> return `<div class="user-section">
<div class="user-header"> <div class="user-header">
<h3>${userData.name}</h3> <h3>${escapeHtml(userData.name)}</h3>
<span class="total">Odpracováno: ${formatMinutes(userData.minutes)} h</span> <span class="total">Odpracováno: ${escapeHtml(formatMinutes(userData.minutes))} h</span>
</div> </div>
${leaveHtml} ${leaveHtml}
<table> <table>
@@ -344,7 +353,7 @@ function buildUserSectionHtml(
<tfoot> <tfoot>
<tr> <tr>
<td colspan="6" class="text-right">Odpracováno:</td> <td colspan="6" class="text-right">Odpracováno:</td>
<td class="text-center">${formatMinutes(userData.minutes)} h</td> <td class="text-center">${escapeHtml(formatMinutes(userData.minutes))} h</td>
<td colspan="2"></td> <td colspan="2"></td>
</tr> </tr>
${fundRow} ${fundRow}
@@ -365,7 +374,7 @@ function buildPrintHtml(
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<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 - ${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; }
body { body {
@@ -428,11 +437,11 @@ function buildPrintHtml(
<img src="/api/admin/company-settings/logo?variant=light" alt="" class="print-logo" /> <img src="/api/admin/company-settings/logo?variant=light" alt="" class="print-logo" />
<div class="print-header-text"> <div class="print-header-text">
<h1>EVIDENCE DOCHÁZKY</h1> <h1>EVIDENCE DOCHÁZKY</h1>
<div class="company">${companyName}</div> <div class="company">${escapeHtml(companyName)}</div>
</div> </div>
</div> </div>
<div class="print-header-right"> <div class="print-header-right">
<div class="period">${pData.month_name}</div> <div class="period">${escapeHtml(pData.month_name)}</div>
${filterNote} ${filterNote}
<div class="generated">Vygenerováno: ${new Date().toLocaleString("cs-CZ")}</div> <div class="generated">Vygenerováno: ${new Date().toLocaleString("cs-CZ")}</div>
</div> </div>
@@ -1037,7 +1046,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
? '<p style="text-align:center;padding:20px">Za vybrané období nejsou žádné záznamy.</p>' ? '<p style="text-align:center;padding:20px">Za vybrané období nejsou žádné záznamy.</p>'
: ""; : "";
const filterNote = pData.selected_user_name const filterNote = pData.selected_user_name
? `<div class="filters">Zaměstnanec: ${pData.selected_user_name}</div>` ? `<div class="filters">Zaměstnanec: ${escapeHtml(pData.selected_user_name)}</div>`
: ""; : "";
const bodyContent = buildPrintHtml( const bodyContent = buildPrintHtml(
pData, pData,
@@ -1051,7 +1060,9 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
printWindow.document.open(); printWindow.document.open();
printWindow.document.write(bodyContent); printWindow.document.write(bodyContent);
printWindow.document.close(); printWindow.document.close();
printWindow.onload = () => printWindow.print(); printWindow.addEventListener("load", () => printWindow.print(), {
once: true,
});
} }
} }
} catch { } catch {

View File

@@ -43,8 +43,14 @@ export default function useListData<T = unknown>(
const [initialLoad, setInitialLoad] = useState(true); const [initialLoad, setInitialLoad] = useState(true);
const [pagination, setPagination] = useState<PaginationData | null>(null); const [pagination, setPagination] = useState<PaginationData | null>(null);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const mountedRef = useRef(true);
const debouncedSearch = useDebounce(search, 300); const debouncedSearch = useDebounce(search, 300);
const extraParamsKey = Object.entries(extraParams)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([k, v]) => `${k}=${v}`)
.join("&");
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
if (abortRef.current) abortRef.current.abort(); if (abortRef.current) abortRef.current.abort();
const controller = new AbortController(); const controller = new AbortController();
@@ -66,7 +72,10 @@ export default function useListData<T = unknown>(
? `${endpoint}?${params}` ? `${endpoint}?${params}`
: `${API_BASE}/${endpoint}?${params}`; : `${API_BASE}/${endpoint}?${params}`;
const response = await apiFetch(url, { signal: controller.signal }); const response = await apiFetch(url, { signal: controller.signal });
if (response.status === 401) return; if (response.status === 401) {
window.location.href = "/login";
return;
}
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
const data = dataKey const data = dataKey
@@ -92,8 +101,10 @@ export default function useListData<T = unknown>(
} }
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof Error && err.name === "AbortError") return; if (err instanceof Error && err.name === "AbortError") return;
if (!mountedRef.current) return;
alert.error(errorMsg); alert.error(errorMsg);
} finally { } finally {
if (!mountedRef.current) return;
setLoading(false); setLoading(false);
setInitialLoad(false); setInitialLoad(false);
} }
@@ -105,12 +116,14 @@ export default function useListData<T = unknown>(
page, page,
perPage, perPage,
dataKey, dataKey,
JSON.stringify(extraParams), extraParamsKey,
]); // eslint-disable-line react-hooks/exhaustive-deps ]);
useEffect(() => { useEffect(() => {
mountedRef.current = true;
fetchData(); fetchData();
return () => { return () => {
mountedRef.current = false;
if (abortRef.current) abortRef.current.abort(); if (abortRef.current) abortRef.current.abort();
}; };
}, [fetchData]); }, [fetchData]);

View File

@@ -1,14 +1,16 @@
import { useEffect } from "react"; import { useEffect } from "react";
let activeLocks = 0;
export default function useModalLock(isOpen: boolean): void { export default function useModalLock(isOpen: boolean): void {
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
document.body.style.overflow = "hidden"; if (activeLocks === 0) document.body.style.overflow = "hidden";
} else { activeLocks++;
document.body.style.overflow = ""; return () => {
activeLocks = Math.max(0, activeLocks - 1);
if (activeLocks === 0) document.body.style.overflow = "";
};
} }
return () => {
document.body.style.overflow = "";
};
}, [isOpen]); }, [isOpen]);
} }

View File

@@ -0,0 +1,44 @@
import {
useQuery,
keepPreviousData,
useQueryClient,
} from "@tanstack/react-query";
import type { UseQueryOptions } from "@tanstack/react-query";
interface PaginatedResult<T> {
data: T[];
pagination: {
total: number;
page: number;
per_page: number;
total_pages: number;
};
}
/**
* Wrapper around useQuery for paginated list endpoints.
* Accepts the return value of queryOptions() from lib/queries/*
* and extracts items + pagination from the response.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function usePaginatedQuery<T>(options: any) {
const query = useQuery({
...options,
placeholderData: keepPreviousData,
} as UseQueryOptions<PaginatedResult<T>>);
const data = query.data as PaginatedResult<T> | undefined;
return {
items: (data?.data ?? []) as T[],
pagination: data?.pagination ?? null,
isPending: query.isPending,
isFetching: query.isFetching,
isPlaceholderData: query.isPlaceholderData,
isError: query.isError,
error: query.error,
refetch: query.refetch,
};
}
export { useQueryClient, useQuery, keepPreviousData };

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useRef } from "react"; import { useState, useCallback } from "react";
interface SortState { interface SortState {
sort: string; sort: string;
@@ -13,10 +13,10 @@ export default function useTableSort(
sort: defaultSort, sort: defaultSort,
order: defaultOrder, order: defaultOrder,
}); });
const userClicked = useRef(false); const [userClicked, setUserClicked] = useState(false);
const handleSort = useCallback((column: string) => { const handleSort = useCallback((column: string) => {
userClicked.current = true; setUserClicked(true);
setState((prev) => { setState((prev) => {
if (prev.sort === column) { if (prev.sort === column) {
return { sort: column, order: prev.order === "asc" ? "desc" : "asc" }; return { sort: column, order: prev.order === "asc" ? "desc" : "asc" };
@@ -25,7 +25,7 @@ export default function useTableSort(
}); });
}, []); }, []);
const activeSort = userClicked.current ? state.sort : null; const activeSort = userClicked ? state.sort : null;
return { sort: state.sort, order: state.order, handleSort, activeSort }; return { sort: state.sort, order: state.order, handleSort, activeSort };
} }

View File

@@ -0,0 +1,83 @@
import apiFetch from "../utils/api";
/**
* Thin adapter that converts apiFetch responses into the shape TanStack Query expects.
* - Checks response.ok and result.success
* - Throws on errors so TanStack Query can handle retry/error states
* - Returns result.data directly (unwrapped from the API envelope)
*/
export async function jsonQuery<T>(
url: string,
options?: RequestInit,
): Promise<T> {
const response = await apiFetch(url, options);
if (response.status === 401) {
throw new Error("Unauthorized");
}
let result: { success: boolean; data?: unknown; error?: string };
try {
result = (await response.json()) as typeof result;
} catch {
throw new Error("Invalid JSON response");
}
if (!response.ok || !result.success) {
throw new Error(result.error || `Request failed (${response.status})`);
}
return result.data as T;
}
export interface PaginationMeta {
total: number;
page: number;
per_page: number;
total_pages: number;
}
export interface PaginatedResult<T> {
data: T[];
pagination: PaginationMeta;
}
export async function paginatedJsonQuery<T>(
url: string,
options?: RequestInit,
): Promise<PaginatedResult<T>> {
const response = await apiFetch(url, options);
if (response.status === 401) {
throw new Error("Unauthorized");
}
let result: {
success: boolean;
data?: unknown;
error?: string;
pagination?: PaginationMeta;
};
try {
result = (await response.json()) as typeof result;
} catch {
throw new Error("Invalid JSON response");
}
if (!response.ok || !result.success) {
throw new Error(result.error || `Request failed (${response.status})`);
}
const items = Array.isArray(result.data)
? result.data
: ((result.data as { items?: T[] })?.items ?? []);
const pagination = result.pagination ??
(result.data as { pagination?: PaginationMeta })?.pagination ?? {
total: items.length,
page: 1,
per_page: items.length,
total_pages: 1,
};
return { data: items as T[], pagination };
}

View File

@@ -0,0 +1,99 @@
import { queryOptions } from "@tanstack/react-query";
import apiFetch from "../../utils/api";
import { jsonQuery } from "../apiAdapter";
interface LocationRaw {
users?: { first_name: string; last_name: string };
user_name?: string;
shift_date: string;
arrival_time?: string | null;
departure_time?: string | null;
arrival_lat?: string | number | null;
arrival_lng?: string | number | null;
arrival_accuracy?: number | null;
arrival_address?: string | null;
departure_lat?: string | number | null;
departure_lng?: string | number | null;
departure_accuracy?: number | null;
departure_address?: string | null;
}
export interface LocationRecord {
user_name: string;
shift_date: string;
arrival_time?: string | null;
departure_time?: string | null;
arrival_lat?: string | number | null;
arrival_lng?: string | number | null;
arrival_accuracy?: number | null;
arrival_address?: string | null;
departure_lat?: string | number | null;
departure_lng?: string | number | null;
departure_accuracy?: number | null;
departure_address?: string | null;
}
export const attendanceHistoryOptions = (filters: {
month?: string;
userId?: number;
}) =>
queryOptions({
queryKey: ["attendance", "history", filters],
queryFn: () => {
const params = new URLSearchParams();
params.set("limit", "1000");
if (filters.month) params.set("month", filters.month);
if (filters.userId) params.set("user_id", String(filters.userId));
return jsonQuery<Record<string, unknown>[]>(
`/api/admin/attendance?${params.toString()}`,
);
},
});
export const attendanceBalancesOptions = (year: number) =>
queryOptions({
queryKey: ["attendance", "balances", year],
queryFn: () =>
jsonQuery<Record<string, unknown>>(
`/api/admin/attendance?action=balances&year=${year}`,
),
});
export const attendanceWorkFundOptions = (year: number) =>
queryOptions({
queryKey: ["attendance", "workfund", year],
queryFn: () =>
jsonQuery<Record<string, unknown>>(
`/api/admin/attendance?action=workfund&year=${year}`,
),
});
export const attendanceProjectReportOptions = (year: number) =>
queryOptions({
queryKey: ["attendance", "project-report", year],
queryFn: () =>
jsonQuery<Record<string, unknown>>(
`/api/admin/attendance?action=project_report&year=${year}`,
),
});
export const attendanceLocationOptions = (id: string | undefined) =>
queryOptions({
queryKey: ["attendance", "location", id],
queryFn: async (): Promise<LocationRecord> => {
const response = await apiFetch(
`/api/admin/attendance?action=location&id=${id}`,
);
const result = await response.json();
if (!result.success) {
throw new Error(result.error || "Záznam nebyl nalezen");
}
const raw = (result.data.record || result.data) as LocationRaw;
const userName = raw.users
? `${raw.users.first_name} ${raw.users.last_name}`.trim()
: raw.user_name || "";
const { users: _users, ...rest } = raw;
return { ...rest, user_name: userName };
},
enabled: !!id,
});

View File

@@ -0,0 +1,28 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery } from "../apiAdapter";
export const auditLogOptions = (filters: {
search?: string;
action?: string;
entityType?: string;
dateFrom?: string;
dateTo?: string;
page?: number;
}) =>
queryOptions({
queryKey: ["audit-log", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search);
if (filters.action) params.set("action", filters.action);
if (filters.entityType) params.set("entity_type", filters.entityType);
if (filters.dateFrom) params.set("date_from", filters.dateFrom);
if (filters.dateTo) params.set("date_to", filters.dateTo);
if (filters.page) params.set("page", String(filters.page));
const qs = params.toString();
return jsonQuery<{
data: Record<string, unknown>[];
pagination: Record<string, unknown>;
}>(`/api/admin/audit-log${qs ? `?${qs}` : ""}`);
},
});

View File

@@ -0,0 +1,18 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery } from "../apiAdapter";
export const bankAccountsOptions = () =>
queryOptions({
queryKey: ["bank-accounts"],
queryFn: () =>
jsonQuery<Record<string, unknown>[]>("/api/admin/bank-accounts"),
staleTime: 2 * 60_000,
});
export const supplierListOptions = () =>
queryOptions({
queryKey: ["suppliers"],
queryFn: () =>
jsonQuery<string[]>("/api/admin/received-invoices/suppliers"),
staleTime: 2 * 60_000,
});

View File

@@ -0,0 +1,42 @@
import { queryOptions } from "@tanstack/react-query";
import apiFetch from "../../utils/api";
import { jsonQuery } from "../apiAdapter";
export const dashboardOptions = () =>
queryOptions({
queryKey: ["dashboard"],
queryFn: () => jsonQuery<Record<string, unknown>>("/api/admin/dashboard"),
staleTime: 60_000,
});
export const require2FAOptions = () =>
queryOptions({
queryKey: ["settings", "2fa"],
queryFn: () =>
jsonQuery<{ require_2fa: boolean }>("/api/admin/totp/required"),
});
export interface Session {
id: number | string;
is_current: boolean;
device_info?: {
icon?: string;
browser?: string;
os?: string;
};
ip_address: string;
created_at: string;
}
export const sessionsOptions = () =>
queryOptions({
queryKey: ["sessions"],
queryFn: async (): Promise<Session[]> => {
const response = await apiFetch("/api/admin/sessions");
const data = await response.json();
if (data.success) {
return Array.isArray(data.data) ? data.data : data.data?.sessions || [];
}
throw new Error(data.error || "Nepodařilo se načíst relace");
},
});

View File

@@ -0,0 +1,126 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
export interface CurrencyAmount {
amount: number;
currency: string;
}
export interface Invoice {
id: number;
invoice_number: string;
customer_name: string | null;
status: string;
issue_date: string;
due_date: string;
total: number;
currency: string;
}
export interface InvoiceStats {
paid_month: CurrencyAmount[];
paid_month_czk: number;
paid_month_count: number;
awaiting: CurrencyAmount[];
awaiting_czk: number;
awaiting_count: number;
overdue: CurrencyAmount[];
overdue_czk: number;
overdue_count: number;
vat_month: CurrencyAmount[];
vat_month_czk: number;
}
export const invoiceListOptions = (filters: {
search?: string;
sort?: string;
order?: string;
page?: number;
perPage?: number;
month?: number;
year?: number;
status?: string;
}) =>
queryOptions({
queryKey: ["invoices", "list", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search);
if (filters.sort) params.set("sort", filters.sort);
if (filters.order) params.set("order", filters.order);
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
if (filters.month) params.set("month", String(filters.month));
if (filters.year) params.set("year", String(filters.year));
if (filters.status) params.set("status", filters.status);
const qs = params.toString();
return paginatedJsonQuery<Invoice>(
`/api/admin/invoices${qs ? `?${qs}` : ""}`,
);
},
});
export const receivedInvoiceListOptions = (filters: {
month?: number;
year?: number;
search?: string;
sort?: string;
order?: string;
page?: number;
perPage?: number;
}) =>
queryOptions({
queryKey: [
"invoices",
"received",
{
month: filters.month,
year: filters.year,
search: filters.search,
sort: filters.sort,
order: filters.order,
page: filters.page,
perPage: filters.perPage,
},
],
queryFn: () => {
const params = new URLSearchParams();
if (filters.month) params.set("month", String(filters.month));
if (filters.year) params.set("year", String(filters.year));
if (filters.search) params.set("search", filters.search);
if (filters.sort) params.set("sort", filters.sort);
if (filters.order) params.set("order", filters.order);
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
const qs = params.toString();
return paginatedJsonQuery(
`/api/admin/received-invoices${qs ? `?${qs}` : ""}`,
);
},
});
export const invoiceStatsOptions = (month: number, year: number) =>
queryOptions({
queryKey: ["invoices", "stats", month, year],
queryFn: () =>
jsonQuery<InvoiceStats>(
`/api/admin/invoices/stats?month=${month}&year=${year}`,
),
});
export const receivedInvoiceStatsOptions = (month: number, year: number) =>
queryOptions({
queryKey: ["invoices", "received", "stats", month, year],
queryFn: () =>
jsonQuery<Record<string, unknown>>(
`/api/admin/received-invoices/stats?month=${month}&year=${year}`,
),
});
export const invoiceDetailOptions = (id: string | undefined) =>
queryOptions({
queryKey: ["invoices", id],
queryFn: () =>
jsonQuery<Record<string, unknown>>(`/api/admin/invoices/${id}`),
enabled: !!id,
});

View File

@@ -0,0 +1,29 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery } from "../apiAdapter";
export const leaveRequestsOptions = (mine = true) =>
queryOptions({
queryKey: ["leave-requests", mine ? "mine" : "all"],
queryFn: () =>
jsonQuery<Record<string, unknown>>(
`/api/admin/leave-requests${mine ? "?mine=1" : ""}`,
),
});
export const leavePendingOptions = () =>
queryOptions({
queryKey: ["leave", "pending"],
queryFn: () =>
jsonQuery<Record<string, unknown>[]>(
"/api/admin/leave-requests?status=pending",
),
});
export const leaveProcessedOptions = () =>
queryOptions({
queryKey: ["leave", "processed"],
queryFn: () =>
jsonQuery<Record<string, unknown>[]>(
"/api/admin/leave-requests?status=approved,rejected",
),
});

View File

@@ -0,0 +1,58 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
export const offerCustomersOptions = () =>
queryOptions({
queryKey: ["offer-customers"],
queryFn: () => jsonQuery<Record<string, unknown>>("/api/admin/customers"),
staleTime: 2 * 60_000,
});
export const offerTemplatesOptions = (action?: string) =>
queryOptions({
queryKey: ["offer-templates", action ?? "all"],
queryFn: () => {
const url = action
? `/api/admin/offers-templates?action=${action}`
: "/api/admin/offers-templates";
return jsonQuery<Record<string, unknown>[]>(url);
},
});
export const offerListOptions = (filters: {
search?: string;
sort?: string;
order?: string;
page?: number;
perPage?: number;
}) =>
queryOptions({
queryKey: ["offers", "list", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search);
if (filters.sort) params.set("sort", filters.sort);
if (filters.order) params.set("order", filters.order);
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
const qs = params.toString();
return paginatedJsonQuery(`/api/admin/offers${qs ? `?${qs}` : ""}`);
},
});
export const offerDetailOptions = (id: string | undefined) =>
queryOptions({
queryKey: ["offers", id],
queryFn: () =>
jsonQuery<Record<string, unknown>>(`/api/admin/offers/${id}`),
enabled: !!id,
});
export const offerNextNumberOptions = () =>
queryOptions({
queryKey: ["offers", "next-number"],
queryFn: () =>
jsonQuery<{ next_number?: string; number?: string }>(
"/api/admin/offers/next-number",
),
});

View File

@@ -0,0 +1,31 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
export const orderListOptions = (filters: {
search?: string;
sort?: string;
order?: string;
page?: number;
perPage?: number;
}) =>
queryOptions({
queryKey: ["orders", "list", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search);
if (filters.sort) params.set("sort", filters.sort);
if (filters.order) params.set("order", filters.order);
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
const qs = params.toString();
return paginatedJsonQuery(`/api/admin/orders${qs ? `?${qs}` : ""}`);
},
});
export const orderDetailOptions = (id: string | undefined) =>
queryOptions({
queryKey: ["orders", id],
queryFn: () =>
jsonQuery<Record<string, unknown>>(`/api/admin/orders/${id}`),
enabled: !!id,
});

View File

@@ -0,0 +1,78 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
import apiFetch from "../../utils/api";
export const projectListOptions = (filters: {
search?: string;
sort?: string;
order?: string;
page?: number;
perPage?: number;
}) =>
queryOptions({
queryKey: ["projects", "list", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search);
if (filters.sort) params.set("sort", filters.sort);
if (filters.order) params.set("order", filters.order);
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
const qs = params.toString();
return paginatedJsonQuery(`/api/admin/projects${qs ? `?${qs}` : ""}`);
},
});
export const projectDetailOptions = (id: string | undefined) =>
queryOptions({
queryKey: ["projects", id],
queryFn: () =>
jsonQuery<Record<string, unknown>>(`/api/admin/projects/${id}`),
enabled: !!id,
});
export interface ProjectFilesData {
items: Array<{
name: string;
type: "file" | "folder";
size?: number;
size_formatted?: string;
modified?: string;
extension?: string;
item_count?: number;
is_symlink?: boolean;
link_target?: string;
}>;
breadcrumb: string[];
path: string;
full_path: string;
}
export const projectFilesOptions = (projectId: number, path: string) =>
queryOptions({
queryKey: ["projects", String(projectId), "files", path],
queryFn: async (): Promise<ProjectFilesData> => {
const params = new URLSearchParams({ project_id: String(projectId) });
if (path) params.set("path", path);
let res: Response;
try {
res = await apiFetch(`/api/admin/project-files?${params}`);
} catch {
throw new Error("Chyba připojení");
}
if (res.status === 401) throw new Error("Unauthorized");
if (res.status === 404) {
return { items: [], breadcrumb: [""], path: "", full_path: "" };
}
const data = await res.json();
if (!res.ok || !data.success) {
throw new Error(data.error || `Request failed (${res.status})`);
}
return {
items: data.data.items || [],
breadcrumb: data.data.breadcrumb || [""],
path: data.data.path || "",
full_path: data.data.full_path || "",
};
},
});

View File

@@ -0,0 +1,29 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery } from "../apiAdapter";
export const companySettingsOptions = () =>
queryOptions({
queryKey: ["company-settings"],
queryFn: () =>
jsonQuery<Record<string, unknown>>("/api/admin/company-settings"),
staleTime: 5 * 60_000,
});
export const systemInfoOptions = () =>
queryOptions({
queryKey: ["settings", "system-info"],
queryFn: () =>
jsonQuery<Record<string, unknown>>(
"/api/admin/company-settings/system-info",
),
});
/** @deprecated Use systemInfoOptions instead — this query fetches system-info, not system settings. */
export const systemSettingsOptions = systemInfoOptions;
export const require2FAOptions = () =>
queryOptions({
queryKey: ["settings", "2fa"],
queryFn: () =>
jsonQuery<{ require_2fa: boolean }>("/api/admin/totp/required"),
});

View File

@@ -0,0 +1,82 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery } from "../apiAdapter";
export const tripListOptions = (filters: {
month?: number;
year?: number;
vehicleId?: number;
userId?: number;
page?: number;
perPage?: number;
}) =>
queryOptions({
queryKey: [
"trips",
"list",
{
month: filters.month,
year: filters.year,
vehicleId: filters.vehicleId,
userId: filters.userId,
page: filters.page,
perPage: filters.perPage,
},
],
queryFn: () => {
const params = new URLSearchParams();
if (filters.month) params.set("month", String(filters.month));
if (filters.year) params.set("year", String(filters.year));
if (filters.vehicleId)
params.set("vehicle_id", String(filters.vehicleId));
if (filters.userId) params.set("user_id", String(filters.userId));
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
const qs = params.toString();
return jsonQuery<Record<string, unknown>[]>(
`/api/admin/trips${qs ? `?${qs}` : ""}`,
);
},
});
export const tripVehiclesOptions = () =>
queryOptions({
queryKey: ["trips", "vehicles"],
queryFn: () => jsonQuery<Record<string, unknown>[]>("/api/admin/vehicles"),
staleTime: 2 * 60_000,
});
export const tripUsersOptions = () =>
queryOptions({
queryKey: ["trips", "users"],
queryFn: () =>
jsonQuery<Record<string, unknown>[]>("/api/admin/trips/users"),
staleTime: 2 * 60_000,
});
export const tripHistoryOptions = (filters: {
month?: string;
vehicleId?: number;
userId?: number;
}) =>
queryOptions({
queryKey: [
"trips",
"history",
{
month: filters.month,
vehicleId: filters.vehicleId,
userId: filters.userId,
},
],
queryFn: () => {
const params = new URLSearchParams();
if (filters.month) params.set("month", filters.month);
if (filters.vehicleId)
params.set("vehicle_id", String(filters.vehicleId));
if (filters.userId) params.set("user_id", String(filters.userId));
const qs = params.toString();
return jsonQuery<Record<string, unknown>[]>(
`/api/admin/trips${qs ? `?${qs}` : ""}`,
);
},
});

View File

@@ -0,0 +1,18 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery } from "../apiAdapter";
export const userListOptions = () =>
queryOptions({
queryKey: ["users"],
queryFn: () => {
// The users endpoint returns { success, data: { items, ... } } or { success, data: [...] }
return jsonQuery<Record<string, unknown>>("/api/admin/users");
},
});
export const roleListOptions = () =>
queryOptions({
queryKey: ["roles"],
queryFn: () => jsonQuery<Record<string, unknown>[]>("/api/admin/roles"),
staleTime: 2 * 60_000,
});

View File

@@ -0,0 +1,8 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery } from "../apiAdapter";
export const vehicleListOptions = () =>
queryOptions({
queryKey: ["vehicles"],
queryFn: () => jsonQuery<Record<string, unknown>[]>("/api/admin/vehicles"),
});

View File

@@ -0,0 +1,13 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
gcTime: 5 * 60_000,
refetchOnWindowFocus: true,
retry: 1,
refetchOnReconnect: true,
},
},
});

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