18 Commits

Author SHA1 Message Date
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
191 changed files with 30870 additions and 12526 deletions

View File

@@ -7,13 +7,13 @@ HOST=127.0.0.1
APP_ENV=local
# 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
REFRESH_TOKEN_SESSION_EXPIRY=3600
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000
# 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
NAS_PATH=Z:/02_PROJEKTY

View File

@@ -35,7 +35,7 @@ src/
├── utils/ # totp.ts, pdf.ts, email.ts, audit.ts, formatters, etc.
├── config/ # env.ts (config singleton, Date.toJSON override)
├── 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
│ ├── contexts/ # AuthContext, AlertContext
│ ├── 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`
2. `npm run build`
3. Create a tarball
4. Tag the release in Gitea
5. Deploy via SSH to production server (`boha_admin@192.168.50.100`)
3. Commit and tag (`git tag -a vX.Y.Z`)
4. Push to Gitea (`git push origin master && git push origin vX.Y.Z`)
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.

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",
"version": "1.5.3",
"version": "1.5.6",
"description": "",
"main": "dist/server.js",
"scripts": {
@@ -17,7 +17,8 @@
"db:push": "prisma db push",
"db:studio": "prisma studio",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"bones": "boneyard-js build http://localhost:3000"
},
"keywords": [],
"author": "",
@@ -34,7 +35,10 @@
"@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^9.0.0",
"@prisma/client": "^6.19.2",
"@tanstack/react-query": "^5.100.5",
"@types/jsdom": "^28.0.1",
"bcryptjs": "^3.0.3",
"boneyard-js": "^1.8.1",
"date-fns": "^4.1.0",
"dompurify": "^3.3.3",
"dotenv": "^17.3.1",
@@ -42,6 +46,7 @@
"file-type": "^16.5.4",
"framer-motion": "^12.38.0",
"hi-base32": "^0.5.1",
"jsdom": "^29.0.2",
"jsonwebtoken": "^9.0.3",
"leaflet": "^1.9.4",
"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")
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([project_id], map: "idx_project_id")
}
@@ -46,6 +46,7 @@ model attendance_project_logs {
hours Int? @db.UnsignedInt
minutes Int? @db.UnsignedInt
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([project_id], map: "idx_project_id")
@@ -104,7 +105,7 @@ model company_settings {
quotation_prefix String? @db.VarChar(20)
default_currency String? @default("CZK") @db.VarChar(10)
default_vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
uuid String? @db.VarChar(36)
uuid String? @unique @db.VarChar(36)
modified_at DateTime? @db.DateTime(0)
is_deleted Boolean? @default(false)
sync_version Int? @default(0)
@@ -165,7 +166,7 @@ model invoice_items {
model invoices {
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?
customer_id Int?
status String? @default("issued") @db.VarChar(30)
@@ -196,6 +197,7 @@ model invoices {
@@index([customer_id], map: "customer_id")
@@index([due_date], map: "idx_invoices_due_date")
@@index([status, issue_date], map: "idx_invoices_status_issue")
@@index([status, due_date], map: "idx_invoices_status_due")
@@index([order_id], map: "order_id")
}
@@ -288,7 +290,7 @@ model order_sections {
model orders {
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)
attachment_data Bytes?
attachment_name String? @db.VarChar(255)
@@ -340,7 +342,7 @@ model project_notes {
model projects {
id Int @id @default(autoincrement())
project_number String? @db.VarChar(50)
project_number String? @unique @db.VarChar(50)
name String? @db.VarChar(255)
customer_id Int?
responsible_user_id Int?
@@ -352,6 +354,7 @@ model projects {
notes String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
attendance_project_logs attendance_project_logs[]
project_notes project_notes[]
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")
@@ -385,7 +388,7 @@ model quotation_items {
model quotations {
id Int @id @default(autoincrement())
quotation_number String? @db.VarChar(50)
quotation_number String? @unique @db.VarChar(50)
project_code String? @db.VarChar(50)
customer_id Int?
created_at DateTime? @default(now()) @db.DateTime(0)
@@ -434,7 +437,7 @@ model received_invoices {
file_mime String? @db.VarChar(100)
file_size Int? @db.UnsignedInt
notes String? @db.Text
uploaded_by Int? @db.UnsignedInt
uploaded_by Int?
created_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
model refresh_tokens {
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)
expires_at DateTime @db.DateTime(0)
replaced_at DateTime? @db.DateTime(0)
@@ -580,6 +583,7 @@ model users {
totp_secret String? @db.VarChar(255)
totp_enabled Boolean @default(false)
totp_backup_codes String? @db.Text
totp_last_used_counter Int?
attendance attendance[]
leave_balances leave_balances[]
leave_requests_leave_requests_user_idTousers leave_requests[] @relation("leave_requests_user_idTousers")
@@ -626,6 +630,7 @@ model invoice_alert_log {
invoice_id Int
alert_type String @db.VarChar(20) // "3days" or "due"
sent_at DateTime @default(now()) @db.DateTime(0)
created_at DateTime @default(now()) @db.DateTime(0)
@@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 { Routes, Route } from "react-router-dom";
import { QueryClientProvider } from "@tanstack/react-query";
import { AuthProvider } from "./context/AuthContext";
import { AlertProvider } from "./context/AlertContext";
import { queryClient } from "./lib/queryClient";
import ErrorBoundary from "./components/ErrorBoundary";
import AdminLayout from "./components/AdminLayout";
import AlertContainer from "./components/AlertContainer";
@@ -14,8 +16,8 @@ import "./buttons.css";
import "./layout.css";
import "./components.css";
import "./tables.css";
import "./skeleton.css";
import "./datepicker.css";
import "./bones/registry";
import "./filemanager.css";
import "./pagination.css";
import "./responsive.css";
@@ -46,7 +48,6 @@ const OffersTemplates = lazy(() => import("./pages/OffersTemplates"));
const Orders = lazy(() => import("./pages/Orders"));
const OrderDetail = lazy(() => import("./pages/OrderDetail"));
const Projects = lazy(() => import("./pages/Projects"));
const ProjectCreate = lazy(() => import("./pages/ProjectCreate"));
const ProjectDetail = lazy(() => import("./pages/ProjectDetail"));
const Invoices = lazy(() => import("./pages/Invoices"));
const InvoiceDetail = lazy(() => import("./pages/InvoiceDetail"));
@@ -58,6 +59,7 @@ export default function AdminApp() {
return (
<AuthProvider>
<AlertProvider>
<QueryClientProvider client={queryClient}>
<AlertContainer />
<ErrorBoundary>
<Suspense
@@ -77,13 +79,22 @@ export default function AdminApp() {
path="attendance/history"
element={<AttendanceHistory />}
/>
<Route path="attendance/admin" element={<AttendanceAdmin />} />
<Route
path="attendance/admin"
element={<AttendanceAdmin />}
/>
<Route
path="attendance/balances"
element={<AttendanceBalances />}
/>
<Route path="attendance/requests" element={<LeaveRequests />} />
<Route path="attendance/approval" element={<LeaveApproval />} />
<Route
path="attendance/requests"
element={<LeaveRequests />}
/>
<Route
path="attendance/approval"
element={<LeaveApproval />}
/>
<Route
path="attendance/create"
element={<AttendanceCreate />}
@@ -99,12 +110,17 @@ export default function AdminApp() {
<Route path="offers" element={<Offers />} />
<Route path="offers/new" element={<OfferDetail />} />
<Route path="offers/:id" element={<OfferDetail />} />
<Route path="offers/customers" element={<OffersCustomers />} />
<Route path="offers/templates" element={<OffersTemplates />} />
<Route
path="offers/customers"
element={<OffersCustomers />}
/>
<Route
path="offers/templates"
element={<OffersTemplates />}
/>
<Route path="orders" element={<Orders />} />
<Route path="orders/:id" element={<OrderDetail />} />
<Route path="projects" element={<Projects />} />
<Route path="projects/new" element={<ProjectCreate />} />
<Route path="projects/:id" element={<ProjectDetail />} />
<Route path="invoices" element={<Invoices />} />
<Route path="invoices/new" element={<InvoiceDetail />} />
@@ -116,6 +132,7 @@ export default function AdminApp() {
</Routes>
</Suspense>
</ErrorBoundary>
</QueryClientProvider>
</AlertProvider>
</AuthProvider>
);

View File

@@ -330,15 +330,6 @@ img {
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
/* ── Additional Utilities ─────────────────────────────────────────── */
/* 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,
}: NativeInputProps) {
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 (
<input
type={type}
@@ -84,14 +97,14 @@ function NativeInput({
className="admin-form-input"
required={required}
disabled={disabled}
min={minDate || undefined}
max={maxDate || undefined}
min={minProp}
max={maxProp}
/>
);
}
interface AdminDatePickerProps {
mode?: "date" | "month" | "datetime" | "time";
mode?: "date" | "month" | "time";
value: string;
onChange: (value: string) => void;
minDate?: string;
@@ -165,17 +178,22 @@ export default function AdminDatePicker({
return undefined;
};
const commonProps = {
selected: toDate(value),
onChange: handleChange,
locale: "cs",
customInput: (
const customInput = useMemo(
() => (
<CustomInput
required={required}
placeholder={placeholder}
disabled={disabled}
/>
),
[required, placeholder, disabled],
);
const commonProps = {
selected: toDate(value),
onChange: handleChange,
locale: "cs",
customInput,
minDate: parseMinMax(minDate),
maxDate: parseMinMax(maxDate),
popperPlacement: "bottom-start" as const,

View File

@@ -1,4 +1,4 @@
import { Link } from "react-router-dom";
import { Link } from "react-router-dom";
import {
formatDate,
formatDatetime,
@@ -64,24 +64,30 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
let h: number,
m: number,
isActive = false;
let durationValid = true;
if (log.hours !== null && log.hours !== undefined) {
h = parseInt(String(log.hours)) || 0;
m = parseInt(String(log.minutes)) || 0;
} else {
isActive = !log.ended_at;
const end = log.ended_at ? new Date(log.ended_at) : new Date();
const start = log.started_at ? new Date(log.started_at) : null;
if (start && !isNaN(start.getTime()) && !isNaN(end.getTime())) {
const mins = Math.max(
0,
Math.floor(
(end.getTime() - new Date(log.started_at!).getTime()) / 60000,
),
Math.floor((end.getTime() - start.getTime()) / 60000),
);
h = Math.floor(mins / 60);
m = mins % 60;
} else {
durationValid = false;
h = 0;
m = 0;
}
}
return (
<span
key={log.id || i}
key={log.id ?? i}
className="admin-badge"
style={{
fontSize: "0.7rem",
@@ -89,8 +95,10 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
background: isActive ? "var(--accent-light)" : undefined,
}}
>
{log.project_name || `#${log.project_id}`} ({h}:
{String(m).padStart(2, "0")}h{isActive ? " \u25B8" : ""})
{log.project_name || `#${log.project_id}`}{" "}
{durationValid
? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " \u25B8" : ""})`
: "—"}
</span>
);
})}
@@ -146,7 +154,8 @@ export default function AttendanceShiftTable({
const leaveType = record.leave_type || "work";
const isLeave = leaveType !== "work";
const workMinutes = isLeave
? (Number(record.leave_hours) || 8) * 60
? (record.leave_hours != null ? Number(record.leave_hours) : 8) *
60
: calculateWorkMinutes(record);
const hasLocation =
(record.arrival_lat && record.arrival_lng) ||
@@ -186,7 +195,7 @@ export default function AttendanceShiftTable({
title="Zobrazit polohu"
aria-label="Zobrazit polohu"
>
{"\uD83D\uDCCD"}
<span aria-hidden="true">{"\uD83D\uDCCD"}</span>
</Link>
) : (
"\u2014"

View File

@@ -17,7 +17,7 @@ interface BulkAttendanceUser {
}
interface BulkAttendanceModalProps {
show: boolean;
isOpen: boolean;
onClose: () => void;
form: BulkAttendanceForm;
setForm: (form: BulkAttendanceForm) => void;
@@ -29,7 +29,7 @@ interface BulkAttendanceModalProps {
}
export default function BulkAttendanceModal({
show,
isOpen,
onClose,
form,
setForm,
@@ -39,11 +39,11 @@ export default function BulkAttendanceModal({
toggleUser,
toggleAllUsers,
}: BulkAttendanceModalProps) {
useModalLock(show);
useModalLock(isOpen);
return (
<AnimatePresence>
{show && (
{isOpen && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
@@ -57,13 +57,21 @@ export default function BulkAttendanceModal({
/>
<motion.div
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 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 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
style={{
color: "var(--text-secondary)",

View File

@@ -14,6 +14,71 @@ interface ConfirmModalProps {
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({
isOpen,
onClose,
@@ -39,6 +104,9 @@ export default function ConfirmModal({
<div className="admin-modal-backdrop" onClick={onClose} />
<motion.div
className="admin-modal admin-confirm-modal"
role="dialog"
aria-modal="true"
aria-labelledby="confirm-modal-title"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
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-confirm-icon admin-confirm-icon-${type}`}>
<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>
<ConfirmIcon type={type} />
</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>
</div>
<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 {
label: ReactNode;
@@ -15,13 +21,22 @@ export default function FormField({
required,
style,
}: 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 (
<div className="admin-form-group" style={style}>
<label className="admin-form-label">
<label className="admin-form-label" htmlFor={childId}>
{label}
{required && <span className="admin-form-required"> *</span>}
</label>
{children}
{childWithId}
{error && <span className="admin-form-error">{error}</span>}
</div>
);

View File

@@ -1,5 +1,6 @@
import { useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useAlert } from "../context/AlertContext";
interface ConfirmationItem {
description: string;
@@ -33,6 +34,7 @@ export default function OrderConfirmationModal({
defaultVatRate,
applyVat,
}: OrderConfirmationModalProps) {
const alert = useAlert();
const [step, setStep] = useState<"choose" | "edit">("choose");
const [lang, setLang] = useState<string>("cs");
const [applyVatState, setApplyVatState] = useState(applyVat);
@@ -43,6 +45,9 @@ export default function OrderConfirmationModal({
setLoading(true);
try {
await onGenerate(lang, applyVatState, undefined);
} catch (err) {
console.error("Chyba při generování potvrzení:", err);
alert.error("Nepodařilo se vygenerovat potvrzení");
} finally {
setLoading(false);
setStep("choose");
@@ -54,6 +59,9 @@ export default function OrderConfirmationModal({
setLoading(true);
try {
await onGenerate(lang, applyVatState, items);
} catch (err) {
console.error("Chyba při generování potvrzení:", err);
alert.error("Nepodařilo se vygenerovat potvrzení");
} finally {
setLoading(false);
setStep("choose");
@@ -109,13 +117,19 @@ export default function OrderConfirmationModal({
className={
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 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">
<h2
id="order-confirmation-modal-title"
className="admin-modal-title"
>
Potvrzení objednávky {orderNumber}
</h2>
</div>

View File

@@ -36,13 +36,18 @@ export default function Pagination({
};
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-controls">
<button
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
className="admin-pagination-page"
aria-label="Předchozí stránka"
>
<svg
width="16"
@@ -65,6 +70,8 @@ export default function Pagination({
key={p}
onClick={() => onPageChange(p)}
className={`admin-pagination-page ${p === page ? "active" : ""}`}
aria-label={`Stránka ${p}`}
aria-current={p === page ? "page" : undefined}
>
{p}
</button>
@@ -74,6 +81,7 @@ export default function Pagination({
disabled={page >= total_pages}
onClick={() => onPageChange(page + 1)}
className="admin-pagination-page"
aria-label="Další stránka"
>
<svg
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 ConfirmModal from "./ConfirmModal";
import apiFetch from "../utils/api";
import { Skeleton } from "boneyard-js/react";
import ProjectFileManagerFixture from "../fixtures/ProjectFileManagerFixture";
const API_BASE = "/api/admin";
@@ -196,13 +200,11 @@ export default function ProjectFileManager({
hasNasFolder,
}: ProjectFileManagerProps) {
const alert = useAlert();
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const isCancelling = useRef(false);
const [items, setItems] = useState<FileItem[]>([]);
const [loading, setLoading] = useState(true);
const [currentPath, setCurrentPath] = useState("");
const [breadcrumb, setBreadcrumb] = useState<string[]>([""]);
const [fullPath, setFullPath] = useState("");
const [dragOver, setDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
@@ -216,59 +218,25 @@ export default function ProjectFileManager({
const [deleteTarget, setDeleteTarget] = useState<FileItem | null>(null);
const [deleting, setDeleting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const canManage = hasPermission("projects.files");
const fetchFiles = useCallback(
async (path = "", options: { ignore?: boolean } = {}) => {
setLoading(true);
setErrorMessage(null);
try {
const params = new URLSearchParams({ project_id: String(projectId) });
if (path) {
params.set("path", path);
}
const res = await apiFetch(`${API_BASE}/project-files?${params}`);
if (options.ignore) return;
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 {
data: filesData,
isPending: filesLoading,
error: filesError,
} = useQuery(projectFilesOptions(projectId, currentPath));
const items = filesData?.items ?? [];
const breadcrumb = filesData?.breadcrumb ?? [""];
const fullPath = filesData?.full_path ?? "";
const errorMessage = filesError
? filesError.message || "Nepodařilo se načíst soubory"
: null;
const navigateTo = (path: string) => {
setNewFolderMode(false);
setRenamingItem(null);
fetchFiles(path);
setCurrentPath(path);
};
const handleBreadcrumbClick = (index: number) => {
@@ -331,7 +299,9 @@ export default function ProjectFileManager({
? "Soubor byl nahrán"
: `Nahráno ${successCount} souborů`;
alert.success(msg);
fetchFiles(currentPath);
queryClient.invalidateQueries({
queryKey: ["projects", String(projectId), "files"],
});
}
if (errorMsg) {
alert.error(errorMsg);
@@ -382,7 +352,9 @@ export default function ProjectFileManager({
alert.success("Složka byla vytvořena");
setNewFolderMode(false);
setNewFolderName("");
fetchFiles(currentPath);
queryClient.invalidateQueries({
queryKey: ["projects", String(projectId), "files"],
});
} else {
alert.error(data.error || "Nepodařilo se vytvořit složku");
}
@@ -443,7 +415,9 @@ export default function ProjectFileManager({
? "Složka byla smazána"
: "Soubor byl smazán",
);
fetchFiles(currentPath);
queryClient.invalidateQueries({
queryKey: ["projects", String(projectId), "files"],
});
} else {
alert.error(data.error || "Nepodařilo se smazat");
}
@@ -478,7 +452,9 @@ export default function ProjectFileManager({
const data = await res.json();
if (data.success) {
alert.success("Přejmenováno");
fetchFiles(currentPath);
queryClient.invalidateQueries({
queryKey: ["projects", String(projectId), "files"],
});
} else {
alert.error(data.error || "Nepodařilo se přejmenovat");
}
@@ -494,32 +470,15 @@ export default function ProjectFileManager({
setRenameValue(item.name);
};
if (loading && items.length === 0 && !errorMessage) {
if (filesLoading && items.length === 0 && !errorMessage) {
return (
<div className="admin-card">
<div className="admin-card-body">
<h3 className="admin-card-title">Soubory</h3>
<div className="admin-skeleton" style={{ padding: 0, gap: "0.5rem" }}>
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-skeleton-row">
<div
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>
<Skeleton
name="project-file-manager"
loading={filesLoading && items.length === 0}
fixture={<ProjectFileManagerFixture />}
>
<div />
</Skeleton>
);
}
@@ -709,7 +668,7 @@ export default function ProjectFileManager({
</div>
)}
{items.length === 0 && !loading ? (
{items.length === 0 && !filesLoading ? (
<div className="fm-empty">
<svg
width="32"
@@ -768,10 +727,26 @@ export default function ProjectFileManager({
}}
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") handleRename(item);
if (e.key === "Escape") setRenamingItem(null);
if (e.key === "Enter") {
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

View File

@@ -68,7 +68,7 @@ interface ProjectLogRowProps {
export interface ShiftFormModalProps {
mode: "create" | "edit";
show: boolean;
isOpen: boolean;
onClose: () => void;
onSubmit: () => void;
form: ShiftFormData;
@@ -196,7 +196,7 @@ function ProjectLogRow({
export default function ShiftFormModal({
mode,
show,
isOpen,
onClose,
onSubmit,
form,
@@ -208,7 +208,7 @@ export default function ShiftFormModal({
onShiftDateChange,
editingRecord,
}: ShiftFormModalProps) {
useModalLock(show);
useModalLock(isOpen);
const isCreate = mode === "create";
const isWorkType = form.leave_type === "work";
@@ -240,7 +240,7 @@ export default function ShiftFormModal({
return (
<AnimatePresence>
{show && (
{isOpen && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
@@ -251,13 +251,16 @@ export default function ShiftFormModal({
<div className="admin-modal-backdrop" onClick={onClose} />
<motion.div
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 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">
<h2 id="shift-form-modal-title" className="admin-modal-title">
{isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
</h2>
{!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 { useAlert } from "../../context/AlertContext";
import ConfirmModal from "../ConfirmModal";
import useModalLock from "../../hooks/useModalLock";
import apiFetch from "../../utils/api";
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";
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 {
isOpen: boolean;
session: Session | null;
@@ -77,9 +67,10 @@ function getDeviceIcon(iconType?: string) {
export default function DashSessions() {
const alert = useAlert();
const queryClient = useQueryClient();
const [sessions, setSessions] = useState<Session[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(true);
const { data: sessions = [], isPending: sessionsLoading } =
useQuery(sessionsOptions());
const [deleteModal, setDeleteModal] = useState<DeleteModalState>({
isOpen: false,
session: null,
@@ -89,26 +80,6 @@ export default function DashSessions() {
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 () => {
if (!deleteModal.session) {
return;
@@ -122,7 +93,7 @@ export default function DashSessions() {
const data = await response.json();
if (data.success) {
setDeleteModal({ isOpen: false, session: null });
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
queryClient.invalidateQueries({ queryKey: ["sessions"] });
alert.success("Relace byla ukončena");
} else {
alert.error(data.error || "Nepodařilo se ukončit relaci");
@@ -143,7 +114,7 @@ export default function DashSessions() {
const data = await response.json();
if (data.success) {
setDeleteAllModal(false);
setSessions((prev) => prev.filter((s) => s.is_current));
queryClient.invalidateQueries({ queryKey: ["sessions"] });
alert.success(data.message || "Ostatní relace byly ukončeny");
} else {
alert.error(data.error || "Nepodařilo se ukončit relace");
@@ -183,29 +154,13 @@ export default function DashSessions() {
)}
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
{sessionsLoading && (
<div
className="admin-skeleton"
style={{ padding: "1rem", gap: "1rem" }}
<Skeleton
name="dash-sessions"
loading={sessionsLoading}
fixture={<DashSessionsFixture />}
>
{[0, 1, 2].map((i) => (
<div key={i} className="admin-skeleton-row">
<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 && (
<>
{sessions.length === 0 && (
<div
className="text-secondary"
style={{
@@ -217,7 +172,7 @@ export default function DashSessions() {
Žádné aktivní relace
</div>
)}
{!sessionsLoading && sessions.length > 0 && (
{sessions.length > 0 && (
<div className="dash-sessions-list">
{sessions.map((session) => (
<div
@@ -275,6 +230,8 @@ export default function DashSessions() {
))}
</div>
)}
</>
</Skeleton>
</div>
</motion.div>

View File

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

View File

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

View File

@@ -224,11 +224,20 @@ function computeUserTotals(
// 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 {
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)
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>';
}
@@ -255,11 +264,11 @@ function buildProjectLogsHtml(record: Record<string, any>): string {
h = 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("");
}
return record.project_name || "—";
return escapeHtml(record.project_name || "—");
}
function buildLeaveSummaryHtml(
@@ -268,15 +277,15 @@ function buildLeaveSummaryHtml(
printData: Record<string, any>,
): string {
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)
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)
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)
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)
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>`;
}
@@ -299,17 +308,17 @@ function buildUserSectionHtml(
const breakCell =
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>
<td>${formatDate(record.shift_date)}</td>
<td><span class="leave-badge ${getLeaveTypeBadgeClass(leaveType)}">${getLeaveTypeName(leaveType)}</span></td>
<td class="text-center">${isLeave ? "—" : formatTimeOrDatetimePrint(record.arrival_time, record.shift_date)}</td>
<td>${escapeHtml(formatDate(record.shift_date))}</td>
<td><span class="leave-badge ${escapeHtml(getLeaveTypeBadgeClass(leaveType))}">${escapeHtml(getLeaveTypeName(leaveType))}</span></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">${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 style="font-size:8px">${buildProjectLogsHtml(record)}</td>
<td>${record.notes || ""}</td>
<td>${escapeHtml(record.notes || "")}</td>
</tr>`;
})
.join("");
@@ -318,15 +327,15 @@ function buildUserSectionHtml(
userData.fund !== null
? `<tr>
<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>
</tr>`
: "";
return `<div class="user-section">
<div class="user-header">
<h3>${userData.name}</h3>
<span class="total">Odpracováno: ${formatMinutes(userData.minutes)} h</span>
<h3>${escapeHtml(userData.name)}</h3>
<span class="total">Odpracováno: ${escapeHtml(formatMinutes(userData.minutes))} h</span>
</div>
${leaveHtml}
<table>
@@ -344,7 +353,7 @@ function buildUserSectionHtml(
<tfoot>
<tr>
<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>
</tr>
${fundRow}
@@ -365,7 +374,7 @@ function buildPrintHtml(
<head>
<meta charset="UTF-8">
<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>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
@@ -428,11 +437,11 @@ function buildPrintHtml(
<img src="/api/admin/company-settings/logo?variant=light" alt="" class="print-logo" />
<div class="print-header-text">
<h1>EVIDENCE DOCHÁZKY</h1>
<div class="company">${companyName}</div>
<div class="company">${escapeHtml(companyName)}</div>
</div>
</div>
<div class="print-header-right">
<div class="period">${pData.month_name}</div>
<div class="period">${escapeHtml(pData.month_name)}</div>
${filterNote}
<div class="generated">Vygenerováno: ${new Date().toLocaleString("cs-CZ")}</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>'
: "";
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(
pData,
@@ -1051,7 +1060,9 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
printWindow.document.open();
printWindow.document.write(bodyContent);
printWindow.document.close();
printWindow.onload = () => printWindow.print();
printWindow.addEventListener("load", () => printWindow.print(), {
once: true,
});
}
}
} catch {

View File

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

View File

@@ -1,14 +1,16 @@
import { useEffect } from "react";
let activeLocks = 0;
export default function useModalLock(isOpen: boolean): void {
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
if (activeLocks === 0) document.body.style.overflow = "hidden";
activeLocks++;
return () => {
document.body.style.overflow = "";
activeLocks = Math.max(0, activeLocks - 1);
if (activeLocks === 0) document.body.style.overflow = "";
};
}
}, [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 {
sort: string;
@@ -13,10 +13,10 @@ export default function useTableSort(
sort: defaultSort,
order: defaultOrder,
});
const userClicked = useRef(false);
const [userClicked, setUserClicked] = useState(false);
const handleSort = useCallback((column: string) => {
userClicked.current = true;
setUserClicked(true);
setState((prev) => {
if (prev.sort === column) {
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 };
}

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