2 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
110 changed files with 26417 additions and 10160 deletions

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"
]
}

220
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "1.5.3", "version": "1.5.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "app-ts", "name": "app-ts",
"version": "1.5.3", "version": "1.5.5",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@@ -19,8 +19,10 @@
"@fastify/rate-limit": "^10.3.0", "@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^9.0.0", "@fastify/static": "^9.0.0",
"@prisma/client": "^6.19.2", "@prisma/client": "^6.19.2",
"@tanstack/react-query": "^5.100.5",
"@types/jsdom": "^28.0.1", "@types/jsdom": "^28.0.1",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"boneyard-js": "^1.8.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dompurify": "^3.3.3", "dompurify": "^3.3.3",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
@@ -148,6 +150,13 @@
"specificity": "bin/cli.js" "specificity": "bin/cli.js"
} }
}, },
"node_modules/@chenglou/pretext": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/@chenglou/pretext/-/pretext-0.0.5.tgz",
"integrity": "sha512-A8GZN10REdFGsyuiUgLV8jjPDDFMg5GmgxGWV0I3igxBOnzj+jgz2VMmVD7g+SFyoctfeqHFxbNatKSzVRWtRg==",
"license": "MIT",
"optional": true
},
"node_modules/@csstools/color-helpers": { "node_modules/@csstools/color-helpers": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
@@ -353,7 +362,6 @@
"version": "1.9.0", "version": "1.9.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz",
"integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -365,7 +373,6 @@
"version": "1.9.0", "version": "1.9.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz",
"integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -376,7 +383,6 @@
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
"integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -390,7 +396,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -407,7 +412,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -424,7 +428,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -441,7 +444,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -458,7 +460,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -475,7 +476,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -492,7 +492,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -509,7 +508,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -526,7 +524,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -543,7 +540,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -560,7 +556,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -577,7 +572,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -594,7 +588,6 @@
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -611,7 +604,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -628,7 +620,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -645,7 +636,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -662,7 +652,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -679,7 +668,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -696,7 +684,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -713,7 +700,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -730,7 +716,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -747,7 +732,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -764,7 +748,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -781,7 +764,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -798,7 +780,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -815,7 +796,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1195,7 +1175,6 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
"integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -1224,7 +1203,7 @@
"version": "0.115.0", "version": "0.115.0",
"resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz",
"integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==", "integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
@@ -1234,7 +1213,7 @@
"version": "0.115.0", "version": "0.115.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz",
"integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==", "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/Boshen" "url": "https://github.com/sponsors/Boshen"
@@ -1385,7 +1364,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1402,7 +1380,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1419,7 +1396,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1436,7 +1412,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1453,7 +1428,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1470,7 +1444,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1487,7 +1460,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1504,7 +1476,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1521,7 +1492,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1538,7 +1508,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1555,7 +1524,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1572,7 +1540,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1589,7 +1556,6 @@
"cpu": [ "cpu": [
"wasm32" "wasm32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -1606,7 +1572,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1623,7 +1588,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1646,6 +1610,32 @@
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tanstack/query-core": {
"version": "5.100.5",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.5.tgz",
"integrity": "sha512-t20KrhKkf0HXzqQkPbJ5erhFesup68BAbwFgYmTrS7bxMF7O5MdmL8jUkik4thsG7Hg00fblz30h6yF1d5TxGg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.100.5",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.5.tgz",
"integrity": "sha512-aNwj1mi2v2bQ9IxkyR1grLOUkv3BYWoykHy9KDyLNbjC3tsahbOHJibK+Wjtr1wRhG59/AvJhiJG5OlthaCgJA==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.100.5"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tokenizer/token": { "node_modules/@tokenizer/token": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
@@ -1662,7 +1652,6 @@
"version": "0.10.1", "version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -2332,6 +2321,53 @@
"require-from-string": "^2.0.2" "require-from-string": "^2.0.2"
} }
}, },
"node_modules/boneyard-js": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/boneyard-js/-/boneyard-js-1.8.1.tgz",
"integrity": "sha512-tnffA34xFgGSKpQ6QXebalkz2g/rMRAUIh3S0OBoPTK+OgYR3QEQvD3fHGKNQDbCTjn3W7dgNfiBhZGpnqlcNg==",
"license": "MIT",
"dependencies": {
"playwright": "^1.58.2"
},
"bin": {
"boneyard-js": "bin/cli.js"
},
"optionalDependencies": {
"@chenglou/pretext": "^0.0.5"
},
"peerDependencies": {
"@angular/core": ">=14",
"preact": ">=10",
"react": ">=18",
"react-native": ">=0.71",
"svelte": ">=5.29",
"vite": ">=5",
"vue": ">=3"
},
"peerDependenciesMeta": {
"@angular/core": {
"optional": true
},
"preact": {
"optional": true
},
"react": {
"optional": true
},
"react-native": {
"optional": true
},
"svelte": {
"optional": true
},
"vite": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.5", "version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
@@ -2889,7 +2925,7 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -3087,7 +3123,7 @@
"version": "0.27.4", "version": "0.27.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
"dev": true, "devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@@ -3444,7 +3480,7 @@
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"
@@ -3568,7 +3604,6 @@
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -3656,7 +3691,7 @@
"version": "4.13.6", "version": "4.13.6",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"resolve-pkg-maps": "^1.0.0" "resolve-pkg-maps": "^1.0.0"
@@ -4135,7 +4170,7 @@
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true, "devOptional": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"dependencies": { "dependencies": {
"detect-libc": "^2.0.3" "detect-libc": "^2.0.3"
@@ -4168,7 +4203,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4189,7 +4223,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4210,7 +4243,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4231,7 +4263,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4252,7 +4283,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4273,7 +4303,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4294,7 +4323,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4315,7 +4343,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4336,7 +4363,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4357,7 +4383,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4378,7 +4403,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4618,7 +4642,7 @@
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true, "devOptional": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -4931,7 +4955,7 @@
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -4988,6 +5012,50 @@
"pathe": "^2.0.3" "pathe": "^2.0.3"
} }
}, },
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pngjs": { "node_modules/pngjs": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
@@ -5001,7 +5069,7 @@
"version": "8.5.8", "version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"dev": true, "devOptional": true,
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -5521,7 +5589,7 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
@@ -5556,7 +5624,7 @@
"version": "1.0.0-rc.9", "version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz",
"integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==", "integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@oxc-project/types": "=0.115.0", "@oxc-project/types": "=0.115.0",
@@ -5590,7 +5658,7 @@
"version": "1.0.0-rc.9", "version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz",
"integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==", "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/rxjs": { "node_modules/rxjs": {
@@ -6159,7 +6227,7 @@
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -6279,7 +6347,7 @@
"version": "4.21.0", "version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "~0.27.0", "esbuild": "~0.27.0",
@@ -6334,7 +6402,7 @@
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz",
"integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==", "integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@oxc-project/runtime": "0.115.0", "@oxc-project/runtime": "0.115.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "1.5.5", "version": "1.5.6",
"description": "", "description": "",
"main": "dist/server.js", "main": "dist/server.js",
"scripts": { "scripts": {
@@ -17,7 +17,8 @@
"db:push": "prisma db push", "db:push": "prisma db push",
"db:studio": "prisma studio", "db:studio": "prisma studio",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest" "test:watch": "vitest",
"bones": "boneyard-js build http://localhost:3000"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
@@ -34,8 +35,10 @@
"@fastify/rate-limit": "^10.3.0", "@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^9.0.0", "@fastify/static": "^9.0.0",
"@prisma/client": "^6.19.2", "@prisma/client": "^6.19.2",
"@tanstack/react-query": "^5.100.5",
"@types/jsdom": "^28.0.1", "@types/jsdom": "^28.0.1",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"boneyard-js": "^1.8.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dompurify": "^3.3.3", "dompurify": "^3.3.3",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",

View File

@@ -1,7 +1,9 @@
import { lazy, Suspense } from "react"; import { lazy, Suspense } from "react";
import { Routes, Route } from "react-router-dom"; import { Routes, Route } from "react-router-dom";
import { QueryClientProvider } from "@tanstack/react-query";
import { AuthProvider } from "./context/AuthContext"; import { AuthProvider } from "./context/AuthContext";
import { AlertProvider } from "./context/AlertContext"; import { AlertProvider } from "./context/AlertContext";
import { queryClient } from "./lib/queryClient";
import ErrorBoundary from "./components/ErrorBoundary"; import ErrorBoundary from "./components/ErrorBoundary";
import AdminLayout from "./components/AdminLayout"; import AdminLayout from "./components/AdminLayout";
import AlertContainer from "./components/AlertContainer"; import AlertContainer from "./components/AlertContainer";
@@ -14,8 +16,8 @@ import "./buttons.css";
import "./layout.css"; import "./layout.css";
import "./components.css"; import "./components.css";
import "./tables.css"; import "./tables.css";
import "./skeleton.css";
import "./datepicker.css"; import "./datepicker.css";
import "./bones/registry";
import "./filemanager.css"; import "./filemanager.css";
import "./pagination.css"; import "./pagination.css";
import "./responsive.css"; import "./responsive.css";
@@ -57,6 +59,7 @@ export default function AdminApp() {
return ( return (
<AuthProvider> <AuthProvider>
<AlertProvider> <AlertProvider>
<QueryClientProvider client={queryClient}>
<AlertContainer /> <AlertContainer />
<ErrorBoundary> <ErrorBoundary>
<Suspense <Suspense
@@ -76,13 +79,22 @@ export default function AdminApp() {
path="attendance/history" path="attendance/history"
element={<AttendanceHistory />} element={<AttendanceHistory />}
/> />
<Route path="attendance/admin" element={<AttendanceAdmin />} /> <Route
path="attendance/admin"
element={<AttendanceAdmin />}
/>
<Route <Route
path="attendance/balances" path="attendance/balances"
element={<AttendanceBalances />} element={<AttendanceBalances />}
/> />
<Route path="attendance/requests" element={<LeaveRequests />} /> <Route
<Route path="attendance/approval" element={<LeaveApproval />} /> path="attendance/requests"
element={<LeaveRequests />}
/>
<Route
path="attendance/approval"
element={<LeaveApproval />}
/>
<Route <Route
path="attendance/create" path="attendance/create"
element={<AttendanceCreate />} element={<AttendanceCreate />}
@@ -98,8 +110,14 @@ export default function AdminApp() {
<Route path="offers" element={<Offers />} /> <Route path="offers" element={<Offers />} />
<Route path="offers/new" element={<OfferDetail />} /> <Route path="offers/new" element={<OfferDetail />} />
<Route path="offers/:id" element={<OfferDetail />} /> <Route path="offers/:id" element={<OfferDetail />} />
<Route path="offers/customers" element={<OffersCustomers />} /> <Route
<Route path="offers/templates" element={<OffersTemplates />} /> path="offers/customers"
element={<OffersCustomers />}
/>
<Route
path="offers/templates"
element={<OffersTemplates />}
/>
<Route path="orders" element={<Orders />} /> <Route path="orders" element={<Orders />} />
<Route path="orders/:id" element={<OrderDetail />} /> <Route path="orders/:id" element={<OrderDetail />} />
<Route path="projects" element={<Projects />} /> <Route path="projects" element={<Projects />} />
@@ -114,6 +132,7 @@ export default function AdminApp() {
</Routes> </Routes>
</Suspense> </Suspense>
</ErrorBoundary> </ErrorBoundary>
</QueryClientProvider>
</AlertProvider> </AlertProvider>
</AuthProvider> </AuthProvider>
); );

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,27 +1,17 @@
import { useState, useEffect, useCallback } from "react"; import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { useAlert } from "../../context/AlertContext"; import { useAlert } from "../../context/AlertContext";
import ConfirmModal from "../ConfirmModal"; import ConfirmModal from "../ConfirmModal";
import useModalLock from "../../hooks/useModalLock"; import useModalLock from "../../hooks/useModalLock";
import apiFetch from "../../utils/api"; import apiFetch from "../../utils/api";
import { formatSessionDate } from "../../utils/dashboardHelpers"; import { formatSessionDate } from "../../utils/dashboardHelpers";
import { sessionsOptions, type Session } from "../../lib/queries/dashboard";
import { Skeleton } from "boneyard-js/react";
import DashSessionsFixture from "../../fixtures/DashSessionsFixture";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
interface DeviceInfo {
icon?: string;
browser?: string;
os?: string;
}
interface Session {
id: number | string;
is_current: boolean;
device_info?: DeviceInfo;
ip_address: string;
created_at: string;
}
interface DeleteModalState { interface DeleteModalState {
isOpen: boolean; isOpen: boolean;
session: Session | null; session: Session | null;
@@ -77,9 +67,10 @@ function getDeviceIcon(iconType?: string) {
export default function DashSessions() { export default function DashSessions() {
const alert = useAlert(); const alert = useAlert();
const queryClient = useQueryClient();
const [sessions, setSessions] = useState<Session[]>([]); const { data: sessions = [], isPending: sessionsLoading } =
const [sessionsLoading, setSessionsLoading] = useState(true); useQuery(sessionsOptions());
const [deleteModal, setDeleteModal] = useState<DeleteModalState>({ const [deleteModal, setDeleteModal] = useState<DeleteModalState>({
isOpen: false, isOpen: false,
session: null, session: null,
@@ -89,26 +80,6 @@ export default function DashSessions() {
useModalLock(deleteAllModal); useModalLock(deleteAllModal);
const fetchSessions = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/sessions`);
const data = await response.json();
if (data.success) {
setSessions(
Array.isArray(data.data) ? data.data : data.data?.sessions || [],
);
}
} catch {
// session fetch failed silently
} finally {
setSessionsLoading(false);
}
}, []);
useEffect(() => {
fetchSessions();
}, [fetchSessions]);
const handleDeleteSession = async () => { const handleDeleteSession = async () => {
if (!deleteModal.session) { if (!deleteModal.session) {
return; return;
@@ -122,7 +93,7 @@ export default function DashSessions() {
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
setDeleteModal({ isOpen: false, session: null }); setDeleteModal({ isOpen: false, session: null });
setSessions((prev) => prev.filter((s) => s.id !== sessionId)); queryClient.invalidateQueries({ queryKey: ["sessions"] });
alert.success("Relace byla ukončena"); alert.success("Relace byla ukončena");
} else { } else {
alert.error(data.error || "Nepodařilo se ukončit relaci"); alert.error(data.error || "Nepodařilo se ukončit relaci");
@@ -143,7 +114,7 @@ export default function DashSessions() {
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
setDeleteAllModal(false); setDeleteAllModal(false);
setSessions((prev) => prev.filter((s) => s.is_current)); queryClient.invalidateQueries({ queryKey: ["sessions"] });
alert.success(data.message || "Ostatní relace byly ukončeny"); alert.success(data.message || "Ostatní relace byly ukončeny");
} else { } else {
alert.error(data.error || "Nepodařilo se ukončit relace"); alert.error(data.error || "Nepodařilo se ukončit relace");
@@ -183,29 +154,13 @@ export default function DashSessions() {
)} )}
</div> </div>
<div className="admin-card-body" style={{ padding: 0 }}> <div className="admin-card-body" style={{ padding: 0 }}>
{sessionsLoading && ( <Skeleton
<div name="dash-sessions"
className="admin-skeleton" loading={sessionsLoading}
style={{ padding: "1rem", gap: "1rem" }} fixture={<DashSessionsFixture />}
> >
{[0, 1, 2].map((i) => ( <>
<div key={i} className="admin-skeleton-row"> {sessions.length === 0 && (
<div className="admin-skeleton-line circle" />
<div className="flex-1">
<div
className="admin-skeleton-line w-1/2"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-1/3"
style={{ height: "10px" }}
/>
</div>
</div>
))}
</div>
)}
{!sessionsLoading && sessions.length === 0 && (
<div <div
className="text-secondary" className="text-secondary"
style={{ style={{
@@ -217,7 +172,7 @@ export default function DashSessions() {
Žádné aktivní relace Žádné aktivní relace
</div> </div>
)} )}
{!sessionsLoading && sessions.length > 0 && ( {sessions.length > 0 && (
<div className="dash-sessions-list"> <div className="dash-sessions-list">
{sessions.map((session) => ( {sessions.map((session) => (
<div <div
@@ -275,6 +230,8 @@ export default function DashSessions() {
))} ))}
</div> </div>
)} )}
</>
</Skeleton>
</div> </div>
</motion.div> </motion.div>

View File

@@ -90,6 +90,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const cachedUserRef = useRef<User | null>(null); const cachedUserRef = useRef<User | null>(null);
const sessionFetchedRef = useRef(false); const sessionFetchedRef = useRef(false);
const silentRefreshInFlightRef = useRef<Promise<boolean> | null>(null); const silentRefreshInFlightRef = useRef<Promise<boolean> | null>(null);
const hadValidSessionRef = useRef(false);
const [user, setUser] = useState<User | null>(cachedUserRef.current); const [user, setUser] = useState<User | null>(cachedUserRef.current);
const [loading, setLoading] = useState(!sessionFetchedRef.current); const [loading, setLoading] = useState(!sessionFetchedRef.current);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -138,13 +139,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (data.success && data.data?.access_token) { if (data.success && data.data?.access_token) {
setAccessTokenFn(data.data.access_token, data.data.expires_in); setAccessTokenFn(data.data.access_token, data.data.expires_in);
setUser(mapUser(data.data.user)); setUser(mapUser(data.data.user));
hadValidSessionRef.current = true;
return true; return true;
} }
accessTokenRef.current = null; accessTokenRef.current = null;
tokenExpiresAtRef.current = null; tokenExpiresAtRef.current = null;
setUser(null); setUser(null);
cachedUserRef.current = null; cachedUserRef.current = null;
setSessionExpired(); if (hadValidSessionRef.current) setSessionExpired();
return false; return false;
} catch { } catch {
// Network error — don't kick the user out, just return false // Network error — don't kick the user out, just return false
@@ -178,6 +180,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (data.data.access_token) setAccessTokenFn(data.data.access_token); if (data.data.access_token) setAccessTokenFn(data.data.access_token);
setUser(mapUser(data.data.user)); setUser(mapUser(data.data.user));
cachedUserRef.current = mapUser(data.data.user); cachedUserRef.current = mapUser(data.data.user);
hadValidSessionRef.current = true;
return true; return true;
} }
} }
@@ -233,6 +236,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(mapUser(data.data.user)); setUser(mapUser(data.data.user));
cachedUserRef.current = mapUser(data.data.user); cachedUserRef.current = mapUser(data.data.user);
sessionFetchedRef.current = true; sessionFetchedRef.current = true;
hadValidSessionRef.current = true;
return { success: true }; return { success: true };
} }
setError(data.error); setError(data.error);
@@ -273,6 +277,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(mapUser(data.data.user)); setUser(mapUser(data.data.user));
cachedUserRef.current = mapUser(data.data.user); cachedUserRef.current = mapUser(data.data.user);
sessionFetchedRef.current = true; sessionFetchedRef.current = true;
hadValidSessionRef.current = true;
return { success: true }; return { success: true };
} }
setError(data.error); setError(data.error);
@@ -302,6 +307,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(null); setUser(null);
cachedUserRef.current = null; cachedUserRef.current = null;
sessionFetchedRef.current = false; sessionFetchedRef.current = false;
hadValidSessionRef.current = false;
if (refreshTimeoutRef.current) { if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current); clearTimeout(refreshTimeoutRef.current);
refreshTimeoutRef.current = null; refreshTimeoutRef.current = null;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

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

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
@@ -14,6 +15,9 @@ import {
import FormField from "../components/FormField"; import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import apiFetch from "../utils/api"; import apiFetch from "../utils/api";
import { jsonQuery } from "../lib/apiAdapter";
import { Skeleton } from "boneyard-js/react";
import AttendanceFixture from "../fixtures/AttendanceFixture";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
@@ -92,22 +96,20 @@ function getFundBarBackground(fund: MonthlyFund) {
export default function Attendance() { export default function Attendance() {
const alert = useAlert(); const alert = useAlert();
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true); const queryClient = useQueryClient();
const [submitting, setSubmitting] = useState(false);
const [data, setData] = useState<AttendanceData>({ const statusQuery = useQuery({
ongoing_shift: null, queryKey: ["attendance", "status"],
today_shifts: [], queryFn: () => jsonQuery<AttendanceData>("/api/admin/attendance/status"),
date: "",
leave_balance: {
vacation_total: 160,
vacation_used: 0,
vacation_remaining: 160,
sick_used: 0,
},
monthly_fund: null,
project_logs: [],
active_project_id: null,
}); });
const projectsQuery = useQuery({
queryKey: ["attendance", "projects"],
queryFn: () =>
jsonQuery<Project[]>("/api/admin/attendance?action=projects"),
});
const [submitting, setSubmitting] = useState(false);
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false); const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
const [leaveForm, setLeaveForm] = useState({ const [leaveForm, setLeaveForm] = useState({
leave_type: "vacation", leave_type: "vacation",
@@ -117,10 +119,7 @@ export default function Attendance() {
}); });
const [requestSubmitting, setRequestSubmitting] = useState(false); const [requestSubmitting, setRequestSubmitting] = useState(false);
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [projects, setProjects] = useState<Project[]>([]);
const [switchingProject, setSwitchingProject] = useState(false); const [switchingProject, setSwitchingProject] = useState(false);
const [projectLogs, setProjectLogs] = useState<ProjectLog[]>([]);
const [activeProjectId, setActiveProjectId] = useState<number | null>(null);
const [gpsConfirm, setGpsConfirm] = useState<{ const [gpsConfirm, setGpsConfirm] = useState<{
isOpen: boolean; isOpen: boolean;
action: string | null; action: string | null;
@@ -139,45 +138,12 @@ export default function Attendance() {
}; };
}, []); }, []);
const fetchData = useCallback(async () => { // Sync notes from query data when the shift changes
try {
const response = await apiFetch(`${API_BASE}/attendance/status`);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setData(result.data);
setNotes(result.data.ongoing_shift?.notes || "");
setProjectLogs(result.data.project_logs || []);
setActiveProjectId(result.data.active_project_id || null);
}
} catch {
alert.error("Nepodařilo se načíst data");
} finally {
setLoading(false);
}
}, [alert]);
useEffect(() => { useEffect(() => {
fetchData(); if (statusQuery.data) {
}, [fetchData]); setNotes(statusQuery.data.ongoing_shift?.notes || "");
useEffect(() => {
const loadProjects = async () => {
try {
const response = await apiFetch(
`${API_BASE}/attendance?action=projects`,
);
const result = await response.json();
if (result.success) {
const items = Array.isArray(result.data) ? result.data : [];
setProjects(items);
} }
} catch { }, [statusQuery.data]);
// silent - projects are supplementary
}
};
loadProjects();
}, []);
useModalLock(isLeaveModalOpen); useModalLock(isLeaveModalOpen);
@@ -277,7 +243,9 @@ export default function Attendance() {
setSubmitting(false); setSubmitting(false);
if (result.success) { if (result.success) {
await fetchData(); await queryClient.invalidateQueries({
queryKey: ["attendance", "status"],
});
punchTimeoutRef.current = setTimeout(() => { punchTimeoutRef.current = setTimeout(() => {
alert.success(result.data?.message || result.message || "Uloženo"); alert.success(result.data?.message || result.message || "Uloženo");
}, 300); }, 300);
@@ -302,7 +270,9 @@ export default function Attendance() {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
await fetchData(); await queryClient.invalidateQueries({
queryKey: ["attendance", "status"],
});
alert.success( alert.success(
result.data?.message || result.message || "Přestávka zaznamenána", result.data?.message || result.message || "Přestávka zaznamenána",
); );
@@ -348,7 +318,9 @@ export default function Attendance() {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
await fetchData(); await queryClient.invalidateQueries({
queryKey: ["attendance", "status"],
});
alert.success( alert.success(
result.data?.message || result.message || "Projekt přepnut", result.data?.message || result.message || "Projekt přepnut",
); );
@@ -390,7 +362,9 @@ export default function Attendance() {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
setIsLeaveModalOpen(false); setIsLeaveModalOpen(false);
await fetchData(); await queryClient.invalidateQueries({
queryKey: ["attendance", "status"],
});
await new Promise((resolve) => setTimeout(resolve, 300)); await new Promise((resolve) => setTimeout(resolve, 300));
alert.success( alert.success(
result.data?.message || result.message || "Žádost odeslána", result.data?.message || result.message || "Žádost odeslána",
@@ -411,103 +385,15 @@ export default function Attendance() {
} }
}; };
if (loading) { if (!statusQuery.data) {
return ( return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}> <Skeleton
<div name="attendance"
className="admin-skeleton-row" loading={statusQuery.isPending}
style={{ justifyContent: "space-between" }} fixture={<AttendanceFixture />}
> >
<div> <div />
<div </Skeleton>
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
</div>
<div style={{ display: "flex", gap: "1.5rem" }}>
<div className="admin-card" style={{ flex: 2 }}>
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
<div
className="admin-skeleton-line h-8"
style={{ width: "120px", marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line h-10"
style={{ width: "180px" }}
/>
<div className="admin-skeleton-row">
<div style={{ flex: 1 }}>
<div
className="admin-skeleton-line w-1/3"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div style={{ flex: 1 }}>
<div
className="admin-skeleton-line w-1/3"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
</div>
<div
className="admin-skeleton-line h-10"
style={{ width: "100%", borderRadius: "8px" }}
/>
</div>
</div>
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
gap: "1rem",
}}
>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1rem" }}>
<div
className="admin-skeleton-line w-1/3"
style={{ marginBottom: "0.25rem" }}
/>
<div
className="admin-skeleton-line h-8"
style={{ width: "80px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "100%", height: "6px", borderRadius: "3px" }}
/>
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1rem" }}>
<div
className="admin-skeleton-line w-1/3"
style={{ marginBottom: "0.25rem" }}
/>
<div
className="admin-skeleton-line h-8"
style={{ width: "80px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "100%", height: "6px", borderRadius: "3px" }}
/>
</div>
</div>
</div>
</div>
</div>
); );
} }
@@ -515,7 +401,11 @@ export default function Attendance() {
ongoing_shift: ongoingShift, ongoing_shift: ongoingShift,
today_shifts: todayShifts, today_shifts: todayShifts,
leave_balance: leaveBalance, leave_balance: leaveBalance,
} = data; } = statusQuery.data;
const data = statusQuery.data;
const projects = projectsQuery.data ?? [];
const projectLogs = data.project_logs ?? [];
const activeProjectId = data.active_project_id ?? null;
const isOngoingShift = ongoingShift && !ongoingShift.departure_time; const isOngoingShift = ongoingShift && !ongoingShift.departure_time;
const completedToday = todayShifts.filter((s) => s.departure_time); const completedToday = todayShifts.filter((s) => s.departure_time);
const vacationDaysRemaining = Math.floor(leaveBalance.vacation_remaining / 8); const vacationDaysRemaining = Math.floor(leaveBalance.vacation_remaining / 8);

View File

@@ -11,6 +11,8 @@ import useModalLock from "../hooks/useModalLock";
import useAttendanceAdmin from "../hooks/useAttendanceAdmin"; import useAttendanceAdmin from "../hooks/useAttendanceAdmin";
import FormField from "../components/FormField"; import FormField from "../components/FormField";
import { formatMinutes } from "../utils/attendanceHelpers"; import { formatMinutes } from "../utils/attendanceHelpers";
import { Skeleton } from "boneyard-js/react";
import AttendanceAdminFixture from "../fixtures/AttendanceAdminFixture";
interface UserTotalData { interface UserTotalData {
name: string; name: string;
@@ -95,84 +97,13 @@ export default function AttendanceAdmin() {
if (isInitialLoad) { if (isInitialLoad) {
return ( return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}> <Skeleton
<div name="attendance-admin"
className="admin-skeleton-row" loading={isInitialLoad}
style={{ justifyContent: "space-between" }} fixture={<AttendanceAdminFixture />}
> >
<div> <div />
<div </Skeleton>
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
</div>
<div className="admin-skeleton-row" style={{ gap: "0.5rem" }}>
<div
className="admin-skeleton-line h-10"
style={{ width: "120px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-10"
style={{ width: "120px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-10"
style={{ width: "140px", borderRadius: "8px" }}
/>
</div>
</div>
<div className="admin-card">
<div
className="admin-skeleton"
style={{ gap: "0.75rem", padding: "1rem" }}
>
<div className="admin-skeleton-row">
<div
className="admin-skeleton-line h-10"
style={{ flex: 1, borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-10"
style={{ flex: 1, borderRadius: "8px" }}
/>
</div>
</div>
</div>
<div className="admin-grid admin-grid-3">
{[0, 1, 2].map((i) => (
<div key={i} className="admin-card">
<div className="admin-card-body">
<div className="admin-skeleton" style={{ gap: "0.75rem" }}>
<div className="admin-skeleton-line w-1/2" />
<div
className="admin-skeleton-line h-8"
style={{ width: "80px" }}
/>
<div
className="admin-skeleton-line w-1/3"
style={{ height: "10px" }}
/>
<div
className="admin-skeleton-line w-full"
style={{ height: "4px" }}
/>
</div>
</div>
</div>
))}
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from "react"; import { useState } from "react";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
@@ -6,8 +6,16 @@ import { motion, AnimatePresence } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal"; import ConfirmModal from "../components/ConfirmModal";
import useModalLock from "../hooks/useModalLock"; import useModalLock from "../hooks/useModalLock";
import FormField from "../components/FormField"; import FormField from "../components/FormField";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
attendanceBalancesOptions,
attendanceWorkFundOptions,
attendanceProjectReportOptions,
} from "../lib/queries/attendance";
import apiFetch from "../utils/api"; import apiFetch from "../utils/api";
import { Skeleton } from "boneyard-js/react";
import AttendanceBalancesFixture from "../fixtures/AttendanceBalancesFixture";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
interface BalanceEntry { interface BalanceEntry {
@@ -134,23 +142,20 @@ const getProgressBackground = (
export default function AttendanceBalances() { export default function AttendanceBalances() {
const alert = useAlert(); const alert = useAlert();
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true); const queryClient = useQueryClient();
const [year, setYear] = useState(new Date().getFullYear()); const [year, setYear] = useState(new Date().getFullYear());
const [data, setData] = useState<BalancesData>({ const { data: balancesRaw, isPending: balancesPending } = useQuery(
users: [], attendanceBalancesOptions(year),
balances: {}, );
}); const { data: fundRaw, isPending: fundPending } = useQuery(
attendanceWorkFundOptions(year),
const [fundLoading, setFundLoading] = useState(true); );
const [fundData, setFundData] = useState<FundData>({ const { data: projectRaw, isPending: projectPending } = useQuery(
months: {}, attendanceProjectReportOptions(year),
holidays: [], );
users: [], const balancesData = balancesRaw as BalancesData | undefined;
balances: {}, const fundData = fundRaw as FundData | undefined;
}); const projectData = projectRaw as ProjectData | undefined;
const [projectLoading, setProjectLoading] = useState(true);
const [projectData, setProjectData] = useState<ProjectData>({ months: {} });
const [showEditModal, setShowEditModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false);
const [editingUser, setEditingUser] = useState<{ const [editingUser, setEditingUser] = useState<{
@@ -169,67 +174,6 @@ export default function AttendanceBalances() {
userName: string; userName: string;
}>({ show: false, userId: null, userName: "" }); }>({ show: false, userId: null, userName: "" });
const fetchData = useCallback(
async (showLoading = true) => {
if (showLoading) setLoading(true);
try {
const response = await apiFetch(
`${API_BASE}/attendance?action=balances&year=${year}`,
);
const result = await response.json();
if (result.success) {
setData(result.data);
}
} catch {
alert.error("Nepodařilo se načíst data");
} finally {
if (showLoading) setLoading(false);
}
},
[year, alert],
);
const fetchFundData = useCallback(async () => {
setFundLoading(true);
try {
const response = await apiFetch(
`${API_BASE}/attendance?action=workfund&year=${year}`,
);
const result = await response.json();
if (result.success) {
setFundData(result.data);
}
} catch {
// silent - fund data is supplementary
} finally {
setFundLoading(false);
}
}, [year]);
const fetchProjectData = useCallback(async () => {
setProjectLoading(true);
try {
const response = await apiFetch(
`${API_BASE}/attendance?action=project_report&year=${year}`,
);
const result = await response.json();
if (result.success) {
setProjectData(result.data);
}
} catch {
// silent - project data is supplementary
} finally {
setProjectLoading(false);
}
}, [year]);
useEffect(() => {
const loadAll = async () => {
await Promise.all([fetchData(), fetchFundData(), fetchProjectData()]);
};
loadAll();
}, [fetchData, fetchFundData, fetchProjectData]);
useModalLock(showEditModal); useModalLock(showEditModal);
if (!hasPermission("attendance.balances")) return <Forbidden />; if (!hasPermission("attendance.balances")) return <Forbidden />;
@@ -265,8 +209,7 @@ export default function AttendanceBalances() {
if (result.success) { if (result.success) {
setShowEditModal(false); setShowEditModal(false);
await fetchData(false); await queryClient.invalidateQueries({ queryKey: ["attendance"] });
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(result.message); alert.success(result.message);
} else { } else {
alert.error(result.error); alert.error(result.error);
@@ -297,7 +240,7 @@ export default function AttendanceBalances() {
if (result.success) { if (result.success) {
setResetConfirm({ show: false, userId: null, userName: "" }); setResetConfirm({ show: false, userId: null, userName: "" });
await fetchData(false); await queryClient.invalidateQueries({ queryKey: ["attendance"] });
alert.success(result.message); alert.success(result.message);
} else { } else {
alert.error(result.error); alert.error(result.error);
@@ -315,7 +258,7 @@ export default function AttendanceBalances() {
} }
const getYearFundTotals = (userId: string) => { const getYearFundTotals = (userId: string) => {
if (!fundData.months || Object.keys(fundData.months).length === 0) if (!fundData?.months || Object.keys(fundData.months).length === 0)
return null; return null;
let totalFund = 0; let totalFund = 0;
let totalWorked = 0; let totalWorked = 0;
@@ -380,23 +323,20 @@ export default function AttendanceBalances() {
transition={{ duration: 0.25, delay: 0.06 }} transition={{ duration: 0.25, delay: 0.06 }}
> >
<div className="admin-card-body"> <div className="admin-card-body">
{loading && ( <Skeleton
<div className="admin-skeleton" style={{ gap: "1.25rem" }}> name="attendance-balances"
{[0, 1, 2, 3, 4].map((i) => ( loading={balancesPending}
<div key={i} className="admin-skeleton-row"> fixture={<AttendanceBalancesFixture />}
<div className="admin-skeleton-line w-1/4" /> >
<div className="admin-skeleton-line w-1/3" /> <>
<div className="admin-skeleton-line w-1/4" /> {balancesData &&
</div> Object.keys(balancesData.balances).length === 0 && (
))}
</div>
)}
{!loading && Object.keys(data.balances).length === 0 && (
<div className="admin-empty-state"> <div className="admin-empty-state">
<p>Žádní uživatelé k zobrazení.</p> <p>Žádní uživatelé k zobrazení.</p>
</div> </div>
)} )}
{!loading && Object.keys(data.balances).length > 0 && ( {balancesData &&
Object.keys(balancesData.balances).length > 0 && (
<div className="admin-table-responsive"> <div className="admin-table-responsive">
<table className="admin-table"> <table className="admin-table">
<thead> <thead>
@@ -413,12 +353,15 @@ export default function AttendanceBalances() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{Object.entries(data.balances).map(([userId, balance]) => { {Object.entries(balancesData.balances).map(
([userId, balance]) => {
const yf = getYearFundTotals(userId); const yf = getYearFundTotals(userId);
return ( return (
<tr key={userId}> <tr key={userId}>
<td className="fw-500">{balance.name}</td> <td className="fw-500">{balance.name}</td>
<td className="admin-mono">{balance.vacation_total}</td> <td className="admin-mono">
{balance.vacation_total}
</td>
<td className="admin-mono"> <td className="admin-mono">
{balance.vacation_used.toFixed(1)} {balance.vacation_used.toFixed(1)}
</td> </td>
@@ -446,7 +389,9 @@ export default function AttendanceBalances() {
<td> <td>
<div className="admin-table-actions"> <div className="admin-table-actions">
<button <button
onClick={() => openEditModal(userId, balance)} onClick={() =>
openEditModal(userId, balance)
}
className="admin-btn-icon" className="admin-btn-icon"
title="Upravit" title="Upravit"
aria-label="Upravit" aria-label="Upravit"
@@ -495,17 +440,20 @@ export default function AttendanceBalances() {
</td> </td>
</tr> </tr>
); );
})} },
)}
</tbody> </tbody>
</table> </table>
</div> </div>
)} )}
</>
</Skeleton>
</div> </div>
</motion.div> </motion.div>
{/* Monthly Fund Overview */} {/* Monthly Fund Overview */}
{!fundLoading && {!fundPending &&
fundData.months && fundData?.months &&
Object.keys(fundData.months).length > 0 && ( Object.keys(fundData.months).length > 0 && (
<motion.div <motion.div
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
@@ -587,7 +535,7 @@ export default function AttendanceBalances() {
gap: "0.375rem", gap: "0.375rem",
}} }}
> >
{fundData.users && {fundData?.users &&
fundData.users.map((user) => { fundData.users.map((user) => {
const us = monthData.users?.[String(user.id)]; const us = monthData.users?.[String(user.id)];
if (!us) return null; if (!us) return null;
@@ -668,23 +616,19 @@ export default function AttendanceBalances() {
</motion.div> </motion.div>
)} )}
{fundLoading && ( {fundPending && (
<div className="mt-6"> <Skeleton
<div className="admin-skeleton" style={{ gap: "1.25rem" }}> name="attendance-balances-fund"
{[0, 1, 2].map((i) => ( loading={fundPending}
<div key={i} className="admin-skeleton-row"> fixture={<AttendanceBalancesFixture />}
<div className="admin-skeleton-line w-1/4" /> >
<div className="admin-skeleton-line w-1/3" /> <div className="mt-6" />
<div className="admin-skeleton-line w-1/4" /> </Skeleton>
</div>
))}
</div>
</div>
)} )}
{/* Monthly Project Overview */} {/* Monthly Project Overview */}
{!projectLoading && {!projectPending &&
projectData.months && projectData?.months &&
Object.keys(projectData.months).length > 0 && ( Object.keys(projectData.months).length > 0 && (
<motion.div <motion.div
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
@@ -876,18 +820,14 @@ export default function AttendanceBalances() {
</motion.div> </motion.div>
)} )}
{projectLoading && ( {projectPending && (
<div className="mt-6"> <Skeleton
<div className="admin-skeleton" style={{ gap: "1.25rem" }}> name="attendance-balances-projects"
{[0, 1, 2].map((i) => ( loading={projectPending}
<div key={i} className="admin-skeleton-row"> fixture={<AttendanceBalancesFixture />}
<div className="admin-skeleton-line w-1/4" /> >
<div className="admin-skeleton-line w-1/3" /> <div className="mt-6" />
<div className="admin-skeleton-line w-1/4" /> </Skeleton>
</div>
))}
</div>
</div>
)} )}
{/* Edit Modal */} {/* Edit Modal */}

View File

@@ -1,4 +1,6 @@
import { useState, useEffect } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { userListOptions } from "../lib/queries/users";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
@@ -8,6 +10,8 @@ import { motion } from "framer-motion";
import AdminDatePicker from "../components/AdminDatePicker"; import AdminDatePicker from "../components/AdminDatePicker";
import FormField from "../components/FormField"; import FormField from "../components/FormField";
import apiFetch from "../utils/api"; import apiFetch from "../utils/api";
import { Skeleton } from "boneyard-js/react";
import AttendanceCreateFixture from "../fixtures/AttendanceCreateFixture";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
interface User { interface User {
@@ -35,9 +39,9 @@ export default function AttendanceCreate() {
const alert = useAlert(); const alert = useAlert();
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = useState(true); const { data: usersData, isPending: loading } = useQuery(userListOptions());
const users = (usersData as unknown as User[] | undefined) ?? [];
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [users, setUsers] = useState<User[]>([]);
const [form, setForm] = useState<CreateForm>(() => { const [form, setForm] = useState<CreateForm>(() => {
const today = new Date().toISOString().split("T")[0]; const today = new Date().toISOString().split("T")[0];
@@ -58,26 +62,6 @@ export default function AttendanceCreate() {
}; };
}); });
useEffect(() => {
const fetchUsers = async () => {
try {
const response = await apiFetch(`${API_BASE}/users`);
const result = await response.json();
if (result.success) {
setUsers(
Array.isArray(result.data) ? result.data : result.data?.items || [],
);
}
} catch {
alert.error("Nepodařilo se načíst uživatele");
} finally {
setLoading(false);
}
};
fetchUsers();
}, [alert]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -125,37 +109,12 @@ export default function AttendanceCreate() {
if (!hasPermission("attendance.admin")) return <Forbidden />; if (!hasPermission("attendance.admin")) return <Forbidden />;
if (loading) {
return ( return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}> <Skeleton
<div name="attendance-create"
className="admin-skeleton-row" loading={loading}
style={{ justifyContent: "space-between" }} fixture={<AttendanceCreateFixture />}
> >
<div className="admin-skeleton-line h-8" style={{ width: "200px" }} />
</div>
<div className="admin-card" style={{ maxWidth: "600px" }}>
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i}>
<div
className="admin-skeleton-line w-1/4"
style={{ marginBottom: "0.5rem", height: "10px" }}
/>
<div className="admin-skeleton-line w-full h-10" />
</div>
))}
<div
className="admin-skeleton-line h-10"
style={{ width: "120px", borderRadius: "8px" }}
/>
</div>
</div>
</div>
);
}
return (
<div> <div>
<motion.div <motion.div
className="admin-page-header" className="admin-page-header"
@@ -367,5 +326,6 @@ export default function AttendanceCreate() {
</div> </div>
</motion.div> </motion.div>
</div> </div>
</Skeleton>
); );
} }

View File

@@ -1,9 +1,11 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { useState, useMemo, useRef } from "react";
import { useAlert } from "../context/AlertContext"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import AdminDatePicker from "../components/AdminDatePicker"; import AdminDatePicker from "../components/AdminDatePicker";
import { companySettingsOptions } from "../lib/queries/settings";
import { attendanceHistoryOptions } from "../lib/queries/attendance";
import { import {
formatDate, formatDate,
formatDatetime, formatDatetime,
@@ -16,10 +18,8 @@ import {
formatTimeOrDatetimePrint, formatTimeOrDatetimePrint,
} from "../utils/attendanceHelpers"; } from "../utils/attendanceHelpers";
import FormField from "../components/FormField"; import FormField from "../components/FormField";
import apiFetch from "../utils/api"; import { Skeleton } from "boneyard-js/react";
import AttendanceHistoryFixture from "../fixtures/AttendanceHistoryFixture";
const API_BASE = "/api/admin";
interface ProjectLog { interface ProjectLog {
id?: number; id?: number;
project_id?: number; project_id?: number;
@@ -193,48 +193,21 @@ const renderProjectCell = (record: AttendanceRecord) => {
}; };
export default function AttendanceHistory() { export default function AttendanceHistory() {
const alert = useAlert();
const { user, hasPermission } = useAuth(); const { user, hasPermission } = useAuth();
const [loading, setLoading] = useState(true); const queryClient = useQueryClient();
const [companyName, setCompanyName] = useState(""); const { data: companySettings } = useQuery(companySettingsOptions());
const companyName =
((companySettings as Record<string, unknown> | undefined)
?.company_name as string) || "";
const printRef = useRef<HTMLDivElement>(null); const printRef = useRef<HTMLDivElement>(null);
const [month, setMonth] = useState(() => { const [month, setMonth] = useState(() => {
const now = new Date(); const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
}); });
const [records, setRecords] = useState<AttendanceRecord[]>([]); const { data, isPending } = useQuery(
attendanceHistoryOptions({ month, userId: user?.id }),
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [yearStr, monthStr] = month.split("-");
const response = await apiFetch(
`${API_BASE}/attendance?year=${yearStr}&month=${monthStr}&limit=1000&user_id=${user?.id || ""}`,
); );
if (response.status === 401) return; const records = (data as AttendanceRecord[] | undefined) ?? [];
const result = await response.json();
if (result.success) {
setRecords(result.data);
}
} catch {
alert.error("Nepodařilo se načíst data");
} finally {
setLoading(false);
}
}, [month, alert, user?.id]);
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => {
apiFetch(`${API_BASE}/company-settings`)
.then((r) => r.json())
.then((d) => {
if (d.success) setCompanyName(d.data.company_name || "");
})
.catch(() => {});
}, []);
const computed = useMemo(() => { const computed = useMemo(() => {
const [yearStr, monthStr] = month.split("-"); const [yearStr, monthStr] = month.split("-");
@@ -459,36 +432,13 @@ export default function AttendanceHistory() {
transition={{ duration: 0.25, delay: 0.08 }} transition={{ duration: 0.25, delay: 0.08 }}
> >
<div className="admin-card-body"> <div className="admin-card-body">
{loading && ( <Skeleton
<div className="admin-skeleton" style={{ gap: "0.5rem" }}> name="attendance-history-fund"
<div className="admin-skeleton-row" style={{ gap: "1rem" }}> loading={isPending}
<div fixture={<AttendanceHistoryFixture />}
className="admin-skeleton-line" >
style={{ <>
width: "48px", {computed.monthlyFund && (
height: "48px",
borderRadius: "12px",
flexShrink: 0,
}}
/>
<div className="flex-1">
<div
className="admin-skeleton-line w-1/2"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-full"
style={{ height: "6px", borderRadius: "3px" }}
/>
<div
className="admin-skeleton-line w-1/3"
style={{ height: "10px", marginTop: "0.5rem" }}
/>
</div>
</div>
</div>
)}
{!loading && computed.monthlyFund && (
<div <div
style={{ style={{
display: "flex", display: "flex",
@@ -585,7 +535,7 @@ export default function AttendanceHistory() {
</div> </div>
</div> </div>
)} )}
{!loading && !computed.monthlyFund && ( {!computed.monthlyFund && (
<div <div
className="text-muted" className="text-muted"
style={{ style={{
@@ -597,6 +547,8 @@ export default function AttendanceHistory() {
Fond měsíce není k dispozici Fond měsíce není k dispozici
</div> </div>
)} )}
</>
</Skeleton>
</div> </div>
</motion.div> </motion.div>
@@ -608,23 +560,18 @@ export default function AttendanceHistory() {
transition={{ duration: 0.25, delay: 0.12 }} transition={{ duration: 0.25, delay: 0.12 }}
> >
<div className="admin-card-body"> <div className="admin-card-body">
{loading && ( <Skeleton
<div className="admin-skeleton" style={{ gap: "1.25rem" }}> name="attendance-history-table"
{[0, 1, 2, 3, 4].map((i) => ( loading={isPending}
<div key={i} className="admin-skeleton-row"> fixture={<AttendanceHistoryFixture />}
<div className="admin-skeleton-line w-1/4" /> >
<div className="admin-skeleton-line w-1/3" /> <>
<div className="admin-skeleton-line w-1/4" /> {records.length === 0 && (
</div>
))}
</div>
)}
{!loading && records.length === 0 && (
<div className="admin-empty-state"> <div className="admin-empty-state">
<p>Za tento měsíc nejsou žádné záznamy.</p> <p>Za tento měsíc nejsou žádné záznamy.</p>
</div> </div>
)} )}
{!loading && records.length > 0 && ( {records.length > 0 && (
<div className="admin-table-responsive"> <div className="admin-table-responsive">
<table className="admin-table"> <table className="admin-table">
<thead> <thead>
@@ -660,7 +607,9 @@ export default function AttendanceHistory() {
</span> </span>
</td> </td>
<td className="admin-mono"> <td className="admin-mono">
{isLeave ? "—" : formatDatetime(record.arrival_time)} {isLeave
? "—"
: formatDatetime(record.arrival_time)}
</td> </td>
<td className="admin-mono"> <td className="admin-mono">
{isLeave ? "—" : formatBreakRange(record)} {isLeave ? "—" : formatBreakRange(record)}
@@ -693,6 +642,8 @@ export default function AttendanceHistory() {
</table> </table>
</div> </div>
)} )}
</>
</Skeleton>
</div> </div>
</motion.div> </motion.div>

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
@@ -9,65 +10,35 @@ import L from "leaflet";
import "leaflet/dist/leaflet.css"; import "leaflet/dist/leaflet.css";
import { formatDate, formatTime } from "../utils/attendanceHelpers"; import { formatDate, formatTime } from "../utils/attendanceHelpers";
import apiFetch from "../utils/api"; import {
const API_BASE = "/api/admin"; attendanceLocationOptions,
type LocationRecord,
interface LocationRecord { } from "../lib/queries/attendance";
user_name: string; import { Skeleton } from "boneyard-js/react";
shift_date: string; import AttendanceLocationFixture from "../fixtures/AttendanceLocationFixture";
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 default function AttendanceLocation() { export default function AttendanceLocation() {
const alert = useAlert(); const alert = useAlert();
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [loading, setLoading] = useState(true);
const [record, setRecord] = useState<LocationRecord | null>(null);
const mapRef = useRef<HTMLDivElement>(null); const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<unknown>(null); const mapInstanceRef = useRef<unknown>(null);
const locationQuery = useQuery(attendanceLocationOptions(id));
const record = locationQuery.data ?? null;
const isPending = locationQuery.isPending;
// Navigate away on fetch error
useEffect(() => { useEffect(() => {
const fetchData = async () => { if (locationQuery.error) {
try {
const response = await apiFetch(
`${API_BASE}/attendance?action=location&id=${id}`,
);
const result = await response.json();
if (result.success) {
const raw = result.data.record || result.data;
// Enrich with user_name from nested users relation
const userName = raw.users
? `${raw.users.first_name} ${raw.users.last_name}`.trim()
: raw.user_name || "";
setRecord({ ...raw, user_name: userName });
} else {
alert.error("Záznam nebyl nalezen");
navigate("/attendance/admin");
}
} catch {
alert.error("Nepodařilo se načíst data"); alert.error("Nepodařilo se načíst data");
navigate("/attendance/admin"); navigate("/attendance/admin");
} finally {
setLoading(false);
} }
}; }, [locationQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps
fetchData();
}, [id, alert, navigate]);
useEffect(() => { useEffect(() => {
if (!record || loading) return; if (!record || isPending) return;
const hasArrivalLocation = record.arrival_lat && record.arrival_lng; const hasArrivalLocation = record.arrival_lat && record.arrival_lng;
const hasDepartureLocation = record.departure_lat && record.departure_lng; const hasDepartureLocation = record.departure_lat && record.departure_lng;
@@ -175,7 +146,7 @@ export default function AttendanceLocation() {
mapInstanceRef.current = null; mapInstanceRef.current = null;
} }
}; };
}, [record, loading]); }, [record, isPending]);
const formatDatetimeLocal = (datetime: string | null | undefined): string => { const formatDatetimeLocal = (datetime: string | null | undefined): string => {
if (!datetime) return "—"; if (!datetime) return "—";
@@ -185,56 +156,6 @@ export default function AttendanceLocation() {
if (!hasPermission("attendance.admin")) return <Forbidden />; if (!hasPermission("attendance.admin")) return <Forbidden />;
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div
style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}
>
<div
className="admin-skeleton-line"
style={{ width: "32px", height: "32px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px" }}
/>
</div>
</div>
<div className="admin-card">
<div
className="admin-skeleton-line"
style={{ width: "100%", height: "300px", borderRadius: "8px" }}
/>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "1.25rem",
}}
>
{[0, 1].map((i) => (
<div key={i} className="admin-card">
<div className="admin-skeleton" style={{ gap: "1rem" }}>
<div
className="admin-skeleton-line h-8"
style={{ width: "50%" }}
/>
<div className="admin-skeleton-line w-full" />
<div className="admin-skeleton-line w-3/4" />
</div>
</div>
))}
</div>
</div>
);
}
if (!record) { if (!record) {
return null; return null;
} }
@@ -248,6 +169,11 @@ export default function AttendanceLocation() {
const month = shiftDateStr.substring(0, 7); const month = shiftDateStr.substring(0, 7);
return ( return (
<Skeleton
name="attendance-location"
loading={isPending}
fixture={<AttendanceLocationFixture />}
>
<div> <div>
<motion.div <motion.div
className="admin-page-header" className="admin-page-header"
@@ -362,5 +288,6 @@ export default function AttendanceLocation() {
</div> </div>
</motion.div> </motion.div>
</div> </div>
</Skeleton>
); );
} }

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from "react"; import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
@@ -8,6 +9,8 @@ import FormField from "../components/FormField";
import AdminDatePicker from "../components/AdminDatePicker"; import AdminDatePicker from "../components/AdminDatePicker";
import { czechPlural } from "../utils/formatters"; import { czechPlural } from "../utils/formatters";
import apiFetch from "../utils/api"; import apiFetch from "../utils/api";
import { Skeleton } from "boneyard-js/react";
import AuditLogFixture from "../fixtures/AuditLogFixture";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
@@ -77,13 +80,6 @@ interface AuditLogEntry {
user_ip: string | null; user_ip: string | null;
} }
interface PaginationData {
total: number;
page: number;
per_page: number;
total_pages: number;
}
interface Filters { interface Filters {
search: string; search: string;
action: string; action: string;
@@ -95,9 +91,7 @@ interface Filters {
export default function AuditLog() { export default function AuditLog() {
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const alert = useAlert(); const alert = useAlert();
const [logs, setLogs] = useState<AuditLogEntry[]>([]); const queryClient = useQueryClient();
const [loading, setLoading] = useState(true);
const [pagination, setPagination] = useState<PaginationData | null>(null);
const [filters, setFilters] = useState<Filters>({ const [filters, setFilters] = useState<Filters>({
search: "", search: "",
action: "", action: "",
@@ -105,19 +99,30 @@ export default function AuditLog() {
date_from: "", date_from: "",
date_to: "", date_to: "",
}); });
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(50);
const [showCleanup, setShowCleanup] = useState(false); const [showCleanup, setShowCleanup] = useState(false);
const [cleanupDays, setCleanupDays] = useState(90); const [cleanupDays, setCleanupDays] = useState(90);
const [cleaning, setCleaning] = useState(false); const [cleaning, setCleaning] = useState(false);
const fetchLogs = useCallback( const { data: logsData, isPending } = useQuery({
async (page = 1, perPage = 50) => { queryKey: [
setLoading(true); "audit-log",
try { {
search: filters.search,
action: filters.action,
entityType: filters.entity_type,
dateFrom: filters.date_from,
dateTo: filters.date_to,
page,
perPage,
},
],
queryFn: async () => {
const params = new URLSearchParams({ const params = new URLSearchParams({
page: String(page), page: String(page),
per_page: String(perPage), per_page: String(perPage),
}); });
if (filters.search) params.set("search", filters.search); if (filters.search) params.set("search", filters.search);
if (filters.action) params.set("action", filters.action); if (filters.action) params.set("action", filters.action);
if (filters.entity_type) params.set("entity_type", filters.entity_type); if (filters.entity_type) params.set("entity_type", filters.entity_type);
@@ -127,31 +132,24 @@ export default function AuditLog() {
const response = await apiFetch( const response = await apiFetch(
`${API_BASE}/audit-log?${params.toString()}`, `${API_BASE}/audit-log?${params.toString()}`,
); );
const data = await response.json(); if (response.status === 401) throw new Error("Unauthorized");
const result = await response.json();
if (data.success) { if (!result.success)
setLogs(Array.isArray(data.data) ? data.data : []); throw new Error(result.error || "Nepodařilo se načíst audit log");
setPagination({ return {
total: data.pagination?.total ?? 0, data: Array.isArray(result.data) ? result.data : [],
page: data.pagination?.page ?? 1, pagination: {
per_page: data.pagination?.limit ?? 50, total: result.pagination?.total ?? 0,
total_pages: data.pagination?.total_pages ?? 1, page: result.pagination?.page ?? 1,
}); per_page: result.pagination?.limit ?? perPage,
} else { total_pages: result.pagination?.total_pages ?? 1,
alert.error(data.error || "Nepodařilo se načíst audit log");
}
} catch {
alert.error("Chyba připojení");
} finally {
setLoading(false);
}
}, },
[filters, alert], };
); },
});
useEffect(() => { const logs = logsData?.data ?? [];
fetchLogs(); const pagination = logsData?.pagination ?? null;
}, [fetchLogs]);
if (!hasPermission("settings.audit")) { if (!hasPermission("settings.audit")) {
return <Forbidden />; return <Forbidden />;
@@ -159,14 +157,16 @@ export default function AuditLog() {
const handleFilterChange = (key: keyof Filters, value: string) => { const handleFilterChange = (key: keyof Filters, value: string) => {
setFilters((prev) => ({ ...prev, [key]: value })); setFilters((prev) => ({ ...prev, [key]: value }));
setPage(1);
}; };
const handlePageChange = (newPage: number) => { const handlePageChange = (newPage: number) => {
fetchLogs(newPage, pagination?.per_page || 50); setPage(newPage);
}; };
const handlePerPageChange = (newPerPage: number) => { const handlePerPageChange = (newPerPage: number) => {
fetchLogs(1, newPerPage); setPage(1);
setPerPage(newPerPage);
}; };
const handleCleanup = async () => { const handleCleanup = async () => {
@@ -181,7 +181,7 @@ export default function AuditLog() {
if (data.success) { if (data.success) {
alert.success(data.message); alert.success(data.message);
setShowCleanup(false); setShowCleanup(false);
fetchLogs(); queryClient.invalidateQueries({ queryKey: ["audit-log"] });
} else { } else {
alert.error(data.error); alert.error(data.error);
} }
@@ -197,66 +197,15 @@ export default function AuditLog() {
return new Date(dateString).toLocaleString("cs-CZ"); return new Date(dateString).toLocaleString("cs-CZ");
}; };
if (loading && logs.length === 0) { if (isPending && logs.length === 0) {
return ( return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}> <Skeleton
<div name="audit-log"
className="admin-skeleton-row" loading={isPending && logs.length === 0}
style={{ justifyContent: "space-between" }} fixture={<AuditLogFixture />}
> >
<div> <div />
<div </Skeleton>
className="admin-skeleton-line h-8"
style={{ width: "160px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "100px" }} />
</div>
</div>
<div className="admin-card">
<div
className="admin-skeleton"
style={{ gap: "0.75rem", padding: "1rem" }}
>
<div
className="admin-skeleton-line h-10"
style={{ width: "100%", borderRadius: "8px" }}
/>
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1rem" }}>
<div
className="admin-skeleton-line h-10"
style={{ width: "100%", borderRadius: "4px" }}
/>
{Array.from({ length: 8 }, (_, i) => (
<div key={i} className="admin-skeleton-row">
<div
className="admin-skeleton-line"
style={{ width: "120px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "80px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "70px", borderRadius: "10px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "80px" }}
/>
<div className="admin-skeleton-line flex-1" />
<div
className="admin-skeleton-line"
style={{ width: "90px" }}
/>
</div>
))}
</div>
</div>
</div>
); );
} }
@@ -454,52 +403,75 @@ export default function AuditLog() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{loading && <Skeleton
Array.from({ length: 10 }, (_, i) => ( name="audit-log-rows"
<tr key={`skeleton-${i}`}> loading={isPending}
<td> fixture={
<div style={{ padding: "1rem" }}>
{Array.from({ length: 10 }, (_, i) => (
<div <div
className="admin-skeleton-line" key={i}
style={{ width: "110px", height: "14px" }}
/>
</td>
<td>
<div
className="admin-skeleton-line"
style={{ width: "80px", height: "14px" }}
/>
</td>
<td>
<div
className="admin-skeleton-line"
style={{ style={{
width: "70px", display: "flex",
height: "22px", gap: "1rem",
borderRadius: "10px", marginBottom: "0.75rem",
}}
>
<div
style={{
width: 110,
height: 14,
background: "var(--bg-tertiary)",
borderRadius: 4,
}} }}
/> />
</td>
<td>
<div <div
className="admin-skeleton-line" style={{
style={{ width: "80px", height: "14px" }} width: 80,
height: 14,
background: "var(--bg-tertiary)",
borderRadius: 4,
}}
/> />
</td>
<td>
<div <div
className="admin-skeleton-line" style={{
style={{ width: "60%", height: "14px" }} width: 70,
height: 22,
background: "var(--bg-tertiary)",
borderRadius: 10,
}}
/> />
</td>
<td>
<div <div
className="admin-skeleton-line" style={{
style={{ width: "90px", height: "14px" }} width: 80,
height: 14,
background: "var(--bg-tertiary)",
borderRadius: 4,
}}
/> />
</td> <div
</tr> style={{
flex: 1,
height: 14,
background: "var(--bg-tertiary)",
borderRadius: 4,
}}
/>
<div
style={{
width: 90,
height: 14,
background: "var(--bg-tertiary)",
borderRadius: 4,
}}
/>
</div>
))} ))}
{!loading && logs.length === 0 && ( </div>
}
>
<>
{logs.length === 0 && (
<tr> <tr>
<td colSpan={6}> <td colSpan={6}>
<div className="admin-empty-state"> <div className="admin-empty-state">
@@ -523,7 +495,7 @@ export default function AuditLog() {
</td> </td>
</tr> </tr>
)} )}
{!loading && {logs.length > 0 &&
logs.map((log) => ( logs.map((log) => (
<tr key={log.id}> <tr key={log.id}>
<td className="admin-mono"> <td className="admin-mono">
@@ -546,6 +518,8 @@ export default function AuditLog() {
<td className="admin-mono">{log.user_ip || "-"}</td> <td className="admin-mono">{log.user_ip || "-"}</td>
</tr> </tr>
))} ))}
</>
</Skeleton>
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@@ -1,12 +1,17 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import FormField from "../components/FormField"; import FormField from "../components/FormField";
import ConfirmModal from "../components/ConfirmModal"; import ConfirmModal from "../components/ConfirmModal";
import { companySettingsOptions } from "../lib/queries/settings";
import { bankAccountsOptions } from "../lib/queries/common";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import apiFetch from "../utils/api"; import apiFetch from "../utils/api";
import { Skeleton } from "boneyard-js/react";
import CompanySettingsFixture from "../fixtures/CompanySettingsFixture";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
const DEFAULT_FIELD_ORDER = [ const DEFAULT_FIELD_ORDER = [
@@ -68,7 +73,7 @@ export default function CompanySettings({
}: { embedded?: boolean } = {}) { }: { embedded?: boolean } = {}) {
const alert = useAlert(); const alert = useAlert();
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true); const queryClient = useQueryClient();
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [uploadingLogo, setUploadingLogo] = useState(false); const [uploadingLogo, setUploadingLogo] = useState(false);
const [uploadingLogoDark, setUploadingLogoDark] = useState(false); const [uploadingLogoDark, setUploadingLogoDark] = useState(false);
@@ -89,14 +94,12 @@ export default function CompanySettings({
const [fieldOrder, setFieldOrder] = useState<string[]>([ const [fieldOrder, setFieldOrder] = useState<string[]>([
...DEFAULT_FIELD_ORDER, ...DEFAULT_FIELD_ORDER,
]); ]);
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([]);
const [availableCurrencies, setAvailableCurrencies] = useState<string[]>([ const [availableCurrencies, setAvailableCurrencies] = useState<string[]>([
"CZK", "CZK",
"EUR", "EUR",
"USD", "USD",
"GBP", "GBP",
]); ]);
const [bankLoading, setBankLoading] = useState(true);
const [bankSaving, setBankSaving] = useState(false); const [bankSaving, setBankSaving] = useState(false);
const [editingBank, setEditingBank] = useState<number | null>(null); const [editingBank, setEditingBank] = useState<number | null>(null);
const [bankDeleteConfirm, setBankDeleteConfirm] = useState<{ const [bankDeleteConfirm, setBankDeleteConfirm] = useState<{
@@ -182,45 +185,47 @@ export default function CompanySettings({
} }
}, []); }, []);
const fetchData = useCallback(async () => { // ── TanStack Query: company settings ──
try { const { data: settingsData, isPending: settingsLoading } = useQuery(
const response = await apiFetch(`${API_BASE}/company-settings`); companySettingsOptions(),
if (response.status === 401) return; );
const result = await response.json();
if (result.success) { // ── TanStack Query: bank accounts ──
const d = result.data; const { data: bankAccountsData, isPending: bankLoading } = useQuery(
bankAccountsOptions(),
);
const bankAccountsList: BankAccount[] = Array.isArray(bankAccountsData)
? (bankAccountsData as unknown as BankAccount[])
: [];
// Populate form state when settings data arrives
useEffect(() => {
if (!settingsData) return;
const d = settingsData as Record<string, unknown>;
setForm({ setForm({
company_name: d.company_name || "", company_name: (d.company_name as string) || "",
street: d.street || "", street: (d.street as string) || "",
city: d.city || "", city: (d.city as string) || "",
postal_code: d.postal_code || "", postal_code: (d.postal_code as string) || "",
country: d.country || "", country: (d.country as string) || "",
company_id: d.company_id || "", company_id: (d.company_id as string) || "",
vat_id: d.vat_id || "", vat_id: (d.vat_id as string) || "",
}); });
const cf = const cf: CustomField[] =
Array.isArray(d.custom_fields) && d.custom_fields.length > 0 Array.isArray(d.custom_fields) && d.custom_fields.length > 0
? d.custom_fields.map( ? (d.custom_fields as CustomField[]).map((f, i) => ({
(
f: {
name: string;
value: string;
showLabel?: boolean;
_key?: string;
},
i: number,
) => ({
...f, ...f,
showLabel: f.showLabel !== false,
_key: f._key || `cf-${Date.now()}-${i}`, _key: f._key || `cf-${Date.now()}-${i}`,
}), }))
)
: []; : [];
setCustomFields(cf); setCustomFields(cf);
if ( if (
Array.isArray(d.supplier_field_order) && Array.isArray(d.supplier_field_order) &&
d.supplier_field_order.length > 0 d.supplier_field_order.length > 0
) { ) {
setFieldOrder(d.supplier_field_order); setFieldOrder(d.supplier_field_order as string[]);
} else { } else {
setFieldOrder([...DEFAULT_FIELD_ORDER]); setFieldOrder([...DEFAULT_FIELD_ORDER]);
} }
@@ -228,7 +233,7 @@ export default function CompanySettings({
Array.isArray(d.available_currencies) && Array.isArray(d.available_currencies) &&
d.available_currencies.length > 0 d.available_currencies.length > 0
) { ) {
setAvailableCurrencies(d.available_currencies); setAvailableCurrencies(d.available_currencies as string[]);
} }
if (d.has_logo) { if (d.has_logo) {
fetchLogo("light"); fetchLogo("light");
@@ -236,30 +241,7 @@ export default function CompanySettings({
if (d.has_logo_dark) { if (d.has_logo_dark) {
fetchLogo("dark"); fetchLogo("dark");
} }
} else { }, [settingsData, fetchLogo]);
alert.error(result.error || "Nepodařilo se načíst nastavení");
}
} catch {
alert.error("Chyba připojení");
} finally {
setLoading(false);
}
}, [alert, fetchLogo]);
const fetchBankAccounts = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/bank-accounts`);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setBankAccounts(result.data);
}
} catch {
// ignore
} finally {
setBankLoading(false);
}
}, []);
const resetBankForm = () => { const resetBankForm = () => {
setEditingBank(null); setEditingBank(null);
@@ -294,7 +276,7 @@ export default function CompanySettings({
if (result.success) { if (result.success) {
alert.success(result.message); alert.success(result.message);
resetBankForm(); resetBankForm();
fetchBankAccounts(); queryClient.invalidateQueries({ queryKey: ["bank-accounts"] });
} else { } else {
alert.error(result.error || "Chyba při ukládání"); alert.error(result.error || "Chyba při ukládání");
} }
@@ -322,7 +304,7 @@ export default function CompanySettings({
if (result.success) { if (result.success) {
alert.success(result.message); alert.success(result.message);
if (editingBank === bankDeleteConfirm.id) resetBankForm(); if (editingBank === bankDeleteConfirm.id) resetBankForm();
fetchBankAccounts(); queryClient.invalidateQueries({ queryKey: ["bank-accounts"] });
} else { } else {
alert.error(result.error || "Chyba při mazání"); alert.error(result.error || "Chyba při mazání");
} }
@@ -346,11 +328,6 @@ export default function CompanySettings({
}); });
}; };
useEffect(() => {
fetchData();
fetchBankAccounts();
}, [fetchData, fetchBankAccounts]);
// Cleanup blob URLs on unmount // Cleanup blob URLs on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -377,6 +354,7 @@ export default function CompanySettings({
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
alert.success(result.message || "Nastavení bylo uloženo"); alert.success(result.message || "Nastavení bylo uloženo");
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
} else { } else {
alert.error(result.error || "Nepodařilo se uložit nastavení"); alert.error(result.error || "Nepodařilo se uložit nastavení");
} }
@@ -411,6 +389,7 @@ export default function CompanySettings({
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
alert.success(result.message || "Logo bylo nahráno"); alert.success(result.message || "Logo bylo nahráno");
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
fetchLogo(variant); fetchLogo(variant);
} else { } else {
alert.error(result.error || "Nepodařilo se nahrát logo"); alert.error(result.error || "Nepodařilo se nahrát logo");
@@ -429,50 +408,15 @@ export default function CompanySettings({
if (!embedded && !hasPermission("settings.manage")) return <Forbidden />; if (!embedded && !hasPermission("settings.manage")) return <Forbidden />;
if (loading) { if (settingsLoading) {
return ( return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}> <Skeleton
<div name="company-settings"
className="admin-skeleton-row" loading={settingsLoading}
style={{ justifyContent: "space-between" }} fixture={<CompanySettingsFixture />}
> >
<div> <div />
<div </Skeleton>
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
<div
className="admin-skeleton-line h-10"
style={{ width: "120px", borderRadius: "8px" }}
/>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: "1.25rem",
}}
>
{[0, 1, 2, 3, 4, 5].map((i) => (
<div key={i} className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
<div
className="admin-skeleton-line h-8"
style={{ width: "60%" }}
/>
{[0, 1, 2].map((j) => (
<div key={j} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/2" />
</div>
))}
</div>
</div>
))}
</div>
</div>
); );
} }
@@ -774,18 +718,16 @@ export default function CompanySettings({
</div> </div>
<div className="admin-card-body"> <div className="admin-card-body">
{bankLoading ? ( {bankLoading ? (
<div className="admin-skeleton" style={{ gap: "1rem" }}> <Skeleton
{[0, 1, 2].map((i) => ( name="company-settings-bank"
<div key={i} className="admin-skeleton-row"> loading={bankLoading}
<div className="admin-skeleton-line w-1/3" /> fixture={<CompanySettingsFixture />}
<div className="admin-skeleton-line w-1/4" /> >
<div className="admin-skeleton-line w-1/4" /> <div />
</div> </Skeleton>
))}
</div>
) : ( ) : (
<> <>
{bankAccounts.length > 0 && ( {bankAccountsList.length > 0 && (
<div className="admin-table-responsive mb-4"> <div className="admin-table-responsive mb-4">
<table className="admin-table"> <table className="admin-table">
<thead> <thead>
@@ -801,7 +743,7 @@ export default function CompanySettings({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{bankAccounts.map((acc) => ( {bankAccountsList.map((acc) => (
<tr <tr
key={acc.id} key={acc.id}
style={ style={

View File

@@ -1,10 +1,13 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useCallback } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import useModalLock from "../hooks/useModalLock"; import useModalLock from "../hooks/useModalLock";
import apiFetch from "../utils/api"; import apiFetch from "../utils/api";
import { dashboardOptions } from "../lib/queries/dashboard";
import { require2FAOptions } from "../lib/queries/settings";
import { getCzechDate } from "../utils/dashboardHelpers"; import { getCzechDate } from "../utils/dashboardHelpers";
import DashKpiCards from "../components/dashboard/DashKpiCards"; import DashKpiCards from "../components/dashboard/DashKpiCards";
import DashQuickActions from "../components/dashboard/DashQuickActions"; import DashQuickActions from "../components/dashboard/DashQuickActions";
@@ -12,6 +15,8 @@ import DashActivityFeed from "../components/dashboard/DashActivityFeed";
import DashAttendanceToday from "../components/dashboard/DashAttendanceToday"; import DashAttendanceToday from "../components/dashboard/DashAttendanceToday";
import DashProfile from "../components/dashboard/DashProfile"; import DashProfile from "../components/dashboard/DashProfile";
import DashSessions from "../components/dashboard/DashSessions"; import DashSessions from "../components/dashboard/DashSessions";
import { Skeleton } from "boneyard-js/react";
import DashboardFixture from "../fixtures/DashboardFixture";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
@@ -69,13 +74,17 @@ export default function Dashboard() {
const { user, updateUser, hasPermission } = useAuth(); const { user, updateUser, hasPermission } = useAuth();
const alert = useAlert(); const alert = useAlert();
const [dashData, setDashData] = useState<DashData | null>(null);
const [dashLoading, setDashLoading] = useState(true);
const [punching, setPunching] = useState(false); const [punching, setPunching] = useState(false);
const queryClient = useQueryClient();
const { data: dashDataRaw, isPending: dashLoading } =
useQuery(dashboardOptions());
const dashData = dashDataRaw as DashData | undefined;
const { data: totpData, isPending: totpLoading } =
useQuery(require2FAOptions());
const totpEnabled = totpData?.require_2fa ?? !!user?.totpEnabled;
// 2FA state - sdileny mezi profilem a bannerem // 2FA state - sdileny mezi profilem a bannerem
const [totpEnabled, setTotpEnabled] = useState(false);
const [totpLoading, setTotpLoading] = useState(true);
const [show2FASetup, setShow2FASetup] = useState(false); const [show2FASetup, setShow2FASetup] = useState(false);
const [show2FADisable, setShow2FADisable] = useState(false); const [show2FADisable, setShow2FADisable] = useState(false);
const [totpSecret, setTotpSecret] = useState<string | null>(null); const [totpSecret, setTotpSecret] = useState<string | null>(null);
@@ -88,46 +97,6 @@ export default function Dashboard() {
useModalLock(show2FASetup); useModalLock(show2FASetup);
useModalLock(show2FADisable); useModalLock(show2FADisable);
const fetchDashboard = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/dashboard`);
const data = await response.json();
if (data.success !== false) {
setDashData(data.data || data);
}
} catch (err) {
if (import.meta.env.DEV) {
console.error("Dashboard fetch error:", err);
}
} finally {
setDashLoading(false);
}
}, []);
useEffect(() => {
fetchDashboard();
}, [fetchDashboard]);
// 2FA status fetch
const fetch2FAStatus = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/totp/setup`);
const data = await response.json();
if (data.success) {
setTotpEnabled(!!user?.totpEnabled);
}
} catch {
// 2FA status fetch failed silently
setTotpEnabled(!!user?.totpEnabled);
} finally {
setTotpLoading(false);
}
}, [user?.totpEnabled]);
useEffect(() => {
fetch2FAStatus();
}, [fetch2FAStatus]);
// Punch (prichod/odchod) primo z dashboardu // Punch (prichod/odchod) primo z dashboardu
const handleQuickPunch = useCallback(() => { const handleQuickPunch = useCallback(() => {
const action = dashData?.my_shift?.has_ongoing ? "departure" : "arrival"; const action = dashData?.my_shift?.has_ongoing ? "departure" : "arrival";
@@ -143,7 +112,7 @@ export default function Dashboard() {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
alert.success(result.data?.message || "Docházka zaznamenána"); alert.success(result.data?.message || "Docházka zaznamenána");
fetchDashboard(); queryClient.invalidateQueries({ queryKey: ["dashboard"] });
} else { } else {
alert.error(result.error || "Chyba při záznamu docházky"); alert.error(result.error || "Chyba při záznamu docházky");
} }
@@ -167,7 +136,7 @@ export default function Dashboard() {
() => submitPunch({}), () => submitPunch({}),
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }, { enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 },
); );
}, [dashData, alert, fetchDashboard]); }, [dashData, alert, queryClient]);
// 2FA handlery // 2FA handlery
const handleStart2FASetup = async () => { const handleStart2FASetup = async () => {
@@ -202,7 +171,7 @@ export default function Dashboard() {
}); });
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
setTotpEnabled(true); queryClient.invalidateQueries({ queryKey: ["settings", "2fa"] });
setBackupCodes(data.data?.backup_codes || null); setBackupCodes(data.data?.backup_codes || null);
setTotpSecret(null); setTotpSecret(null);
setTotpQrUri(null); setTotpQrUri(null);
@@ -230,7 +199,7 @@ export default function Dashboard() {
}); });
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
setTotpEnabled(false); queryClient.invalidateQueries({ queryKey: ["settings", "2fa"] });
setShow2FADisable(false); setShow2FADisable(false);
setDisableCode(""); setDisableCode("");
updateUser({ totpEnabled: false }); updateUser({ totpEnabled: false });
@@ -337,62 +306,13 @@ export default function Dashboard() {
{/* Skeleton loading */} {/* Skeleton loading */}
{dashLoading && ( {dashLoading && (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.25rem" }}> <Skeleton
<div className="admin-kpi-grid admin-kpi-4"> name="dashboard"
{[0, 1, 2, 3].map((i) => ( loading={dashLoading}
<div fixture={<DashboardFixture />}
key={i}
className="admin-skeleton-line h-24"
style={{ borderRadius: "10px" }}
/>
))}
</div>
<div className="dash-quick-actions">
{[0, 1, 2, 3].map((i) => (
<div
key={i}
className="admin-skeleton-line"
style={{ height: "52px", borderRadius: "10px" }}
/>
))}
</div>
<div className="dash-main-grid">
<div
className="admin-skeleton-line"
style={{ height: "320px", borderRadius: "10px" }}
/>
<div
className="admin-skeleton-line"
style={{ height: "320px", borderRadius: "10px" }}
/>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "1.25rem",
}}
> >
<div <div />
className="admin-skeleton-line" </Skeleton>
style={{ height: "150px", borderRadius: "10px" }}
/>
<div
className="admin-skeleton-line"
style={{ height: "150px", borderRadius: "10px" }}
/>
</div>
</div>
<div className="dash-bottom">
<div
className="admin-skeleton-line"
style={{ height: "200px", borderRadius: "10px" }}
/>
<div
className="admin-skeleton-line"
style={{ height: "200px", borderRadius: "10px" }}
/>
</div>
</div>
)} )}
{/* KPI cards — only show if user has any admin-level permissions */} {/* KPI cards — only show if user has any admin-level permissions */}

View File

@@ -5,10 +5,13 @@ import {
useParams, useParams,
Link, Link,
} from "react-router-dom"; } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import { Skeleton } from "boneyard-js/react";
import InvoiceDetailFixture from "../fixtures/InvoiceDetailFixture";
import FormField from "../components/FormField"; import FormField from "../components/FormField";
import AdminDatePicker from "../components/AdminDatePicker"; import AdminDatePicker from "../components/AdminDatePicker";
import ConfirmModal from "../components/ConfirmModal"; import ConfirmModal from "../components/ConfirmModal";
@@ -35,6 +38,11 @@ import {
} from "@dnd-kit/modifiers"; } from "@dnd-kit/modifiers";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import apiFetch from "../utils/api"; import apiFetch from "../utils/api";
import { companySettingsOptions } from "../lib/queries/settings";
import { invoiceDetailOptions } from "../lib/queries/invoices";
import { offerCustomersOptions } from "../lib/queries/offers";
import { bankAccountsOptions } from "../lib/queries/common";
import { jsonQuery } from "../lib/apiAdapter";
import { formatCurrency, formatDate } from "../utils/formatters"; import { formatCurrency, formatDate } from "../utils/formatters";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
@@ -367,7 +375,6 @@ export default function InvoiceDetail() {
bank_account: "", bank_account: "",
}); });
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([]);
const [dueDays, setDueDays] = useState(14); const [dueDays, setDueDays] = useState(14);
const [items, setItems] = useState<InvoiceItem[]>([ const [items, setItems] = useState<InvoiceItem[]>([
{ {
@@ -381,44 +388,37 @@ export default function InvoiceDetail() {
]); ]);
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(true); const [dataReady, setDataReady] = useState(false);
const [invoiceNumber, setInvoiceNumber] = useState(""); const [invoiceNumber, setInvoiceNumber] = useState("");
const initialSnapshotRef = useRef<string | null>(null); const initialSnapshotRef = useRef<string | null>(null);
const [customers, setCustomers] = useState<Customer[]>([]);
const [customerSearch, setCustomerSearch] = useState(""); const [customerSearch, setCustomerSearch] = useState("");
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false); const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
const [companySettings, setCompanySettings] = useState<{ const companySettings = useQuery(companySettingsOptions()).data as unknown as
| {
default_currency: string; default_currency: string;
default_vat_rate: number; default_vat_rate: number;
available_currencies: string[]; available_currencies: string[];
available_vat_rates: number[]; available_vat_rates: number[];
} | null>(null); }
| undefined;
useEffect(() => { useEffect(() => {
apiFetch(`${API_BASE}/company-settings`) if (companySettings && !isEdit) {
.then((r) => r.json())
.then((d) => {
if (d.success) {
setCompanySettings(d.data);
if (!isEdit) {
setForm((prev) => ({ setForm((prev) => ({
...prev, ...prev,
currency: currency:
prev.currency === "CZK" prev.currency === "CZK"
? d.data.default_currency || "CZK" ? companySettings.default_currency || "CZK"
: prev.currency, : prev.currency,
vat_rate: vat_rate:
prev.vat_rate === 21 prev.vat_rate === 21
? (d.data.default_vat_rate ?? 21) ? (companySettings.default_vat_rate ?? 21)
: prev.vat_rate, : prev.vat_rate,
})); }));
} }
} }, [companySettings, isEdit]);
})
.catch(() => {});
}, []);
const vatOptions = ( const vatOptions = (
companySettings?.available_vat_rates || [0, 10, 12, 15, 21] companySettings?.available_vat_rates || [0, 10, 12, 15, 21]
@@ -437,8 +437,44 @@ export default function InvoiceDetail() {
} }
}, []); }, []);
// ─── TanStack Query ───
const queryClient = useQueryClient();
const customersQuery = useQuery(offerCustomersOptions());
const customers = useMemo<Customer[]>(() => {
const data = customersQuery.data;
if (!data) return [];
if (Array.isArray(data)) return data as Customer[];
const obj = data as Record<string, unknown>;
if (Array.isArray(obj.customers)) return obj.customers as Customer[];
return [];
}, [customersQuery.data]);
const bankAccountsQuery = useQuery(bankAccountsOptions());
const bankAccounts = (bankAccountsQuery.data ?? []) as BankAccount[];
const invoiceQuery = useQuery(invoiceDetailOptions(id));
const invoice = (invoiceQuery.data as Invoice | undefined) ?? null;
const nextNumberQuery = useQuery({
queryKey: ["invoices", "next-number"],
queryFn: () =>
jsonQuery<{ next_number?: string; number?: string }>(
`${API_BASE}/invoices/next-number`,
).then((d) => d?.next_number || d?.number || ""),
enabled: !isEdit,
});
const orderDataQuery = useQuery({
queryKey: ["invoices", "order-data", fromOrderId],
queryFn: () =>
jsonQuery<Record<string, unknown>>(
`${API_BASE}/invoices/order-data/${fromOrderId}`,
),
enabled: !!fromOrderId,
});
// ─── Edit mode state ─── // ─── Edit mode state ───
const [invoice, setInvoice] = useState<Invoice | null>(null);
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [statusChanging, setStatusChanging] = useState<string | null>(null); const [statusChanging, setStatusChanging] = useState<string | null>(null);
const [statusConfirm, setStatusConfirm] = useState<{ const [statusConfirm, setStatusConfirm] = useState<{
@@ -456,186 +492,69 @@ export default function InvoiceDetail() {
}; };
}, []); }, []);
// ─── Data loading ─── // ─── Sync query data to form state ───
// Edit mode: populate form from invoice data
useEffect(() => { useEffect(() => {
if (isEdit) return; if (!isEdit || dataReady) return;
const load = async () => { if (
try { invoiceQuery.isLoading ||
const promises = [ bankAccountsQuery.isLoading ||
apiFetch(`${API_BASE}/invoices/next-number`), customersQuery.isLoading
apiFetch(`${API_BASE}/customers`), )
apiFetch(`${API_BASE}/bank-accounts`), return;
]; if (!invoiceQuery.data) return;
if (fromOrderId) {
promises.push(
apiFetch(`${API_BASE}/invoices/order-data/${fromOrderId}`),
);
}
const results = await Promise.all(promises); const inv = invoiceQuery.data as Record<string, unknown>;
const numRes = results[0]; // Match bank account from invoice's bank details
if (numRes.ok) {
const numData = await numRes.json();
if (numData.success)
setInvoiceNumber(
numData.data?.next_number || numData.data?.number || "",
);
}
const custRes = results[1];
if (custRes.ok) {
const custData = await custRes.json();
if (custData.success)
setCustomers(
Array.isArray(custData.data)
? custData.data
: custData.data?.customers || [],
);
}
const bankRes = results[2];
if (bankRes.ok) {
const bankData = await bankRes.json();
if (bankData.success && Array.isArray(bankData.data)) {
setBankAccounts(bankData.data);
const defaultAcc = bankData.data.find(
(a: BankAccount) => a.is_default,
);
if (defaultAcc) {
setForm((prev) => ({
...prev,
bank_account_id: defaultAcc.id,
bank_name: defaultAcc.bank_name || "",
bank_swift: defaultAcc.bic || "",
bank_iban: defaultAcc.iban || "",
bank_account: defaultAcc.account_number || "",
}));
}
}
}
// Pre-fill from order
if (fromOrderId && results[3]?.ok) {
const orderData = await results[3].json();
if (orderData.success) {
const order = orderData.data;
const vatRate =
Number(order.vat_rate) ||
(companySettings?.default_vat_rate ?? 21);
setForm((prev) => ({
...prev,
customer_id: order.customer_id,
customer_name: order.customer_name || "",
order_id: order.id,
currency:
order.currency || companySettings?.default_currency || "CZK",
apply_vat: Number(order.apply_vat) || 0,
vat_rate: vatRate,
}));
if (order.items?.length > 0) {
setItems(
order.items.map((item: Record<string, unknown>) => ({
_key: `inv-${++keyCounterRef.current}`,
description: (item.description as string) || "",
quantity: Number(item.quantity) || 1,
unit: (item.unit as string) || "",
unit_price: Number(item.unit_price) || 0,
vat_rate: vatRate,
})),
);
}
}
}
} catch {
alert.error("Chyba při načítání dat");
} finally {
setLoading(false);
}
};
load();
}, [isEdit, fromOrderId, alert]);
// Edit mode: load existing invoice
const fetchDetail = useCallback(async () => {
if (!id) return;
try {
const [response, custRes, bankRes] = await Promise.all([
apiFetch(`${API_BASE}/invoices/${id}`),
apiFetch(`${API_BASE}/customers`),
apiFetch(`${API_BASE}/bank-accounts`),
]);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
const inv = result.data;
setInvoice(inv);
setNotes(inv.notes || "");
setInvoiceNumber(inv.invoice_number || "");
// Populate customers list
if (custRes.ok) {
const custData = await custRes.json();
if (custData.success)
setCustomers(
Array.isArray(custData.data)
? custData.data
: custData.data?.customers || [],
);
}
// Populate bank accounts and match existing
let matchedBankId: number | string = ""; let matchedBankId: number | string = "";
if (bankRes.ok) { const bankData = bankAccountsQuery.data ?? [];
const bankData = await bankRes.json(); if (Array.isArray(bankData)) {
if (bankData.success && Array.isArray(bankData.data)) { const match = bankData.find(
setBankAccounts(bankData.data);
// Match by IBAN or account number
const match = bankData.data.find(
(b: BankAccount) => (b: BankAccount) =>
(inv.bank_iban && b.iban === inv.bank_iban) || (inv.bank_iban && b.iban === inv.bank_iban) ||
(inv.bank_account && b.account_number === inv.bank_account), (inv.bank_account && b.account_number === inv.bank_account),
); );
if (match) matchedBankId = match.id; if (match) matchedBankId = match.id;
} }
}
// Populate form state from existing invoice
const formData = { const formData = {
customer_id: inv.customer_id || null, customer_id: (inv.customer_id as number) || null,
customer_name: inv.customer_name || "", customer_name: (inv.customer_name as string) || "",
order_id: inv.order_id || null, order_id: (inv.order_id as number) || null,
issue_date: inv.issue_date issue_date: inv.issue_date
? new Date(inv.issue_date).toISOString().split("T")[0] ? new Date(inv.issue_date as string).toISOString().split("T")[0]
: "", : "",
due_date: inv.due_date due_date: inv.due_date
? new Date(inv.due_date).toISOString().split("T")[0] ? new Date(inv.due_date as string).toISOString().split("T")[0]
: "", : "",
tax_date: inv.tax_date tax_date: inv.tax_date
? new Date(inv.tax_date).toISOString().split("T")[0] ? new Date(inv.tax_date as string).toISOString().split("T")[0]
: "", : "",
currency: inv.currency || "CZK", currency: (inv.currency as string) || "CZK",
apply_vat: Number(inv.apply_vat) ? 1 : 0, apply_vat: Number(inv.apply_vat) ? 1 : 0,
vat_rate: Number(inv.vat_rate) || 21, vat_rate: Number(inv.vat_rate) || 21,
payment_method: inv.payment_method || "Příkazem", payment_method: (inv.payment_method as string) || "Příkazem",
constant_symbol: inv.constant_symbol || "0308", constant_symbol: (inv.constant_symbol as string) || "0308",
issued_by: inv.issued_by || "", issued_by: (inv.issued_by as string) || "",
billing_text: inv.billing_text || "", billing_text: (inv.billing_text as string) || "",
notes: inv.notes || "", notes: (inv.notes as string) || "",
language: inv.language || "cs", language: (inv.language as string) || "cs",
bank_account_id: matchedBankId, bank_account_id: matchedBankId,
bank_name: inv.bank_name || "", bank_name: (inv.bank_name as string) || "",
bank_swift: inv.bank_swift || "", bank_swift: (inv.bank_swift as string) || "",
bank_iban: inv.bank_iban || "", bank_iban: (inv.bank_iban as string) || "",
bank_account: inv.bank_account || "", bank_account: (inv.bank_account as string) || "",
}; };
setForm(formData); setForm(formData);
setNotes((inv.notes as string) || "");
setInvoiceNumber((inv.invoice_number as string) || "");
// Calculate dueDays from existing dates // Calculate dueDays from existing dates
if (inv.issue_date && inv.due_date) { if (inv.issue_date && inv.due_date) {
const issue = new Date(inv.issue_date); const issue = new Date(inv.issue_date as string);
const due = new Date(inv.due_date); const due = new Date(inv.due_date as string);
const diffDays = Math.round( const diffDays = Math.round(
(due.getTime() - issue.getTime()) / (1000 * 60 * 60 * 24), (due.getTime() - issue.getTime()) / (1000 * 60 * 60 * 24),
); );
@@ -643,9 +562,10 @@ export default function InvoiceDetail() {
} }
// Populate items from existing invoice // Populate items from existing invoice
const invItems = inv.items as Record<string, unknown>[] | undefined;
const mappedItems = const mappedItems =
inv.items?.length > 0 invItems && invItems.length > 0
? inv.items.map((item: Record<string, unknown>) => ({ ? invItems.map((item) => ({
_key: `inv-${++keyCounterRef.current}`, _key: `inv-${++keyCounterRef.current}`,
id: item.id as number | undefined, id: item.id as number | undefined,
description: (item.description as string) || "", description: (item.description as string) || "",
@@ -664,26 +584,99 @@ export default function InvoiceDetail() {
form: formData, form: formData,
items: mappedItems, items: mappedItems,
}); });
} else {
alert.error(result.error || "Nepodařilo se načíst fakturu");
navigate("/invoices");
}
} catch {
alert.error("Chyba připojení");
navigate("/invoices");
} finally {
setLoading(false);
}
}, [id, alert, navigate]);
setDataReady(true);
}, [
isEdit,
dataReady,
invoiceQuery.isLoading,
invoiceQuery.data,
bankAccountsQuery.isLoading,
bankAccountsQuery.data,
customersQuery.isLoading,
]); // eslint-disable-line react-hooks/exhaustive-deps
// Create mode: populate form from query data
useEffect(() => { useEffect(() => {
if (isEdit) fetchDetail(); if (isEdit || dataReady) return;
}, [isEdit, fetchDetail]); if (
nextNumberQuery.isLoading ||
bankAccountsQuery.isLoading ||
customersQuery.isLoading
)
return;
if (fromOrderId && orderDataQuery.isLoading) return;
// Capture initial snapshot for dirty-checking once data finishes loading. // Set invoice number
// Edit mode: captured inside fetchDetail from raw API data. if (nextNumberQuery.data) {
// Create mode: captured on first stable render after loading completes. setInvoiceNumber(nextNumberQuery.data);
if (!loading && !initialSnapshotRef.current) { }
// Set default bank account
const defaultAcc = bankAccounts.find((a: BankAccount) => a.is_default);
if (defaultAcc) {
setForm((prev) => ({
...prev,
bank_account_id: defaultAcc.id,
bank_name: defaultAcc.bank_name || "",
bank_swift: defaultAcc.bic || "",
bank_iban: defaultAcc.iban || "",
bank_account: defaultAcc.account_number || "",
}));
}
// Pre-fill from order
if (fromOrderId && orderDataQuery.data) {
const order = orderDataQuery.data;
const vatRate =
Number(order.vat_rate) || (companySettings?.default_vat_rate ?? 21);
setForm((prev) => ({
...prev,
customer_id: order.customer_id as number,
customer_name: (order.customer_name as string) || "",
order_id: order.id as number,
currency:
(order.currency as string) ||
companySettings?.default_currency ||
"CZK",
apply_vat: Number(order.apply_vat) || 0,
vat_rate: vatRate,
}));
const orderItems = order.items as Record<string, unknown>[] | undefined;
if (orderItems && orderItems.length > 0) {
setItems(
orderItems.map((item) => ({
_key: `inv-${++keyCounterRef.current}`,
description: (item.description as string) || "",
quantity: Number(item.quantity) || 1,
unit: (item.unit as string) || "",
unit_price: Number(item.unit_price) || 0,
vat_rate: vatRate,
})),
);
}
}
setDataReady(true);
}, [
isEdit,
dataReady,
nextNumberQuery.isLoading,
nextNumberQuery.data,
bankAccountsQuery.isLoading,
bankAccountsQuery.data,
customersQuery.isLoading,
fromOrderId,
orderDataQuery.isLoading,
orderDataQuery.data,
companySettings,
bankAccounts,
]); // eslint-disable-line react-hooks/exhaustive-deps
// Capture initial snapshot for dirty-checking once data sync completes.
// Edit mode: captured inside the sync effect from raw query data.
// Create mode: captured on the first render after sync effects populate the form.
if (dataReady && !initialSnapshotRef.current) {
initialSnapshotRef.current = JSON.stringify({ form, items }); initialSnapshotRef.current = JSON.stringify({ form, items });
} }
@@ -866,9 +859,12 @@ export default function InvoiceDetail() {
); );
initialSnapshotRef.current = JSON.stringify({ form, items }); initialSnapshotRef.current = JSON.stringify({ form, items });
if (isEdit) { if (isEdit) {
fetchDetail(); queryClient.invalidateQueries({ queryKey: ["invoices", id] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
} else { } else {
navigate(`/invoices/${result.data.invoice_id}`); navigate(`/invoices/${result.data.invoice_id}`);
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} }
} else { } else {
alert.error( alert.error(
@@ -899,7 +895,8 @@ export default function InvoiceDetail() {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
alert.success(result.message || "Stav byl změněn"); alert.success(result.message || "Stav byl změněn");
fetchDetail(); queryClient.invalidateQueries({ queryKey: ["invoices", id] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
} else { } else {
alert.error(result.error || "Nepodařilo se změnit stav"); alert.error(result.error || "Nepodařilo se změnit stav");
} }
@@ -944,6 +941,8 @@ export default function InvoiceDetail() {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
alert.success(result.message || "Faktura byla smazána"); alert.success(result.message || "Faktura byla smazána");
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
navigate("/invoices"); navigate("/invoices");
} else { } else {
alert.error(result.error || "Nepodařilo se smazat fakturu"); alert.error(result.error || "Nepodařilo se smazat fakturu");
@@ -960,53 +959,6 @@ export default function InvoiceDetail() {
if (!isEdit && !hasPermission("invoices.create")) return <Forbidden />; if (!isEdit && !hasPermission("invoices.create")) return <Forbidden />;
if (isEdit && !hasPermission("invoices.view")) return <Forbidden />; if (isEdit && !hasPermission("invoices.view")) return <Forbidden />;
// ─── Loading skeleton ───
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div className="flex-row-gap">
{isEdit && (
<div
className="admin-skeleton-line"
style={{ width: "32px", height: "32px", borderRadius: "8px" }}
/>
)}
<div
className="admin-skeleton-line h-8"
style={{ width: "200px" }}
/>
</div>
{isEdit && (
<div className="admin-skeleton-row gap-2">
<div
className="admin-skeleton-line h-10"
style={{ width: "100px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-10"
style={{ width: "100px", borderRadius: "8px" }}
/>
</div>
)}
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/2" />
</div>
))}
</div>
</div>
</div>
);
}
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// PAID INVOICE — read-only view // PAID INVOICE — read-only view
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
@@ -1016,6 +968,11 @@ export default function InvoiceDetail() {
if (isPaid && invoice) { if (isPaid && invoice) {
return ( return (
<Skeleton
name="invoice-detail"
loading={!dataReady}
fixture={<InvoiceDetailFixture />}
>
<div> <div>
{/* Header */} {/* Header */}
<motion.div <motion.div
@@ -1228,14 +1185,19 @@ export default function InvoiceDetail() {
<td className="text-center"> <td className="text-center">
{item.quantity}{" "} {item.quantity}{" "}
{item.unit && ( {item.unit && (
<span className="text-tertiary">{item.unit}</span> <span className="text-tertiary">
{item.unit}
</span>
)} )}
</td> </td>
<td className="text-center"> <td className="text-center">
{item.unit || "\u2014"} {item.unit || "\u2014"}
</td> </td>
<td className="admin-mono text-right"> <td className="admin-mono text-right">
{formatCurrency(item.unit_price, invoice.currency)} {formatCurrency(
item.unit_price,
invoice.currency,
)}
</td> </td>
<td className="text-center"> <td className="text-center">
{Number(invoice.apply_vat) {Number(invoice.apply_vat)
@@ -1267,12 +1229,14 @@ export default function InvoiceDetail() {
</span> </span>
</div> </div>
{Number(invoice.apply_vat) > 0 && {Number(invoice.apply_vat) > 0 &&
Object.entries(createTotals.vatByRate).map(([rate, amount]) => ( Object.entries(createTotals.vatByRate).map(
([rate, amount]) => (
<div key={rate} className="admin-totals-row"> <div key={rate} className="admin-totals-row">
<span>DPH {rate}%:</span> <span>DPH {rate}%:</span>
<span>{formatCurrency(amount, invoice.currency)}</span> <span>{formatCurrency(amount, invoice.currency)}</span>
</div> </div>
))} ),
)}
<div className="admin-totals-row admin-totals-total"> <div className="admin-totals-row admin-totals-total">
<span>Celkem k úhradě:</span> <span>Celkem k úhradě:</span>
<span> <span>
@@ -1313,6 +1277,7 @@ export default function InvoiceDetail() {
loading={deleting} loading={deleting}
/> />
</div> </div>
</Skeleton>
); );
} }
@@ -1320,6 +1285,11 @@ export default function InvoiceDetail() {
// CREATE MODE + EDIT (not paid) — shared form // CREATE MODE + EDIT (not paid) — shared form
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
return ( return (
<Skeleton
name="invoice-detail"
loading={!dataReady}
fixture={<InvoiceDetailFixture />}
>
<div> <div>
<motion.div <motion.div
className="admin-page-header" className="admin-page-header"
@@ -1473,7 +1443,11 @@ export default function InvoiceDetail() {
}} }}
/> />
</FormField> </FormField>
<FormField label="Odběratel" error={errors.customer_id} required> <FormField
label="Odběratel"
error={errors.customer_id}
required
>
{form.customer_id ? ( {form.customer_id ? (
<div className="admin-customer-selected"> <div className="admin-customer-selected">
<span>{form.customer_name}</span> <span>{form.customer_name}</span>
@@ -1815,15 +1789,19 @@ export default function InvoiceDetail() {
</span> </span>
</div> </div>
{form.apply_vat && {form.apply_vat &&
Object.entries(createTotals.vatByRate).map(([rate, amount]) => ( Object.entries(createTotals.vatByRate).map(
([rate, amount]) => (
<div key={rate} className="admin-totals-row"> <div key={rate} className="admin-totals-row">
<span>DPH {rate}%:</span> <span>DPH {rate}%:</span>
<span>{formatCurrency(amount, form.currency)}</span> <span>{formatCurrency(amount, form.currency)}</span>
</div> </div>
))} ),
)}
<div className="admin-totals-row admin-totals-total"> <div className="admin-totals-row admin-totals-total">
<span>Celkem k úhradě:</span> <span>Celkem k úhradě:</span>
<span>{formatCurrency(createTotals.total, form.currency)}</span> <span>
{formatCurrency(createTotals.total, form.currency)}
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -1879,5 +1857,6 @@ export default function InvoiceDetail() {
</> </>
)} )}
</div> </div>
</Skeleton>
); );
} }

View File

@@ -1,11 +1,5 @@
import { import { useState, useEffect, useRef, lazy, Suspense } from "react";
useState, import { useQuery, useQueryClient } from "@tanstack/react-query";
useEffect,
useCallback,
useRef,
lazy,
Suspense,
} from "react";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { Link, useSearchParams } from "react-router-dom"; import { Link, useSearchParams } from "react-router-dom";
@@ -17,8 +11,18 @@ import apiFetch from "../utils/api";
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters"; import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
import SortIcon from "../components/SortIcon"; import SortIcon from "../components/SortIcon";
import useTableSort from "../hooks/useTableSort"; import useTableSort from "../hooks/useTableSort";
import useListData from "../hooks/useListData"; import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import {
invoiceListOptions,
invoiceStatsOptions,
type Invoice,
type InvoiceStats,
type CurrencyAmount,
} from "../lib/queries/invoices";
import Pagination from "../components/Pagination"; import Pagination from "../components/Pagination";
import { Skeleton } from "boneyard-js/react";
import InvoicesFixture from "../fixtures/InvoicesFixture";
import ReceivedInvoicesFixture from "../fixtures/ReceivedInvoicesFixture";
const ReceivedInvoices = lazy(() => import("./ReceivedInvoices")); const ReceivedInvoices = lazy(() => import("./ReceivedInvoices"));
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
@@ -39,11 +43,6 @@ const MONTH_NAMES = [
"prosinec", "prosinec",
]; ];
interface CurrencyAmount {
amount: number;
currency: string;
}
function formatMultiCurrency(amounts: CurrencyAmount[]): string { function formatMultiCurrency(amounts: CurrencyAmount[]): string {
if (!Array.isArray(amounts) || amounts.length === 0) return "0 Kč"; if (!Array.isArray(amounts) || amounts.length === 0) return "0 Kč";
return amounts.map((a) => formatCurrency(a.amount, a.currency)).join(" · "); return amounts.map((a) => formatCurrency(a.amount, a.currency)).join(" · ");
@@ -84,31 +83,6 @@ const STATUS_FILTERS = [
{ value: "overdue", label: "Po splatnosti" }, { value: "overdue", label: "Po splatnosti" },
]; ];
interface Invoice {
id: number;
invoice_number: string;
customer_name: string | null;
status: string;
issue_date: string;
due_date: string;
total: number;
currency: string;
}
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;
}
interface DraftData { interface DraftData {
form: Record<string, unknown>; form: Record<string, unknown>;
items: Record<string, unknown>[]; items: Record<string, unknown>[];
@@ -134,8 +108,6 @@ export default function Invoices() {
const now = new Date(); const now = new Date();
const [statsMonth, setStatsMonth] = useState(now.getMonth() + 1); const [statsMonth, setStatsMonth] = useState(now.getMonth() + 1);
const [statsYear, setStatsYear] = useState(now.getFullYear()); const [statsYear, setStatsYear] = useState(now.getFullYear());
const [stats, setStats] = useState<InvoiceStats | null>(null);
const [statsLoading, setStatsLoading] = useState(true);
const hasLoadedOnce = useRef(false); const hasLoadedOnce = useRef(false);
const slideDirection = useRef(0); const slideDirection = useRef(0);
const blobUrlRef = useRef<string | null>(null); const blobUrlRef = useRef<string | null>(null);
@@ -154,28 +126,15 @@ export default function Invoices() {
statsMonth === now.getMonth() + 1 && statsYear === now.getFullYear(); statsMonth === now.getMonth() + 1 && statsYear === now.getFullYear();
const monthLabel = `${MONTH_NAMES[statsMonth - 1]} ${statsYear}`; const monthLabel = `${MONTH_NAMES[statsMonth - 1]} ${statsYear}`;
const fetchStats = useCallback(async () => { const statsQuery = useQuery(invoiceStatsOptions(statsMonth, statsYear));
setStatsLoading(true); const stats = statsQuery.data ?? null;
try {
const res = await apiFetch( useEffect(() => {
`${API_BASE}/invoices/stats?month=${statsMonth}&year=${statsYear}`, if (statsQuery.data) {
);
const data = await res.json();
if (data.success) {
setStats(data.data);
hasLoadedOnce.current = true; hasLoadedOnce.current = true;
setSlideKey((k) => k + 1); setSlideKey((k) => k + 1);
} }
} catch { }, [statsQuery.data]);
/* ignore */
} finally {
setStatsLoading(false);
}
}, [statsMonth, statsYear]);
useEffect(() => {
fetchStats();
}, [fetchStats]);
const prevMonth = () => { const prevMonth = () => {
slideDirection.current = -1; slideDirection.current = -1;
@@ -225,24 +184,23 @@ export default function Invoices() {
setDraft(null); setDraft(null);
}; };
const queryClient = useQueryClient();
const { const {
items: invoices, items: invoices,
loading,
initialLoad,
pagination, pagination,
refetch: fetchData, isPending: initialLoad,
} = useListData<Invoice>("invoices", { isFetching: loading,
} = usePaginatedQuery<Invoice>(
invoiceListOptions({
search, search,
sort, sort,
order, order,
page, page,
extraParams: { month: statsMonth,
month: String(statsMonth), year: statsYear,
year: String(statsYear), status: statusFilter || undefined,
...(statusFilter ? { status: statusFilter } : {}), }),
}, );
errorMsg: "Nepodařilo se načíst faktury",
});
if (!hasPermission("invoices.view")) return <Forbidden />; if (!hasPermission("invoices.view")) return <Forbidden />;
@@ -260,8 +218,8 @@ export default function Invoices() {
if (result.success) { if (result.success) {
setDeleteConfirm({ show: false, invoice: null }); setDeleteConfirm({ show: false, invoice: null });
alert.success(result.message || "Faktura byla smazána"); alert.success(result.message || "Faktura byla smazána");
fetchData(); queryClient.invalidateQueries({ queryKey: ["invoices"] });
fetchStats(); queryClient.invalidateQueries({ queryKey: ["orders"] });
} else { } else {
alert.error(result.error || "Nepodařilo se smazat fakturu"); alert.error(result.error || "Nepodařilo se smazat fakturu");
} }
@@ -283,8 +241,8 @@ export default function Invoices() {
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
alert.success("Faktura označena jako zaplacená"); alert.success("Faktura označena jako zaplacená");
fetchData(); queryClient.invalidateQueries({ queryKey: ["invoices"] });
fetchStats(); queryClient.invalidateQueries({ queryKey: ["orders"] });
} else { } else {
alert.error(data.error || "Nepodařilo se změnit stav"); alert.error(data.error || "Nepodařilo se změnit stav");
} }
@@ -323,81 +281,13 @@ export default function Invoices() {
if (initialLoad) { if (initialLoad) {
return ( return (
<div> <Skeleton
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}> name="invoices"
<div loading={initialLoad}
className="admin-skeleton-row" fixture={<InvoicesFixture />}
style={{ justifyContent: "space-between" }}
> >
<div> <div />
<div </Skeleton>
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
<div
className="admin-skeleton-line h-10"
style={{ width: "140px", borderRadius: "8px" }}
/>
</div>
<div className="admin-kpi-grid admin-kpi-4">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-stat-card">
<div
className="admin-skeleton-line"
style={{
width: "60%",
height: "11px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{
width: "40%",
height: "28px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{ width: "50%", height: "12px" }}
/>
</div>
))}
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div
className="admin-skeleton-line"
style={{ width: "80px" }}
/>
<div className="admin-skeleton-line w-1/4" />
<div
className="admin-skeleton-line"
style={{ width: "70px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "90px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "90px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "100px" }}
/>
</div>
))}
</div>
</div>
</div>
</div>
); );
} }
@@ -528,35 +418,13 @@ export default function Invoices() {
> >
<Suspense <Suspense
fallback={ fallback={
<div <Skeleton
className="admin-kpi-grid admin-kpi-4" name="invoices-received-kpi"
style={{ marginBottom: "1.5rem" }} loading={true}
fixture={<ReceivedInvoicesFixture />}
> >
{[0, 1, 2, 3].map((i) => ( <div />
<div key={i} className="admin-stat-card"> </Skeleton>
<div
className="admin-skeleton-line"
style={{
width: "60%",
height: "11px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{
width: "40%",
height: "28px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{ width: "50%", height: "12px" }}
/>
</div>
))}
</div>
} }
> >
<ReceivedInvoices <ReceivedInvoices
@@ -574,36 +442,14 @@ export default function Invoices() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.1 }} transition={{ duration: 0.25, delay: 0.1 }}
> >
{!hasLoadedOnce.current && statsLoading ? ( {statsQuery.isPending && !hasLoadedOnce.current ? (
<div <Skeleton
className="admin-kpi-grid admin-kpi-4" name="invoices-kpi"
style={{ marginBottom: "1.5rem" }} loading={statsQuery.isPending && !hasLoadedOnce.current}
fixture={<InvoicesFixture />}
> >
{[0, 1, 2, 3].map((i) => ( <div />
<div key={i} className="admin-stat-card"> </Skeleton>
<div
className="admin-skeleton-line"
style={{
width: "60%",
height: "11px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{
width: "40%",
height: "28px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{ width: "50%", height: "12px" }}
/>
</div>
))}
</div>
) : ( ) : (
stats && ( stats && (
<div style={{ overflow: "hidden", marginBottom: "1.5rem" }}> <div style={{ overflow: "hidden", marginBottom: "1.5rem" }}>

View File

@@ -1,14 +1,21 @@
import { useState, useEffect, useCallback } from "react"; import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { formatDate, formatDatetime } from "../utils/attendanceHelpers"; import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
import apiFetch from "../utils/api"; import apiFetch from "../utils/api";
import { czechPlural } from "../utils/formatters"; import { czechPlural } from "../utils/formatters";
import {
leavePendingOptions,
leaveProcessedOptions,
} from "../lib/queries/leave";
import ConfirmModal from "../components/ConfirmModal"; import ConfirmModal from "../components/ConfirmModal";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import useModalLock from "../hooks/useModalLock"; import useModalLock from "../hooks/useModalLock";
import FormField from "../components/FormField"; import FormField from "../components/FormField";
import { Skeleton } from "boneyard-js/react";
import LeaveApprovalFixture from "../fixtures/LeaveApprovalFixture";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
@@ -101,15 +108,24 @@ function mapLeaveRequest(raw: RawLeaveRequest): LeaveRequest {
export default function LeaveApproval() { export default function LeaveApproval() {
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const alert = useAlert(); const alert = useAlert();
const [loading, setLoading] = useState(true); const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<"pending" | "processed">( const [activeTab, setActiveTab] = useState<"pending" | "processed">(
"pending", "pending",
); );
const [pendingRequests, setPendingRequests] = useState<LeaveRequest[]>([]); const { data: pendingData, isPending: loading } = useQuery(
const [pendingCount, setPendingCount] = useState(0); leavePendingOptions(),
const [processedRequests, setProcessedRequests] = useState<LeaveRequest[]>(
[],
); );
const { data: processedData } = useQuery({
...leaveProcessedOptions(),
enabled: activeTab === "processed",
});
const pendingRequests =
(pendingData as RawLeaveRequest[] | undefined)?.map(mapLeaveRequest) ?? [];
const pendingCount = pendingRequests.length;
const processedRequests =
(processedData as RawLeaveRequest[] | undefined)?.map(mapLeaveRequest) ??
[];
const [approveModal, setApproveModal] = useState<{ const [approveModal, setApproveModal] = useState<{
open: boolean; open: boolean;
request: LeaveRequest | null; request: LeaveRequest | null;
@@ -123,67 +139,6 @@ export default function LeaveApproval() {
useModalLock(rejectModal.open); useModalLock(rejectModal.open);
const fetchPending = useCallback(async () => {
try {
const response = await apiFetch(
`${API_BASE}/leave-requests?status=pending`,
);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
const mapped = (result.data as RawLeaveRequest[]).map(mapLeaveRequest);
setPendingRequests(mapped);
setPendingCount(result.pagination?.total ?? mapped.length);
}
} catch {
alert.error("Nepodařilo se načíst žádosti");
}
}, [alert]);
const fetchProcessed = useCallback(async () => {
try {
const response = await apiFetch(
`${API_BASE}/leave-requests?status=approved`,
);
if (response.status === 401) return;
const resultApproved = await response.json();
const response2 = await apiFetch(
`${API_BASE}/leave-requests?status=rejected`,
);
if (response2.status === 401) return;
const resultRejected = await response2.json();
const all = [
...(resultApproved.success
? (resultApproved.data as RawLeaveRequest[]).map(mapLeaveRequest)
: []),
...(resultRejected.success
? (resultRejected.data as RawLeaveRequest[]).map(mapLeaveRequest)
: []),
].sort(
(a: LeaveRequest, b: LeaveRequest) =>
(b.reviewed_at ? new Date(b.reviewed_at).getTime() : 0) -
(a.reviewed_at ? new Date(a.reviewed_at).getTime() : 0),
);
setProcessedRequests(all);
} catch {
alert.error("Nepodařilo se načíst vyřízené žádosti");
}
}, [alert]);
useEffect(() => {
setLoading(true);
fetchPending().finally(() => setLoading(false));
}, [fetchPending]);
useEffect(() => {
if (activeTab === "processed" && processedRequests.length === 0) {
fetchProcessed();
}
}, [activeTab, processedRequests.length, fetchProcessed]);
if (!hasPermission("attendance.approve")) return <Forbidden />; if (!hasPermission("attendance.approve")) return <Forbidden />;
const handleApprove = async () => { const handleApprove = async () => {
@@ -202,8 +157,7 @@ export default function LeaveApproval() {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
setApproveModal({ open: false, request: null }); setApproveModal({ open: false, request: null });
await fetchPending(); await queryClient.invalidateQueries({ queryKey: ["leave"] });
setProcessedRequests([]);
alert.success("Žádost byla schválena"); alert.success("Žádost byla schválena");
} else { } else {
alert.error(result.error); alert.error(result.error);
@@ -240,8 +194,7 @@ export default function LeaveApproval() {
if (result.success) { if (result.success) {
setRejectModal({ open: false, request: null }); setRejectModal({ open: false, request: null });
setRejectNote(""); setRejectNote("");
await fetchPending(); await queryClient.invalidateQueries({ queryKey: ["leave"] });
setProcessedRequests([]);
alert.success("Žádost byla zamítnuta"); alert.success("Žádost byla zamítnuta");
} else { } else {
alert.error(result.error); alert.error(result.error);
@@ -253,43 +206,12 @@ export default function LeaveApproval() {
} }
}; };
if (loading) {
return ( return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}> <Skeleton
<div name="leave-approval"
className="admin-skeleton-row" loading={loading}
style={{ justifyContent: "space-between" }} fixture={<LeaveApprovalFixture />}
> >
<div>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].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/3 mb-2" />
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
);
}
return (
<div> <div>
<motion.div <motion.div
className="admin-page-header" className="admin-page-header"
@@ -370,7 +292,11 @@ export default function LeaveApproval() {
</div> </div>
) : ( ) : (
<div <div
style={{ display: "flex", flexDirection: "column", gap: "1rem" }} style={{
display: "flex",
flexDirection: "column",
gap: "1rem",
}}
> >
{pendingRequests.map((req) => ( {pendingRequests.map((req) => (
<div key={req.id} className="admin-card"> <div key={req.id} className="admin-card">
@@ -395,7 +321,8 @@ export default function LeaveApproval() {
<span <span
className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ""}`} className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ""}`}
> >
{leaveTypeLabels[req.leave_type] || req.leave_type} {leaveTypeLabels[req.leave_type] ||
req.leave_type}
</span> </span>
</div> </div>
<div <div
@@ -413,8 +340,8 @@ export default function LeaveApproval() {
</span> </span>
<span> <span>
{req.total_days}{" "} {req.total_days}{" "}
{czechPlural(req.total_days, "den", "dny", "dnů")} ( {czechPlural(req.total_days, "den", "dny", "dnů")}{" "}
{req.total_hours}h) ({req.total_hours}h)
</span> </span>
<span className="text-muted"> <span className="text-muted">
Podáno: {formatDatetime(req.created_at)} Podáno: {formatDatetime(req.created_at)}
@@ -515,7 +442,8 @@ export default function LeaveApproval() {
<span <span
className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ""}`} className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ""}`}
> >
{leaveTypeLabels[req.leave_type] || req.leave_type} {leaveTypeLabels[req.leave_type] ||
req.leave_type}
</span> </span>
</td> </td>
<td className="admin-mono"> <td className="admin-mono">
@@ -650,5 +578,6 @@ export default function LeaveApproval() {
)} )}
</AnimatePresence> </AnimatePresence>
</div> </div>
</Skeleton>
); );
} }

View File

@@ -1,11 +1,15 @@
import { useState, useEffect, useCallback } from "react"; import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import { Skeleton } from "boneyard-js/react";
import LeaveRequestsFixture from "../fixtures/LeaveRequestsFixture";
import { formatDate, formatDatetime } from "../utils/attendanceHelpers"; import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
import apiFetch from "../utils/api"; import apiFetch from "../utils/api";
import ConfirmModal from "../components/ConfirmModal"; import ConfirmModal from "../components/ConfirmModal";
import { leaveRequestsOptions } from "../lib/queries/leave";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
@@ -51,33 +55,16 @@ interface LeaveRequest {
export default function LeaveRequests() { export default function LeaveRequests() {
const alert = useAlert(); const alert = useAlert();
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true); const queryClient = useQueryClient();
const [requests, setRequests] = useState<LeaveRequest[]>([]); const { data: requests = [], isPending } = useQuery(
leaveRequestsOptions(true),
) as { data: LeaveRequest[]; isPending: boolean };
const [cancelModal, setCancelModal] = useState<{ const [cancelModal, setCancelModal] = useState<{
open: boolean; open: boolean;
id: number | null; id: number | null;
}>({ open: false, id: null }); }>({ open: false, id: null });
const [cancelling, setCancelling] = useState(false); const [cancelling, setCancelling] = useState(false);
const fetchRequests = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/leave-requests?mine=1`);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setRequests(result.data);
}
} catch {
alert.error("Nepodařilo se načíst žádosti");
} finally {
setLoading(false);
}
}, [alert]);
useEffect(() => {
fetchRequests();
}, [fetchRequests]);
if (!hasPermission("attendance.record")) return <Forbidden />; if (!hasPermission("attendance.record")) return <Forbidden />;
const handleCancel = async () => { const handleCancel = async () => {
@@ -94,7 +81,7 @@ export default function LeaveRequests() {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
setCancelModal({ open: false, id: null }); setCancelModal({ open: false, id: null });
await fetchRequests(); queryClient.invalidateQueries({ queryKey: ["leave-requests"] });
alert.success(result.message); alert.success(result.message);
} else { } else {
alert.error(result.error); alert.error(result.error);
@@ -106,48 +93,6 @@ export default function LeaveRequests() {
} }
}; };
if (loading) {
return (
<div>
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
<div
className="admin-skeleton-line h-10"
style={{ width: "140px", borderRadius: "8px" }}
/>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].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/3 mb-2" />
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
</div>
);
}
function renderNoteCell(req: LeaveRequest) { function renderNoteCell(req: LeaveRequest) {
const truncate = (text: string) => const truncate = (text: string) =>
text.length > 40 ? `${text.substring(0, 40)}...` : text; text.length > 40 ? `${text.substring(0, 40)}...` : text;
@@ -176,6 +121,11 @@ export default function LeaveRequests() {
} }
return ( return (
<Skeleton
name="leave-requests"
loading={isPending}
fixture={<LeaveRequestsFixture />}
>
<div> <div>
<motion.div <motion.div
className="admin-page-header" className="admin-page-header"
@@ -185,7 +135,9 @@ export default function LeaveRequests() {
> >
<div> <div>
<h1 className="admin-page-title">Moje žádosti</h1> <h1 className="admin-page-title">Moje žádosti</h1>
<p className="admin-page-subtitle">Přehled žádostí o nepřítomnost</p> <p className="admin-page-subtitle">
Přehled žádostí o nepřítomnost
</p>
</div> </div>
</motion.div> </motion.div>
@@ -249,7 +201,9 @@ export default function LeaveRequests() {
<td className="admin-mono"> <td className="admin-mono">
{formatDate(req.date_from)} {formatDate(req.date_from)}
</td> </td>
<td className="admin-mono">{formatDate(req.date_to)}</td> <td className="admin-mono">
{formatDate(req.date_to)}
</td>
<td className="admin-mono">{req.total_days}</td> <td className="admin-mono">{req.total_days}</td>
<td className="admin-mono">{req.total_hours}h</td> <td className="admin-mono">{req.total_hours}h</td>
<td> <td>
@@ -300,5 +254,6 @@ export default function LeaveRequests() {
loading={cancelling} loading={cancelling}
/> />
</div> </div>
</Skeleton>
); );
} }

View File

@@ -6,10 +6,17 @@ import {
useRef, useRef,
type ChangeEvent, type ChangeEvent,
} from "react"; } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { useParams, useNavigate, Link } from "react-router-dom"; import { useParams, useNavigate, Link } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import {
offerDetailOptions,
offerCustomersOptions,
offerTemplatesOptions,
offerNextNumberOptions,
} from "../lib/queries/offers";
import { import {
DndContext, DndContext,
@@ -41,6 +48,9 @@ import useModalLock from "../hooks/useModalLock";
import useDebounce from "../hooks/useDebounce"; import useDebounce from "../hooks/useDebounce";
import apiFetch from "../utils/api"; import apiFetch from "../utils/api";
import { formatCurrency } from "../utils/formatters"; import { formatCurrency } from "../utils/formatters";
import { Skeleton } from "boneyard-js/react";
import OfferDetailFixture from "../fixtures/OfferDetailFixture";
import { companySettingsOptions } from "../lib/queries/settings";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
const DRAFT_KEY = "boha_offer_draft"; const DRAFT_KEY = "boha_offer_draft";
@@ -196,9 +206,7 @@ function SortableItemRow({
<input <input
type="number" type="number"
value={item.quantity} value={item.quantity}
onChange={(e) => onChange={(e) => onUpdate("quantity", e.target.value)}
onUpdate("quantity", parseFloat(e.target.value) || 0)
}
className="admin-form-input" className="admin-form-input"
step="1" step="1"
readOnly={readOnly} readOnly={readOnly}
@@ -217,9 +225,7 @@ function SortableItemRow({
<input <input
type="number" type="number"
value={item.unit_price} value={item.unit_price}
onChange={(e) => onChange={(e) => onUpdate("unit_price", e.target.value)}
onUpdate("unit_price", parseFloat(e.target.value) || 0)
}
className="admin-form-input" className="admin-form-input"
step="0.01" step="0.01"
readOnly={readOnly} readOnly={readOnly}
@@ -303,7 +309,44 @@ export default function OfferDetail() {
[], [],
); );
const [loading, setLoading] = useState(isEdit); const queryClient = useQueryClient();
// ---- TanStack Query hooks ----
const offerQuery = useQuery(offerDetailOptions(id));
const { data: customersData } = useQuery({
...offerCustomersOptions(),
enabled: !isEdit,
});
const { data: templatesData } = useQuery({
...offerTemplatesOptions(),
enabled: !isEdit,
});
const { data: nextNumberData } = useQuery({
...offerNextNumberOptions(),
enabled: !isEdit,
});
// Derive typed arrays from query data
const customers = (
Array.isArray(customersData)
? customersData
: (customersData as Record<string, unknown>)?.customers
? ((customersData as Record<string, unknown>).customers as Customer[])
: []
) as Customer[];
const scopeTemplates = (
Array.isArray(templatesData) ? templatesData : []
) as Array<{
id: number;
name: string;
description?: string;
scope_template_sections?: Array<{
title?: string;
title_cz?: string;
content?: string;
}>;
}>;
const loading = isEdit && offerQuery.isLoading;
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [errors, setErrors] = useState<Record<string, string | undefined>>({}); const [errors, setErrors] = useState<Record<string, string | undefined>>({});
const [form, setForm] = useState<OfferForm>(() => { const [form, setForm] = useState<OfferForm>(() => {
@@ -339,19 +382,6 @@ export default function OfferDetail() {
} }
return []; return [];
}); });
const [scopeTemplates, setScopeTemplates] = useState<
Array<{
id: number;
name: string;
description?: string;
scope_template_sections?: Array<{
title?: string;
title_cz?: string;
content?: string;
}>;
}>
>([]);
const [customers, setCustomers] = useState<Customer[]>([]);
const [customerSearch, setCustomerSearch] = useState(""); const [customerSearch, setCustomerSearch] = useState("");
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false); const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
const [orderInfo, setOrderInfo] = useState<OrderInfo | null>(null); const [orderInfo, setOrderInfo] = useState<OrderInfo | null>(null);
@@ -367,12 +397,31 @@ export default function OfferDetail() {
const [orderAttachment, setOrderAttachment] = useState<File | null>(null); const [orderAttachment, setOrderAttachment] = useState<File | null>(null);
const [pdfLoading, setPdfLoading] = useState(false); const [pdfLoading, setPdfLoading] = useState(false);
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]); const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const [companySettings, setCompanySettings] = useState<{ const companySettings = useQuery(companySettingsOptions()).data as unknown as
| {
default_currency: string; default_currency: string;
default_vat_rate: number; default_vat_rate: number;
available_currencies: string[]; available_currencies: string[];
available_vat_rates: number[]; available_vat_rates: number[];
} | null>(null); }
| undefined;
useEffect(() => {
if (companySettings && !isEdit) {
setForm((prev) => ({
...prev,
currency:
prev.currency === "CZK"
? companySettings.default_currency || "CZK"
: prev.currency,
vat_rate:
prev.vat_rate === 21
? (companySettings.default_vat_rate ?? 21)
: prev.vat_rate,
}));
}
}, [companySettings, isEdit]);
const [lockedBy, setLockedBy] = useState<{ const [lockedBy, setLockedBy] = useState<{
user_id: number; user_id: number;
username: string; username: string;
@@ -390,30 +439,6 @@ export default function OfferDetail() {
}; };
}, []); }, []);
useEffect(() => {
apiFetch(`${API_BASE}/company-settings`)
.then((r) => r.json())
.then((d) => {
if (d.success) {
setCompanySettings(d.data);
if (!isEdit) {
setForm((prev) => ({
...prev,
currency:
prev.currency === "CZK"
? d.data.default_currency || "CZK"
: prev.currency,
vat_rate:
prev.vat_rate === 21
? (d.data.default_vat_rate ?? 21)
: prev.vat_rate,
}));
}
}
})
.catch(() => {});
}, []);
const isInvalidated = offerStatus === "invalidated"; const isInvalidated = offerStatus === "invalidated";
const isLockedByOther = !!lockedBy; const isLockedByOther = !!lockedBy;
const isExpiredNotInvalidated = const isExpiredNotInvalidated =
@@ -423,41 +448,40 @@ export default function OfferDetail() {
form.valid_until && form.valid_until &&
new Date(form.valid_until) < new Date(new Date().toDateString()); new Date(form.valid_until) < new Date(new Date().toDateString());
const fetchDetail = useCallback(async () => { // Sync offer detail data to form state on first load (edit mode)
if (!id) return; const formInitializedRef = useRef(false);
try { useEffect(() => {
const response = await apiFetch(`${API_BASE}/offers/${id}`); if (!offerQuery.data || formInitializedRef.current) return;
if (response.status === 401) return; const d = offerQuery.data as Record<string, unknown>;
const result = await response.json(); const formData: OfferForm = {
if (result.success) { quotation_number: (d.quotation_number as string) || "",
const d = result.data; project_code: (d.project_code as string) || "",
const formData = { customer_id: (d.customer_id as number | null) ?? null,
quotation_number: d.quotation_number || "", customer_name: (d.customer_name as string) || "",
project_code: d.project_code || "",
customer_id: d.customer_id || null,
customer_name: d.customer_name || "",
created_at: d.created_at ? String(d.created_at).substring(0, 10) : "", created_at: d.created_at ? String(d.created_at).substring(0, 10) : "",
valid_until: d.valid_until valid_until: d.valid_until ? String(d.valid_until).substring(0, 10) : "",
? String(d.valid_until).substring(0, 10) currency:
: "", (d.currency as string) || companySettings?.default_currency || "CZK",
currency: d.currency || companySettings?.default_currency || "CZK", language: (d.language as string) || "EN",
language: d.language || "EN", vat_rate:
vat_rate: d.vat_rate ?? companySettings?.default_vat_rate ?? 21, (d.vat_rate as number) ?? companySettings?.default_vat_rate ?? 21,
apply_vat: !!d.apply_vat, apply_vat: !!d.apply_vat,
exchange_rate: d.exchange_rate || "", exchange_rate: (d.exchange_rate as string) || "",
scope_title: d.scope_title || "", scope_title: (d.scope_title as string) || "",
scope_description: d.scope_description || "", scope_description: (d.scope_description as string) || "",
}; };
setForm(formData); setForm(formData);
const mappedItems = d.items?.length const mappedItems =
? d.items.map((it: any) => ({ Array.isArray(d.items) && d.items.length
? (d.items as any[]).map((it: any) => ({
...it, ...it,
_key: `item-${++itemKeyCounter.current}`, _key: `item-${++itemKeyCounter.current}`,
})) }))
: [emptyItem()]; : [emptyItem()];
setItems(mappedItems); setItems(mappedItems);
const mappedSections = d.sections?.length const mappedSections =
? d.sections.map((s: any) => ({ Array.isArray(d.sections) && d.sections.length
? (d.sections as any[]).map((s: any) => ({
title: s.title || "", title: s.title || "",
title_cz: s.title_cz || "", title_cz: s.title_cz || "",
content: s.content || "", content: s.content || "",
@@ -469,9 +493,9 @@ export default function OfferDetail() {
items: mappedItems, items: mappedItems,
sections: mappedSections, sections: mappedSections,
}); });
setOfferStatus(d.status || ""); setOfferStatus((d.status as string) || "");
setOrderInfo(d.order || null); setOrderInfo((d.order as OrderInfo) ?? null);
setLockedBy(d.locked_by || null); setLockedBy((d.locked_by as typeof lockedBy) ?? null);
// Try to acquire lock if not locked by someone else and not invalidated // Try to acquire lock if not locked by someone else and not invalidated
if ( if (
@@ -483,17 +507,28 @@ export default function OfferDetail() {
() => {}, () => {},
); );
} }
} else { formInitializedRef.current = true;
alert.error(result.error || "Nepodařilo se načíst nabídku"); }, [offerQuery.data, companySettings, hasPermission, id, emptyItem]);
// Redirect on offer fetch error (edit mode)
useEffect(() => {
if (isEdit && offerQuery.isError) {
alert.error("Nepodařilo se načíst nabídku");
navigate("/offers"); navigate("/offers");
} }
} catch { }, [isEdit, offerQuery.isError, alert, navigate]);
alert.error("Chyba připojení");
navigate("/offers"); // Sync next-number data to form (create mode)
} finally { useEffect(() => {
setLoading(false); if (isEdit || !nextNumberData) return;
const num =
((nextNumberData as Record<string, unknown>).next_number as string) ||
((nextNumberData as Record<string, unknown>).number as string) ||
"";
if (num) {
setForm((prev) => ({ ...prev, quotation_number: num }));
} }
}, [id, alert, navigate, hasPermission, companySettings]); }, [isEdit, nextNumberData]);
// Heartbeat to keep lock alive + cleanup on unmount // Heartbeat to keep lock alive + cleanup on unmount
useEffect(() => { useEffect(() => {
@@ -518,10 +553,6 @@ export default function OfferDetail() {
}; };
}, [isEdit, id, isLockedByOther, isInvalidated]); }, [isEdit, id, isLockedByOther, isInvalidated]);
useEffect(() => {
if (isEdit) fetchDetail();
}, [isEdit, fetchDetail]);
// Capture initial snapshot after loading completes (create mode) // Capture initial snapshot after loading completes (create mode)
if (!loading && !initialSnapshotRef.current) { if (!loading && !initialSnapshotRef.current) {
initialSnapshotRef.current = JSON.stringify({ form, items, sections }); initialSnapshotRef.current = JSON.stringify({ form, items, sections });
@@ -544,36 +575,6 @@ export default function OfferDetail() {
return () => window.removeEventListener("beforeunload", handler); return () => window.removeEventListener("beforeunload", handler);
}, [isDirty]); }, [isDirty]);
useEffect(() => {
const loadCustomers = async () => {
try {
const res = await apiFetch(`${API_BASE}/customers`);
if (res.status === 401) return;
const data = await res.json();
if (data.success)
setCustomers(
Array.isArray(data.data) ? data.data : data.data?.customers || [],
);
} catch {
/* silent */
}
};
const loadScopeTemplates = async () => {
try {
const res = await apiFetch(`${API_BASE}/offers-templates`);
if (res.status === 401) return;
const data = await res.json();
if (data.success && Array.isArray(data.data)) {
setScopeTemplates(data.data);
}
} catch {
/* silent */
}
};
loadCustomers();
loadScopeTemplates();
}, []);
// Close dropdown on outside click // Close dropdown on outside click
useEffect(() => { useEffect(() => {
const handleClickOutside = () => setShowCustomerDropdown(false); const handleClickOutside = () => setShowCustomerDropdown(false);
@@ -583,26 +584,6 @@ export default function OfferDetail() {
} }
}, [showCustomerDropdown]); }, [showCustomerDropdown]);
useEffect(() => {
if (isEdit) return;
const fetchNextNumber = async () => {
try {
const res = await apiFetch(`${API_BASE}/offers/next-number`);
if (res.status === 401) return;
const data = await res.json();
if (data.success) {
setForm((prev) => ({
...prev,
quotation_number: data.data?.next_number || data.data?.number || "",
}));
}
} catch {
/* silent */
}
};
fetchNextNumber();
}, [isEdit]);
// Auto-save draft to localStorage (create mode only) // Auto-save draft to localStorage (create mode only)
const draftPayload = JSON.stringify({ form, items, sections }); const draftPayload = JSON.stringify({ form, items, sections });
const debouncedDraft = useDebounce(draftPayload, 1500); const debouncedDraft = useDebounce(draftPayload, 1500);
@@ -679,6 +660,7 @@ export default function OfferDetail() {
const handleSave = async () => { const handleSave = async () => {
const newErrors: Record<string, string> = {}; const newErrors: Record<string, string> = {};
if (!form.customer_id) newErrors.customer_id = "Zákazník je povinný";
if (!form.created_at) newErrors.created_at = "Datum je povinné"; if (!form.created_at) newErrors.created_at = "Datum je povinné";
if (!form.valid_until) newErrors.valid_until = "Platnost je povinná"; if (!form.valid_until) newErrors.valid_until = "Platnost je povinná";
if (items.length === 0) newErrors.items = "Přidejte alespoň jednu položku"; if (items.length === 0) newErrors.items = "Přidejte alespoň jednu položku";
@@ -728,6 +710,9 @@ export default function OfferDetail() {
items, items,
sections, sections,
}); });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
} }
} else { } else {
alert.error(result.error || "Nepodařilo se uložit nabídku"); alert.error(result.error || "Nepodařilo se uložit nabídku");
@@ -770,6 +755,9 @@ export default function OfferDetail() {
if (result.success) { if (result.success) {
setShowOrderModal(false); setShowOrderModal(false);
alert.success(result.message || "Objednávka byla vytvořena"); alert.success(result.message || "Objednávka byla vytvořena");
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
navigate(`/orders/${result.data.order_id}`); navigate(`/orders/${result.data.order_id}`);
} else { } else {
alert.error(result.error || "Nepodařilo se vytvořit objednávku"); alert.error(result.error || "Nepodařilo se vytvořit objednávku");
@@ -792,6 +780,9 @@ export default function OfferDetail() {
setInvalidateConfirm(false); setInvalidateConfirm(false);
setOfferStatus("invalidated"); setOfferStatus("invalidated");
alert.success(result.message || "Nabídka byla zneplatněna"); alert.success(result.message || "Nabídka byla zneplatněna");
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
} else { } else {
alert.error(result.error || "Nepodařilo se zneplatnit nabídku"); alert.error(result.error || "Nepodařilo se zneplatnit nabídku");
} }
@@ -811,6 +802,9 @@ export default function OfferDetail() {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
alert.success(result.message || "Nabídka byla smazána"); alert.success(result.message || "Nabídka byla smazána");
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
navigate("/offers"); navigate("/offers");
} else { } else {
alert.error(result.error || "Nepodařilo se smazat nabídku"); alert.error(result.error || "Nepodařilo se smazat nabídku");
@@ -858,49 +852,12 @@ export default function OfferDetail() {
const requiredPerm = getRequiredPerm(); const requiredPerm = getRequiredPerm();
if (!hasPermission(requiredPerm)) return <Forbidden />; if (!hasPermission(requiredPerm)) return <Forbidden />;
if (loading) {
return ( return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}> <Skeleton
<div name="offer-detail"
className="admin-skeleton-row" loading={loading}
style={{ justifyContent: "space-between" }} fixture={<OfferDetailFixture />}
> >
<div className="flex-row-gap">
<div
className="admin-skeleton-line"
style={{ width: "32px", height: "32px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px" }}
/>
</div>
<div className="admin-skeleton-row gap-2">
<div
className="admin-skeleton-line h-10"
style={{ width: "100px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-10"
style={{ width: "100px", borderRadius: "8px" }}
/>
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/2" />
</div>
))}
</div>
</div>
</div>
);
}
return (
<div> <div>
{/* Header */} {/* Header */}
<motion.div <motion.div
@@ -1098,7 +1055,7 @@ export default function OfferDetail() {
readOnly={isInvalidated || isLockedByOther} readOnly={isInvalidated || isLockedByOther}
/> />
</FormField> </FormField>
<FormField label="Zákazník" error={errors.customer_id}> <FormField label="Zákazník" error={errors.customer_id} required>
{form.customer_id ? ( {form.customer_id ? (
<div className="admin-customer-selected"> <div className="admin-customer-selected">
<span>{form.customer_name}</span> <span>{form.customer_name}</span>
@@ -1189,7 +1146,11 @@ export default function OfferDetail() {
/> />
)} )}
</FormField> </FormField>
<FormField label="Platnost do" error={errors.valid_until} required> <FormField
label="Platnost do"
error={errors.valid_until}
required
>
{isInvalidated || isLockedByOther ? ( {isInvalidated || isLockedByOther ? (
<input <input
type="text" type="text"
@@ -1203,7 +1164,10 @@ export default function OfferDetail() {
value={form.valid_until} value={form.valid_until}
onChange={(val: string) => { onChange={(val: string) => {
updateForm("valid_until", val); updateForm("valid_until", val);
setErrors((prev) => ({ ...prev, valid_until: undefined })); setErrors((prev) => ({
...prev,
valid_until: undefined,
}));
}} }}
/> />
)} )}
@@ -1257,7 +1221,9 @@ export default function OfferDetail() {
disabled={isInvalidated || isLockedByOther} disabled={isInvalidated || isLockedByOther}
> >
{( {(
companySettings?.available_vat_rates || [0, 10, 12, 15, 21] companySettings?.available_vat_rates || [
0, 10, 12, 15, 21,
]
).map((r) => ( ).map((r) => (
<option key={r} value={r}> <option key={r} value={r}>
{r}% {r}%
@@ -1268,7 +1234,9 @@ export default function OfferDetail() {
<input <input
type="checkbox" type="checkbox"
checked={form.apply_vat} checked={form.apply_vat}
onChange={(e) => updateForm("apply_vat", e.target.checked)} onChange={(e) =>
updateForm("apply_vat", e.target.checked)
}
disabled={isInvalidated || isLockedByOther} disabled={isInvalidated || isLockedByOther}
/> />
<span>Účtovat DPH</span> <span>Účtovat DPH</span>
@@ -1416,7 +1384,11 @@ export default function OfferDetail() {
<h3 className="admin-card-title">Rozsah projektu</h3> <h3 className="admin-card-title">Rozsah projektu</h3>
{!isInvalidated && !isLockedByOther && ( {!isInvalidated && !isLockedByOther && (
<div <div
style={{ display: "flex", gap: "0.5rem", alignItems: "center" }} style={{
display: "flex",
gap: "0.5rem",
alignItems: "center",
}}
> >
{scopeTemplates.length > 0 && ( {scopeTemplates.length > 0 && (
<select <select
@@ -1514,7 +1486,9 @@ export default function OfferDetail() {
{(form.language === "CZ" {(form.language === "CZ"
? section.title_cz ? section.title_cz
: section.title) && ( : section.title) && (
<span style={{ fontWeight: 400, marginLeft: "0.5rem" }}> <span
style={{ fontWeight: 400, marginLeft: "0.5rem" }}
>
{" "} {" "}
{form.language === "CZ" {form.language === "CZ"
? section.title_cz || section.title ? section.title_cz || section.title
@@ -1825,5 +1799,6 @@ export default function OfferDetail() {
loading={deleting} loading={deleting}
/> />
</div> </div>
</Skeleton>
); );
} }

View File

@@ -5,12 +5,16 @@ import { Link, useNavigate } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal"; import ConfirmModal from "../components/ConfirmModal";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import { Skeleton } from "boneyard-js/react";
import OffersFixture from "../fixtures/OffersFixture";
import apiFetch from "../utils/api"; import apiFetch from "../utils/api";
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters"; import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
import SortIcon from "../components/SortIcon"; import SortIcon from "../components/SortIcon";
import useTableSort from "../hooks/useTableSort"; import useTableSort from "../hooks/useTableSort";
import useListData from "../hooks/useListData"; import { useQueryClient } from "@tanstack/react-query";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import { offerListOptions } from "../lib/queries/offers";
import useModalLock from "../hooks/useModalLock"; import useModalLock from "../hooks/useModalLock";
import Pagination from "../components/Pagination"; import Pagination from "../components/Pagination";
import FormField from "../components/FormField"; import FormField from "../components/FormField";
@@ -95,19 +99,15 @@ export default function Offers() {
return null; return null;
}); });
const queryClient = useQueryClient();
const { const {
items: quotations, items: quotations,
loading,
initialLoad,
pagination, pagination,
refetch: fetchData, isPending,
} = useListData("offers", { isFetching,
search, } = usePaginatedQuery<Quotation>(
sort, offerListOptions({ search, sort, order, page }),
order, );
page,
errorMsg: "Nepodařilo se načíst nabídky",
});
const discardDraft = () => { const discardDraft = () => {
try { try {
@@ -139,7 +139,7 @@ export default function Offers() {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
alert.success(result.message || "Nabídka byla duplikována"); alert.success(result.message || "Nabídka byla duplikována");
fetchData(); queryClient.invalidateQueries({ queryKey: ["offers"] });
} else { } else {
alert.error(result.error || "Nepodařilo se duplikovat nabídku"); alert.error(result.error || "Nepodařilo se duplikovat nabídku");
} }
@@ -169,6 +169,9 @@ export default function Offers() {
setOrderModal({ show: false, quotation: null }); setOrderModal({ show: false, quotation: null });
alert.success(result.message || "Objednávka byla vytvořena"); alert.success(result.message || "Objednávka byla vytvořena");
navigate(`/orders/${result.data.order_id}`); navigate(`/orders/${result.data.order_id}`);
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
} else { } else {
alert.error(result.error || "Nepodařilo se vytvořit objednávku"); alert.error(result.error || "Nepodařilo se vytvořit objednávku");
} }
@@ -193,7 +196,9 @@ export default function Offers() {
if (result.success) { if (result.success) {
setDeleteConfirm({ show: false, quotation: null }); setDeleteConfirm({ show: false, quotation: null });
alert.success(result.message || "Nabídka byla smazána"); alert.success(result.message || "Nabídka byla smazána");
fetchData(); queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
} else { } else {
alert.error(result.error || "Nepodařilo se smazat nabídku"); alert.error(result.error || "Nepodařilo se smazat nabídku");
} }
@@ -218,7 +223,9 @@ export default function Offers() {
if (result.success) { if (result.success) {
setInvalidateConfirm({ show: false, quotation: null }); setInvalidateConfirm({ show: false, quotation: null });
alert.success(result.message || "Nabídka byla zneplatněna"); alert.success(result.message || "Nabídka byla zneplatněna");
fetchData(); queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
} else { } else {
alert.error(result.error || "Nepodařilo se zneplatnit nabídku"); alert.error(result.error || "Nepodařilo se zneplatnit nabídku");
} }
@@ -260,64 +267,8 @@ export default function Offers() {
} }
}; };
if (initialLoad) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
<div style={{ display: "flex", gap: "0.5rem" }}>
<div
className="admin-skeleton-line h-10"
style={{ width: "120px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-10"
style={{ width: "140px", borderRadius: "8px" }}
/>
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
<div
className="admin-skeleton-line h-10"
style={{
width: "100%",
borderRadius: "8px",
marginBottom: "0.5rem",
}}
/>
{[0, 1, 2, 3, 4].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/3"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
);
}
return ( return (
<Skeleton name="offers" loading={isPending} fixture={<OffersFixture />}>
<div> <div>
<motion.div <motion.div
className="admin-page-header" className="admin-page-header"
@@ -381,7 +332,7 @@ export default function Offers() {
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }} transition={{ duration: 0.25, delay: 0.06 }}
style={{ opacity: loading ? 0.6 : 1, transition: "opacity 0.2s" }} style={{ opacity: isFetching ? 0.6 : 1, transition: "opacity 0.2s" }}
> >
<div className="admin-card-body"> <div className="admin-card-body">
<div className="admin-search-bar mb-4"> <div className="admin-search-bar mb-4">
@@ -418,7 +369,10 @@ export default function Offers() {
</div> </div>
<p>Zatím nejsou žádné nabídky.</p> <p>Zatím nejsou žádné nabídky.</p>
{hasPermission("offers.create") && ( {hasPermission("offers.create") && (
<Link to="/offers/new" className="admin-btn admin-btn-primary"> <Link
to="/offers/new"
className="admin-btn admin-btn-primary"
>
Vytvořit první nabídku Vytvořit první nabídku
</Link> </Link>
)} )}
@@ -578,7 +532,10 @@ export default function Offers() {
className={getRowClass(isInvalidated, !!isExpired)} className={getRowClass(isInvalidated, !!isExpired)}
> >
<td> <td>
<Link to={`/offers/${q.id}`} className="link-accent"> <Link
to={`/offers/${q.id}`}
className="link-accent"
>
{q.quotation_number} {q.quotation_number}
</Link> </Link>
</td> </td>
@@ -699,7 +656,10 @@ export default function Offers() {
onClick={() => { onClick={() => {
setCustomerOrderNumber(""); setCustomerOrderNumber("");
setOrderAttachment(null); setOrderAttachment(null);
setOrderModal({ show: true, quotation: q }); setOrderModal({
show: true,
quotation: q,
});
}} }}
className="admin-btn-icon" className="admin-btn-icon"
title="Vytvořit objednávku" title="Vytvořit objednávku"
@@ -799,7 +759,10 @@ export default function Offers() {
{hasPermission("offers.delete") && ( {hasPermission("offers.delete") && (
<button <button
onClick={() => onClick={() =>
setDeleteConfirm({ show: true, quotation: q }) setDeleteConfirm({
show: true,
quotation: q,
})
} }
className="admin-btn-icon danger" className="admin-btn-icon danger"
title="Smazat" title="Smazat"
@@ -1017,5 +980,6 @@ export default function Offers() {
)} )}
</AnimatePresence> </AnimatePresence>
</div> </div>
</Skeleton>
); );
} }

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useCallback, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
@@ -6,6 +7,9 @@ import ConfirmModal from "../components/ConfirmModal";
import FormField from "../components/FormField"; import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import useModalLock from "../hooks/useModalLock"; import useModalLock from "../hooks/useModalLock";
import { offerCustomersOptions } from "../lib/queries/offers";
import { Skeleton } from "boneyard-js/react";
import OffersCustomersFixture from "../fixtures/OffersCustomersFixture";
import apiFetch from "../utils/api"; import apiFetch from "../utils/api";
@@ -61,8 +65,10 @@ interface CustomerForm {
export default function OffersCustomers() { export default function OffersCustomers() {
const alert = useAlert(); const alert = useAlert();
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true); const queryClient = useQueryClient();
const [customers, setCustomers] = useState<Customer[]>([]); const { data: customers = [], isPending } = useQuery(
offerCustomersOptions(),
) as { data: Customer[]; isPending: boolean };
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
@@ -135,26 +141,7 @@ export default function OffersCustomers() {
return key; return key;
}; };
const fetchData = useCallback(async () => { // Data fetching moved to useQuery(offerCustomersOptions()) above
try {
const response = await apiFetch(`${API_BASE}/customers`);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setCustomers(Array.isArray(result.data) ? result.data : []);
} else {
alert.error(result.error || "Nepodařilo se načíst zákazníky");
}
} catch {
alert.error("Chyba připojení");
} finally {
setLoading(false);
}
}, [alert]);
useEffect(() => {
fetchData();
}, [fetchData]);
const openCreateModal = () => { const openCreateModal = () => {
setEditingCustomer(null); setEditingCustomer(null);
@@ -244,7 +231,7 @@ export default function OffersCustomers() {
? "Zákazník byl aktualizován" ? "Zákazník byl aktualizován"
: "Zákazník byl vytvořen"), : "Zákazník byl vytvořen"),
); );
fetchData(); queryClient.invalidateQueries({ queryKey: ["offer-customers"] });
} else { } else {
alert.error(result.error || "Nepodařilo se uložit zákazníka"); alert.error(result.error || "Nepodařilo se uložit zákazníka");
} }
@@ -272,7 +259,7 @@ export default function OffersCustomers() {
if (result.success) { if (result.success) {
setDeleteConfirm({ show: false, customer: null }); setDeleteConfirm({ show: false, customer: null });
alert.success(result.message || "Zákazník byl smazán"); alert.success(result.message || "Zákazník byl smazán");
fetchData(); queryClient.invalidateQueries({ queryKey: ["offer-customers"] });
} else { } else {
alert.error(result.error || "Nepodařilo se smazat zákazníka"); alert.error(result.error || "Nepodařilo se smazat zákazníka");
} }
@@ -294,60 +281,14 @@ export default function OffersCustomers() {
) )
: customers; : customers;
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
<div
className="admin-skeleton-line h-10"
style={{ width: "160px", borderRadius: "8px" }}
/>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
<div
className="admin-skeleton-line h-10"
style={{
width: "100%",
borderRadius: "8px",
marginBottom: "0.5rem",
}}
/>
{[0, 1, 2, 3, 4].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/3"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
);
}
const fullFieldOrder = getFullFieldOrder(); const fullFieldOrder = getFullFieldOrder();
return ( return (
<Skeleton
name="offers-customers"
loading={isPending}
fixture={<OffersCustomersFixture />}
>
<div> <div>
<motion.div <motion.div
className="admin-page-header" className="admin-page-header"
@@ -562,7 +503,10 @@ export default function OffersCustomers() {
type="text" type="text"
value={form.street} value={form.street}
onChange={(e) => onChange={(e) =>
setForm((prev) => ({ ...prev, street: e.target.value })) setForm((prev) => ({
...prev,
street: e.target.value,
}))
} }
className="admin-form-input" className="admin-form-input"
/> />
@@ -573,7 +517,10 @@ export default function OffersCustomers() {
type="text" type="text"
value={form.city} value={form.city}
onChange={(e) => onChange={(e) =>
setForm((prev) => ({ ...prev, city: e.target.value })) setForm((prev) => ({
...prev,
city: e.target.value,
}))
} }
className="admin-form-input" className="admin-form-input"
/> />
@@ -701,7 +648,9 @@ export default function OffersCustomers() {
.filter((k) => k !== key) .filter((k) => k !== key)
.map((k) => { .map((k) => {
if (k.startsWith("custom_")) { if (k.startsWith("custom_")) {
const ki = parseInt(k.split("_")[1]); const ki = parseInt(
k.split("_")[1],
);
if (ki > idx) if (ki > idx)
return `custom_${ki - 1}`; return `custom_${ki - 1}`;
} }
@@ -894,5 +843,6 @@ export default function OffersCustomers() {
loading={deleting} loading={deleting}
/> />
</div> </div>
</Skeleton>
); );
} }

View File

@@ -1,10 +1,5 @@
import { import { useState, useRef, type ReactNode } from "react";
useState, import { useQuery, useQueryClient } from "@tanstack/react-query";
useEffect,
useCallback,
useRef,
type ReactNode,
} from "react";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
@@ -13,6 +8,9 @@ import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import RichEditor from "../components/RichEditor"; import RichEditor from "../components/RichEditor";
import useModalLock from "../hooks/useModalLock"; import useModalLock from "../hooks/useModalLock";
import { offerTemplatesOptions } from "../lib/queries/offers";
import { Skeleton } from "boneyard-js/react";
import OffersTemplatesFixture from "../fixtures/OffersTemplatesFixture";
import apiFetch from "../utils/api"; import apiFetch from "../utils/api";
@@ -97,8 +95,10 @@ export default function OffersTemplates() {
function ItemTemplatesTab() { function ItemTemplatesTab() {
const alert = useAlert(); const alert = useAlert();
const [loading, setLoading] = useState(true); const queryClient = useQueryClient();
const [templates, setTemplates] = useState<ItemTemplate[]>([]); const { data: templates = [], isPending } = useQuery(
offerTemplatesOptions("items"),
) as { data: ItemTemplate[]; isPending: boolean };
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<ItemTemplate | null>( const [editingTemplate, setEditingTemplate] = useState<ItemTemplate | null>(
null, null,
@@ -118,27 +118,6 @@ function ItemTemplatesTab() {
useModalLock(showModal); useModalLock(showModal);
const fetchData = useCallback(async () => {
try {
const response = await apiFetch(
`${API_BASE}/offers-templates?action=items`,
);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setTemplates(Array.isArray(result.data) ? result.data : []);
}
} catch {
alert.error("Nepodařilo se načíst šablony");
} finally {
setLoading(false);
}
}, [alert]);
useEffect(() => {
fetchData();
}, [fetchData]);
const openCreate = () => { const openCreate = () => {
setEditingTemplate(null); setEditingTemplate(null);
setForm({ name: "", description: "", default_price: 0, category: "" }); setForm({ name: "", description: "", default_price: 0, category: "" });
@@ -177,7 +156,7 @@ function ItemTemplatesTab() {
setShowModal(false); setShowModal(false);
await new Promise((r) => setTimeout(r, 300)); await new Promise((r) => setTimeout(r, 300));
alert.success(result.message); alert.success(result.message);
fetchData(); queryClient.invalidateQueries({ queryKey: ["offer-templates"] });
} else { } else {
alert.error(result.error); alert.error(result.error);
} }
@@ -200,7 +179,7 @@ function ItemTemplatesTab() {
if (result.success) { if (result.success) {
setDeleteConfirm({ show: false, template: null }); setDeleteConfirm({ show: false, template: null });
alert.success(result.message); alert.success(result.message);
fetchData(); queryClient.invalidateQueries({ queryKey: ["offer-templates"] });
} else { } else {
alert.error(result.error); alert.error(result.error);
} }
@@ -211,32 +190,12 @@ function ItemTemplatesTab() {
} }
}; };
if (loading) {
return (
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].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/3"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
);
}
return ( return (
<Skeleton
name="offers-templates"
loading={isPending}
fixture={<OffersTemplatesFixture />}
>
<> <>
<motion.div <motion.div
className="admin-card" className="admin-card"
@@ -368,7 +327,9 @@ function ItemTemplatesTab() {
> >
<div className="admin-modal-header"> <div className="admin-modal-header">
<h2 className="admin-modal-title"> <h2 className="admin-modal-title">
{editingTemplate ? "Upravit šablonu" : "Nová šablona položky"} {editingTemplate
? "Upravit šablonu"
: "Nová šablona položky"}
</h2> </h2>
</div> </div>
<div className="admin-modal-body"> <div className="admin-modal-body">
@@ -387,7 +348,10 @@ function ItemTemplatesTab() {
<textarea <textarea
value={form.description} value={form.description}
onChange={(e) => onChange={(e) =>
setForm((p) => ({ ...p, description: e.target.value })) setForm((p) => ({
...p,
description: e.target.value,
}))
} }
className="admin-form-input" className="admin-form-input"
rows={2} rows={2}
@@ -401,7 +365,7 @@ function ItemTemplatesTab() {
onChange={(e) => onChange={(e) =>
setForm((p) => ({ setForm((p) => ({
...p, ...p,
default_price: parseFloat(e.target.value) || 0, default_price: e.target.value,
})) }))
} }
className="admin-form-input" className="admin-form-input"
@@ -462,6 +426,7 @@ function ItemTemplatesTab() {
loading={deleting} loading={deleting}
/> />
</> </>
</Skeleton>
); );
} }
@@ -469,8 +434,10 @@ function ItemTemplatesTab() {
function ScopeTemplatesTab() { function ScopeTemplatesTab() {
const alert = useAlert(); const alert = useAlert();
const [loading, setLoading] = useState(true); const queryClient = useQueryClient();
const [templates, setTemplates] = useState<ScopeTemplate[]>([]); const { data: templates = [], isPending } = useQuery(
offerTemplatesOptions(),
) as { data: ScopeTemplate[]; isPending: boolean };
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<ScopeTemplate | null>( const [editingTemplate, setEditingTemplate] = useState<ScopeTemplate | null>(
null, null,
@@ -486,25 +453,6 @@ function ScopeTemplatesTab() {
useModalLock(showModal); useModalLock(showModal);
const fetchData = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/offers-templates`);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setTemplates(Array.isArray(result.data) ? result.data : []);
}
} catch {
alert.error("Nepodařilo se načíst šablony");
} finally {
setLoading(false);
}
}, [alert]);
useEffect(() => {
fetchData();
}, [fetchData]);
const openCreate = () => { const openCreate = () => {
setEditingTemplate(null); setEditingTemplate(null);
setForm({ setForm({
@@ -625,7 +573,7 @@ function ScopeTemplatesTab() {
setShowModal(false); setShowModal(false);
await new Promise((r) => setTimeout(r, 300)); await new Promise((r) => setTimeout(r, 300));
alert.success(result.message); alert.success(result.message);
fetchData(); queryClient.invalidateQueries({ queryKey: ["offer-templates"] });
} else { } else {
alert.error(result.error); alert.error(result.error);
} }
@@ -648,7 +596,7 @@ function ScopeTemplatesTab() {
if (result.success) { if (result.success) {
setDeleteConfirm({ show: false, template: null }); setDeleteConfirm({ show: false, template: null });
alert.success(result.message); alert.success(result.message);
fetchData(); queryClient.invalidateQueries({ queryKey: ["offer-templates"] });
} else { } else {
alert.error(result.error); alert.error(result.error);
} }
@@ -659,32 +607,12 @@ function ScopeTemplatesTab() {
} }
}; };
if (loading) {
return (
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].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/3"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
);
}
return ( return (
<Skeleton
name="offers-templates"
loading={isPending}
fixture={<OffersTemplatesFixture />}
>
<> <>
<motion.div <motion.div
className="admin-card" className="admin-card"
@@ -828,7 +756,10 @@ function ScopeTemplatesTab() {
<label className="admin-form-label mb-2">Sekce</label> <label className="admin-form-label mb-2">Sekce</label>
<div className="admin-scope-list"> <div className="admin-scope-list">
{form.sections.map((section, index) => ( {form.sections.map((section, index) => (
<div key={section._key} className="admin-scope-section"> <div
key={section._key}
className="admin-scope-section"
>
<div className="admin-scope-section-header"> <div className="admin-scope-section-header">
<span className="admin-scope-number"> <span className="admin-scope-number">
{index + 1}. {index + 1}.
@@ -1018,5 +949,6 @@ function ScopeTemplatesTab() {
loading={deleting} loading={deleting}
/> />
</> </>
</Skeleton>
); );
} }

View File

@@ -1,12 +1,7 @@
import { import { useState, useEffect, useMemo, useRef, type ReactNode } from "react";
useState,
useEffect,
useCallback,
useMemo,
useRef,
type ReactNode,
} from "react";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { orderDetailOptions } from "../lib/queries/orders";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { useParams, useNavigate, Link } from "react-router-dom"; import { useParams, useNavigate, Link } from "react-router-dom";
@@ -15,6 +10,8 @@ import ConfirmModal from "../components/ConfirmModal";
import OrderConfirmationModal from "../components/OrderConfirmationModal"; import OrderConfirmationModal from "../components/OrderConfirmationModal";
import FormField from "../components/FormField"; import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import { Skeleton } from "boneyard-js/react";
import OrderDetailFixture from "../fixtures/OrderDetailFixture";
import apiFetch from "../utils/api"; import apiFetch from "../utils/api";
import { formatCurrency, formatDate } from "../utils/formatters"; import { formatCurrency, formatDate } from "../utils/formatters";
@@ -105,8 +102,11 @@ export default function OrderDetail() {
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = useState(true); const queryClient = useQueryClient();
const [order, setOrder] = useState<OrderData | null>(null); const orderQuery = useQuery(orderDetailOptions(id));
const order = orderQuery.data as OrderData | undefined;
const loading = orderQuery.isPending;
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [statusChanging, setStatusChanging] = useState<string | null>(null); const [statusChanging, setStatusChanging] = useState<string | null>(null);
@@ -121,39 +121,38 @@ export default function OrderDetail() {
const [showConfirmationModal, setShowConfirmationModal] = useState(false); const [showConfirmationModal, setShowConfirmationModal] = useState(false);
const [confirmationLoading, setConfirmationLoading] = useState(false); const [confirmationLoading, setConfirmationLoading] = useState(false);
const initialNotesRef = useRef<string | null>(null); const initialNotesRef = useRef<string | null>(null);
const formInitializedRef = useRef(false);
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]); const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
// Reset form sync when navigating to a different order
useEffect(() => {
formInitializedRef.current = false;
}, [id]);
// Sync order data to local form state on first load
useEffect(() => {
if (orderQuery.data && !formInitializedRef.current) {
const orderData = orderQuery.data as OrderData;
setNotes(orderData.notes || "");
initialNotesRef.current = orderData.notes || "";
formInitializedRef.current = true;
}
}, [orderQuery.data]);
// Navigate away on fetch error
useEffect(() => {
if (orderQuery.error) {
alert.error("Nepodařilo se načíst objednávku");
navigate("/orders");
}
}, [orderQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { useEffect(() => {
return () => { return () => {
blobTimeoutsRef.current.forEach(clearTimeout); blobTimeoutsRef.current.forEach(clearTimeout);
}; };
}, []); }, []);
const fetchDetail = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/orders/${id}`);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setOrder(result.data);
setNotes(result.data.notes || "");
initialNotesRef.current = result.data.notes || "";
} else {
alert.error(result.error || "Nepodařilo se načíst objednávku");
navigate("/orders");
}
} catch {
alert.error("Chyba připojení");
navigate("/orders");
} finally {
setLoading(false);
}
}, [id, alert, navigate]);
useEffect(() => {
fetchDetail();
}, [fetchDetail]);
const isDirty = useMemo(() => { const isDirty = useMemo(() => {
if (!initialNotesRef.current) return false; if (!initialNotesRef.current) return false;
return notes !== initialNotesRef.current; return notes !== initialNotesRef.current;
@@ -200,7 +199,9 @@ export default function OrderDetail() {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
alert.success(result.message || "Stav byl změněn"); alert.success(result.message || "Stav byl změněn");
fetchDetail(); queryClient.invalidateQueries({ queryKey: ["orders", id] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
} else { } else {
alert.error(result.error || "Nepodařilo se změnit stav"); alert.error(result.error || "Nepodařilo se změnit stav");
} }
@@ -223,6 +224,9 @@ export default function OrderDetail() {
if (result.success) { if (result.success) {
alert.success("Poznámky byly uloženy"); alert.success("Poznámky byly uloženy");
initialNotesRef.current = notes; initialNotesRef.current = notes;
queryClient.invalidateQueries({ queryKey: ["orders", id] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
} else { } else {
alert.error(result.error || "Nepodařilo se uložit poznámky"); alert.error(result.error || "Nepodařilo se uložit poznámky");
} }
@@ -311,6 +315,9 @@ export default function OrderDetail() {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
alert.success(result.message || "Objednávka byla smazána"); alert.success(result.message || "Objednávka byla smazána");
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
navigate("/orders"); navigate("/orders");
} else { } else {
alert.error(result.error || "Nepodařilo se smazat objednávku"); alert.error(result.error || "Nepodařilo se smazat objednávku");
@@ -323,51 +330,14 @@ export default function OrderDetail() {
} }
}; };
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div className="flex-row-gap">
<div
className="admin-skeleton-line"
style={{ width: "32px", height: "32px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px" }}
/>
</div>
<div className="admin-skeleton-row gap-2">
<div
className="admin-skeleton-line h-10"
style={{ width: "100px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-10"
style={{ width: "100px", borderRadius: "8px" }}
/>
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/2" />
</div>
))}
</div>
</div>
</div>
);
}
if (!order) return null; if (!order) return null;
return ( return (
<Skeleton
name="order-detail"
loading={loading}
fixture={<OrderDetailFixture />}
>
<div> <div>
{/* Header */} {/* Header */}
<motion.div <motion.div
@@ -465,8 +435,8 @@ export default function OrderDetail() {
Potvrzení objednávky Potvrzení objednávky
</button> </button>
{hasPermission("orders.edit") && {hasPermission("orders.edit") &&
order.valid_transitions?.filter((s) => s !== "stornovana").length! > order.valid_transitions?.filter((s) => s !== "stornovana")
0 && .length! > 0 &&
order order
.valid_transitions!.filter((s) => s !== "stornovana") .valid_transitions!.filter((s) => s !== "stornovana")
.map((status) => ( .map((status) => (
@@ -608,7 +578,9 @@ export default function OrderDetail() {
<table className="admin-table"> <table className="admin-table">
<thead> <thead>
<tr> <tr>
<th style={{ width: "2.5rem", textAlign: "center" }}>#</th> <th style={{ width: "2.5rem", textAlign: "center" }}>
#
</th>
<th>Popis</th> <th>Popis</th>
<th style={{ width: "5.5rem", textAlign: "center" }}> <th style={{ width: "5.5rem", textAlign: "center" }}>
Množství Množství
@@ -671,7 +643,9 @@ export default function OrderDetail() {
</div> </div>
)} )}
</td> </td>
<td style={{ textAlign: "center" }}>{item.quantity}</td> <td style={{ textAlign: "center" }}>
{item.quantity}
</td>
<td style={{ textAlign: "center" }}> <td style={{ textAlign: "center" }}>
{item.unit || "—"} {item.unit || "—"}
</td> </td>
@@ -713,7 +687,9 @@ export default function OrderDetail() {
{Number(order.apply_vat) > 0 && ( {Number(order.apply_vat) > 0 && (
<div className="admin-totals-row"> <div className="admin-totals-row">
<span>DPH ({order.vat_rate}%):</span> <span>DPH ({order.vat_rate}%):</span>
<span>{formatCurrency(totals.vatAmount, order.currency)}</span> <span>
{formatCurrency(totals.vatAmount, order.currency)}
</span>
</div> </div>
)} )}
<div className="admin-totals-row admin-totals-total"> <div className="admin-totals-row admin-totals-total">
@@ -741,7 +717,10 @@ export default function OrderDetail() {
)} )}
{order.scope_description && ( {order.scope_description && (
<div <div
style={{ color: "var(--text-secondary)", marginBottom: "1rem" }} style={{
color: "var(--text-secondary)",
marginBottom: "1rem",
}}
> >
{order.scope_description} {order.scope_description}
</div> </div>
@@ -879,5 +858,6 @@ export default function OrderDetail() {
/> />
)} )}
</div> </div>
</Skeleton>
); );
} }

View File

@@ -3,6 +3,8 @@ import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import { Skeleton } from "boneyard-js/react";
import OrdersFixture from "../fixtures/OrdersFixture";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal"; import ConfirmModal from "../components/ConfirmModal";
@@ -10,7 +12,9 @@ import apiFetch from "../utils/api";
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters"; import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
import SortIcon from "../components/SortIcon"; import SortIcon from "../components/SortIcon";
import useTableSort from "../hooks/useTableSort"; import useTableSort from "../hooks/useTableSort";
import useListData from "../hooks/useListData"; import { useQueryClient } from "@tanstack/react-query";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import { orderListOptions } from "../lib/queries/orders";
import Pagination from "../components/Pagination"; import Pagination from "../components/Pagination";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
@@ -57,19 +61,13 @@ export default function Orders() {
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [deleteFiles, setDeleteFiles] = useState(false); const [deleteFiles, setDeleteFiles] = useState(false);
const queryClient = useQueryClient();
const { const {
items: orders, items: orders,
loading,
initialLoad,
pagination, pagination,
refetch: fetchData, isPending,
} = useListData("orders", { isFetching,
search, } = usePaginatedQuery<Order>(orderListOptions({ search, sort, order, page }));
sort,
order,
page,
errorMsg: "Nepodařilo se načíst objednávky",
});
if (!hasPermission("orders.view")) return <Forbidden />; if (!hasPermission("orders.view")) return <Forbidden />;
@@ -90,7 +88,9 @@ export default function Orders() {
setDeleteConfirm({ show: false, order: null }); setDeleteConfirm({ show: false, order: null });
setDeleteFiles(false); setDeleteFiles(false);
alert.success(result.message || "Objednávka byla smazána"); alert.success(result.message || "Objednávka byla smazána");
fetchData(); queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
} else { } else {
alert.error(result.error || "Nepodařilo se smazat objednávku"); alert.error(result.error || "Nepodařilo se smazat objednávku");
} }
@@ -101,52 +101,8 @@ export default function Orders() {
} }
}; };
if (initialLoad) {
return (
<div>
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
<div
className="admin-skeleton-line h-10"
style={{ width: "140px", borderRadius: "8px" }}
/>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].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/3"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
</div>
);
}
return ( return (
<Skeleton name="orders" loading={isPending} fixture={<OrdersFixture />}>
<div> <div>
<motion.div <motion.div
className="admin-page-header" className="admin-page-header"
@@ -173,7 +129,7 @@ export default function Orders() {
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }} transition={{ duration: 0.25, delay: 0.06 }}
style={{ opacity: loading ? 0.6 : 1, transition: "opacity 0.2s" }} style={{ opacity: isFetching ? 0.6 : 1, transition: "opacity 0.2s" }}
> >
<div className="admin-card-body"> <div className="admin-card-body">
<div className="admin-search-bar mb-4"> <div className="admin-search-bar mb-4">
@@ -281,7 +237,9 @@ export default function Orders() {
{STATUS_LABELS[o.status] || o.status} {STATUS_LABELS[o.status] || o.status}
</span> </span>
</td> </td>
<td className="admin-mono">{formatDate(o.created_at)}</td> <td className="admin-mono">
{formatDate(o.created_at)}
</td>
<td className="admin-mono text-right fw-500"> <td className="admin-mono text-right fw-500">
{formatCurrency(o.total, o.currency)} {formatCurrency(o.total, o.currency)}
</td> </td>
@@ -403,8 +361,8 @@ export default function Orders() {
message={ message={
<> <>
Opravdu chcete smazat objednávku &quot; Opravdu chcete smazat objednávku &quot;
{deleteConfirm.order?.order_number}&quot;? Bude smazán i přidružený {deleteConfirm.order?.order_number}&quot;? Bude smazán i
projekt. Tato akce je nevratná. přidružený projekt. Tato akce je nevratná.
<label <label
className="admin-form-checkbox" className="admin-form-checkbox"
style={{ marginTop: "1rem", display: "flex" }} style={{ marginTop: "1rem", display: "flex" }}
@@ -424,5 +382,6 @@ export default function Orders() {
loading={deleting} loading={deleting}
/> />
</div> </div>
</Skeleton>
); );
} }

View File

@@ -1,10 +1,15 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { useParams, useNavigate, Link } from "react-router-dom"; import { useParams, useNavigate, Link } from "react-router-dom";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { projectDetailOptions } from "../lib/queries/projects";
import { userListOptions } from "../lib/queries/users";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import { Skeleton } from "boneyard-js/react";
import ProjectDetailFixture from "../fixtures/ProjectDetailFixture";
import ConfirmModal from "../components/ConfirmModal"; import ConfirmModal from "../components/ConfirmModal";
import FormField from "../components/FormField"; import FormField from "../components/FormField";
import AdminDatePicker from "../components/AdminDatePicker"; import AdminDatePicker from "../components/AdminDatePicker";
@@ -74,9 +79,7 @@ export default function ProjectDetail() {
const { hasPermission, isAdmin } = useAuth(); const { hasPermission, isAdmin } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [project, setProject] = useState<ProjectData | null>(null);
const [form, setForm] = useState<ProjectForm>({ const [form, setForm] = useState<ProjectForm>({
name: "", name: "",
status: "aktivni", status: "aktivni",
@@ -84,88 +87,62 @@ export default function ProjectDetail() {
end_date: "", end_date: "",
responsible_user_id: "", responsible_user_id: "",
}); });
const [users, setUsers] = useState<User[]>([]);
const [deleteConfirm, setDeleteConfirm] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [deleteFiles, setDeleteFiles] = useState(false); const [deleteFiles, setDeleteFiles] = useState(false);
// Dynamic notes // Dynamic notes
const [notes, setNotes] = useState<Note[]>([]);
const [notesLoading, setNotesLoading] = useState(true);
const [newNote, setNewNote] = useState(""); const [newNote, setNewNote] = useState("");
const [addingNote, setAddingNote] = useState(false); const [addingNote, setAddingNote] = useState(false);
const [deletingNoteId, setDeletingNoteId] = useState<number | null>(null); const [deletingNoteId, setDeletingNoteId] = useState<number | null>(null);
const fetchNotes = async () => { const queryClient = useQueryClient();
try { const projectQuery = useQuery(projectDetailOptions(id));
const response = await apiFetch(`${API_BASE}/projects/${id}`); const project = projectQuery.data as
if (response.status === 401) return; | (ProjectData & { project_notes?: Note[] })
const result = await response.json(); | undefined;
if (result.success) { const isPending = projectQuery.isPending;
setNotes(result.data.project_notes || []); const notes: Note[] = project?.project_notes || [];
}
} catch {
// silent - notes are supplementary
} finally {
setNotesLoading(false);
}
};
useEffect(() => { const { data: usersData } = useQuery(userListOptions());
const fetchDetail = async () => { const rawUsers = ((usersData as Record<string, unknown>)?.items ??
try { usersData ??
const response = await apiFetch(`${API_BASE}/projects/${id}`); []) as any[];
if (response.status === 401) return; const users: User[] = Array.isArray(rawUsers)
const result = await response.json(); ? rawUsers.map((u: any) => ({
if (result.success) {
const p = result.data;
setProject(p);
setForm({
name: p.name || "",
status: p.status || "aktivni",
start_date: (p.start_date || "").substring(0, 10),
end_date: (p.end_date || "").substring(0, 10),
responsible_user_id: p.responsible_user_id || "",
});
} else {
alert.error(result.error || "Nepodařilo se načíst projekt");
navigate("/projects");
}
} catch {
alert.error("Chyba připojení");
navigate("/projects");
} finally {
setLoading(false);
}
};
const fetchUsers = async () => {
try {
const res = await apiFetch(`${API_BASE}/users`);
if (res.status === 401) return;
const data = await res.json();
if (data.success) {
const raw = Array.isArray(data.data)
? data.data
: data.data?.items || [];
setUsers(
raw.map((u: any) => ({
id: u.id, id: u.id,
name: name: `${u.first_name || ""} ${u.last_name || ""}`.trim() || u.username,
`${u.first_name || ""} ${u.last_name || ""}`.trim() || }))
u.username, : [];
})),
);
}
} catch {
// silent
}
};
fetchDetail(); // Reset form sync when navigating to a different project
fetchNotes(); const formInitialized = useRef(false);
fetchUsers(); useEffect(() => {
}, [id, alert, navigate]); // eslint-disable-line react-hooks/exhaustive-deps formInitialized.current = false;
}, [id]);
// Sync project data to local form state on first load
useEffect(() => {
if (project && !formInitialized.current) {
setForm({
name: project.name || "",
status: project.status || "aktivni",
start_date: (project.start_date || "").substring(0, 10),
end_date: (project.end_date || "").substring(0, 10),
responsible_user_id: project.responsible_user_id || "",
});
formInitialized.current = true;
}
}, [project]);
// Navigate away on fetch error
useEffect(() => {
if (projectQuery.error) {
alert.error("Nepodařilo se načíst projekt");
navigate("/projects");
}
}, [projectQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps
if (!hasPermission("projects.view")) return <Forbidden />; if (!hasPermission("projects.view")) return <Forbidden />;
@@ -194,6 +171,9 @@ export default function ProjectDetail() {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
alert.success(result.message || "Projekt byl aktualizován"); alert.success(result.message || "Projekt byl aktualizován");
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
} else { } else {
alert.error(result.error || "Nepodařilo se uložit projekt"); alert.error(result.error || "Nepodařilo se uložit projekt");
} }
@@ -214,6 +194,9 @@ export default function ProjectDetail() {
}); });
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
navigate("/projects"); navigate("/projects");
setTimeout(() => alert.success("Projekt byl smazán"), 300); setTimeout(() => alert.success("Projekt byl smazán"), 300);
} else { } else {
@@ -238,9 +221,9 @@ export default function ProjectDetail() {
}); });
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
setNotes((prev) => [result.data.note, ...prev]);
setNewNote(""); setNewNote("");
alert.success("Poznámka byla přidána"); alert.success("Poznámka byla přidána");
queryClient.invalidateQueries({ queryKey: ["projects", id] });
} else { } else {
alert.error(result.error || "Nepodařilo se přidat poznámku"); alert.error(result.error || "Nepodařilo se přidat poznámku");
} }
@@ -262,8 +245,8 @@ export default function ProjectDetail() {
); );
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
setNotes((prev) => prev.filter((n) => n.id !== noteId));
alert.success("Poznámka byla smazána"); alert.success("Poznámka byla smazána");
queryClient.invalidateQueries({ queryKey: ["projects", id] });
} else { } else {
alert.error(result.error || "Nepodařilo se smazat poznámku"); alert.error(result.error || "Nepodařilo se smazat poznámku");
} }
@@ -274,53 +257,16 @@ export default function ProjectDetail() {
} }
}; };
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div className="flex-row-gap">
<div
className="admin-skeleton-line"
style={{ width: "32px", height: "32px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px" }}
/>
</div>
<div className="admin-skeleton-row" style={{ gap: "0.5rem" }}>
<div
className="admin-skeleton-line h-10"
style={{ width: "100px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-10"
style={{ width: "100px", borderRadius: "8px" }}
/>
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/2" />
</div>
))}
</div>
</div>
</div>
);
}
if (!project) return null; if (!project) return null;
const canEdit = hasPermission("projects.edit"); const canEdit = hasPermission("projects.edit");
return ( return (
<Skeleton
name="project-detail"
loading={isPending}
fixture={<ProjectDetailFixture />}
>
<div> <div>
{/* Header */} {/* Header */}
<motion.div <motion.div
@@ -548,18 +494,7 @@ export default function ProjectDetail() {
)} )}
{/* Notes list */} {/* Notes list */}
{notesLoading && ( {notes.length === 0 && !project.notes && (
<div className="admin-skeleton" style={{ gap: "0.75rem" }}>
{[0, 1, 2].map((i) => (
<div
key={i}
className="admin-skeleton-line"
style={{ height: "52px", borderRadius: "8px" }}
/>
))}
</div>
)}
{!notesLoading && notes.length === 0 && !project.notes && (
<div <div
style={{ style={{
color: "var(--text-tertiary)", color: "var(--text-tertiary)",
@@ -571,7 +506,7 @@ export default function ProjectDetail() {
Zatím žádné poznámky Zatím žádné poznámky
</div> </div>
)} )}
{!notesLoading && (notes.length > 0 || project.notes) && ( {(notes.length > 0 || project.notes) && (
<div <div
style={{ style={{
display: "flex", display: "flex",
@@ -606,7 +541,9 @@ export default function ProjectDetail() {
marginBottom: "0.25rem", marginBottom: "0.25rem",
}} }}
> >
<span style={{ fontWeight: 600, fontSize: "0.85rem" }}> <span
style={{ fontWeight: 600, fontSize: "0.85rem" }}
>
{note.user_name} {note.user_name}
</span> </span>
<span <span
@@ -769,5 +706,6 @@ export default function ProjectDetail() {
loading={deleting} loading={deleting}
/> />
</div> </div>
</Skeleton>
); );
} }

View File

@@ -3,6 +3,8 @@ import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import { Skeleton } from "boneyard-js/react";
import ProjectsFixture from "../fixtures/ProjectsFixture";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal"; import ConfirmModal from "../components/ConfirmModal";
@@ -10,7 +12,9 @@ import apiFetch from "../utils/api";
import { formatDate, czechPlural } from "../utils/formatters"; import { formatDate, czechPlural } from "../utils/formatters";
import SortIcon from "../components/SortIcon"; import SortIcon from "../components/SortIcon";
import useTableSort from "../hooks/useTableSort"; import useTableSort from "../hooks/useTableSort";
import useListData from "../hooks/useListData"; import { useQueryClient } from "@tanstack/react-query";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import { projectListOptions } from "../lib/queries/projects";
import Pagination from "../components/Pagination"; import Pagination from "../components/Pagination";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
@@ -52,19 +56,15 @@ export default function Projects() {
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null); const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
const [deleteFiles, setDeleteFiles] = useState(false); const [deleteFiles, setDeleteFiles] = useState(false);
const queryClient = useQueryClient();
const { const {
items: projects, items: projects,
setItems: setProjects,
loading,
initialLoad,
pagination, pagination,
} = useListData<Project>("projects", { isPending,
search, isFetching,
sort, } = usePaginatedQuery<Project>(
order, projectListOptions({ search, sort, order, page }),
page, );
errorMsg: "Nepodařilo se načíst projekty",
});
if (!hasPermission("projects.view")) return <Forbidden />; if (!hasPermission("projects.view")) return <Forbidden />;
@@ -80,9 +80,9 @@ export default function Projects() {
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
alert.success(data.message || "Projekt byl smazán"); alert.success(data.message || "Projekt byl smazán");
setProjects((prev: Project[]) => queryClient.invalidateQueries({ queryKey: ["projects"] });
prev.filter((p) => p.id !== deleteTarget.id), queryClient.invalidateQueries({ queryKey: ["orders"] });
); queryClient.invalidateQueries({ queryKey: ["offers"] });
} else { } else {
alert.error(data.error || "Nepodařilo se smazat projekt"); alert.error(data.error || "Nepodařilo se smazat projekt");
} }
@@ -95,52 +95,8 @@ export default function Projects() {
} }
}; };
if (initialLoad) {
return (
<div>
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
<div
className="admin-skeleton-line h-10"
style={{ width: "140px", borderRadius: "8px" }}
/>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].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/3"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
</div>
);
}
return ( return (
<Skeleton name="projects" loading={isPending} fixture={<ProjectsFixture />}>
<div> <div>
<motion.div <motion.div
className="admin-page-header" className="admin-page-header"
@@ -167,7 +123,7 @@ export default function Projects() {
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }} transition={{ duration: 0.25, delay: 0.06 }}
style={{ opacity: loading ? 0.6 : 1, transition: "opacity 0.2s" }} style={{ opacity: isFetching ? 0.6 : 1, transition: "opacity 0.2s" }}
> >
<div className="admin-card-body"> <div className="admin-card-body">
<div className="admin-search-bar mb-4"> <div className="admin-search-bar mb-4">
@@ -201,7 +157,10 @@ export default function Projects() {
</div> </div>
<p>Zatím nejsou žádné projekty.</p> <p>Zatím nejsou žádné projekty.</p>
<p <p
style={{ color: "var(--text-tertiary)", fontSize: "0.875rem" }} style={{
color: "var(--text-tertiary)",
fontSize: "0.875rem",
}}
> >
Vytvořte první projekt tlačítkem výše nebo automaticky při Vytvořte první projekt tlačítkem výše nebo automaticky při
vytvoření objednávky. vytvoření objednávky.
@@ -228,7 +187,11 @@ export default function Projects() {
onClick={() => handleSort("name")} onClick={() => handleSort("name")}
> >
Název{" "} Název{" "}
<SortIcon column="name" sort={activeSort} order={order} /> <SortIcon
column="name"
sort={activeSort}
order={order}
/>
</th> </th>
<th>Zákazník</th> <th>Zákazník</th>
<th>Zodpovědná osoba</th> <th>Zodpovědná osoba</th>
@@ -273,7 +236,10 @@ export default function Projects() {
{(projects as Project[]).map((p) => ( {(projects as Project[]).map((p) => (
<tr key={p.id}> <tr key={p.id}>
<td className="admin-mono"> <td className="admin-mono">
<Link to={`/projects/${p.id}`} className="link-accent"> <Link
to={`/projects/${p.id}`}
className="link-accent"
>
{p.project_number} {p.project_number}
</Link> </Link>
</td> </td>
@@ -287,7 +253,9 @@ export default function Projects() {
{STATUS_LABELS[p.status] || p.status} {STATUS_LABELS[p.status] || p.status}
</span> </span>
</td> </td>
<td className="admin-mono">{formatDate(p.start_date)}</td> <td className="admin-mono">
{formatDate(p.start_date)}
</td>
<td className="admin-mono">{formatDate(p.end_date)}</td> <td className="admin-mono">{formatDate(p.end_date)}</td>
<td> <td>
{p.order_id ? ( {p.order_id ? (
@@ -322,7 +290,8 @@ export default function Projects() {
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" /> <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg> </svg>
</Link> </Link>
{!p.order_id && hasPermission("projects.create") && ( {!p.order_id &&
hasPermission("projects.create") && (
<button <button
onClick={() => setDeleteTarget(p)} onClick={() => setDeleteTarget(p)}
className="admin-btn-icon danger" className="admin-btn-icon danger"
@@ -388,5 +357,6 @@ export default function Projects() {
loading={!!deletingId} loading={!!deletingId}
/> />
</div> </div>
</Skeleton>
); );
} }

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
@@ -10,6 +11,14 @@ import SortIcon from "../components/SortIcon";
import useTableSort from "../hooks/useTableSort"; import useTableSort from "../hooks/useTableSort";
import useModalLock from "../hooks/useModalLock"; import useModalLock from "../hooks/useModalLock";
import AdminDatePicker from "../components/AdminDatePicker"; import AdminDatePicker from "../components/AdminDatePicker";
import { companySettingsOptions } from "../lib/queries/settings";
import { supplierListOptions } from "../lib/queries/common";
import {
receivedInvoiceListOptions,
receivedInvoiceStatsOptions,
} from "../lib/queries/invoices";
import { Skeleton } from "boneyard-js/react";
import ReceivedInvoicesFixture from "../fixtures/ReceivedInvoicesFixture";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
@@ -131,7 +140,7 @@ interface CompanySettings {
available_vat_rates: number[]; available_vat_rates: number[];
} }
function emptyMeta(settings: CompanySettings | null): UploadMeta { function emptyMeta(settings: CompanySettings | null | undefined): UploadMeta {
return { return {
supplier_name: "", supplier_name: "",
invoice_number: "", invoice_number: "",
@@ -155,10 +164,16 @@ export default function ReceivedInvoices({
const { sort, order, handleSort, activeSort } = useTableSort("created_at"); const { sort, order, handleSort, activeSort } = useTableSort("created_at");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [invoices, setInvoices] = useState<ReceivedInvoice[]>([]); const queryClient = useQueryClient();
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<ReceivedStats | null>(null); const [editOpen, setEditOpen] = useState(false);
const [statsLoading, setStatsLoading] = useState(true); const [editInvoice, setEditInvoice] = useState<EditInvoice | null>(null);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
invoice: ReceivedInvoice | null;
}>({ show: false, invoice: null });
const hasLoadedOnce = useRef(false); const hasLoadedOnce = useRef(false);
const slideDirection = useRef(0); const slideDirection = useRef(0);
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]); const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
@@ -166,18 +181,42 @@ export default function ReceivedInvoices({
const prevMonth = useRef(statsMonth); const prevMonth = useRef(statsMonth);
const prevYear = useRef(statsYear); const prevYear = useRef(statsYear);
const [editOpen, setEditOpen] = useState(false); const { data: supplierNames = [] } = useQuery(supplierListOptions());
const [editInvoice, setEditInvoice] = useState<EditInvoice | null>(null); const companySettings = useQuery(companySettingsOptions()).data as unknown as
const [deleteConfirm, setDeleteConfirm] = useState<{ | CompanySettings
show: boolean; | undefined;
invoice: ReceivedInvoice | null;
}>({ show: false, invoice: null });
const [deleting, setDeleting] = useState(false);
const [saving, setSaving] = useState(false);
const [supplierNames, setSupplierNames] = useState<string[]>([]); // List query — auto-refetches when filters change
const [companySettings, setCompanySettings] = const listQuery = useQuery(
useState<CompanySettings | null>(null); receivedInvoiceListOptions({
month: statsMonth,
year: statsYear,
search,
sort,
order,
}),
);
// Stats query — auto-refetches when month/year change
const statsQuery = useQuery(
receivedInvoiceStatsOptions(statsMonth, statsYear),
);
// Derive list data from query (paginatedJsonQuery returns { data, pagination })
const invoices = (listQuery.data?.data ?? []) as ReceivedInvoice[];
if (listQuery.data || statsQuery.data) hasLoadedOnce.current = true;
// Derive stats from query
const stats = (statsQuery.data as unknown as ReceivedStats) ?? null;
// Trigger slide animation when stats data changes
useEffect(() => {
if (statsQuery.data) {
setSlideKey((k) => k + 1);
}
}, [statsQuery.data]);
const showListSkeleton = listQuery.isPending && !hasLoadedOnce.current;
const [uploadFiles, setUploadFiles] = useState<File[]>([]); const [uploadFiles, setUploadFiles] = useState<File[]>([]);
const [uploadMeta, setUploadMeta] = useState<UploadMeta[]>([]); const [uploadMeta, setUploadMeta] = useState<UploadMeta[]>([]);
@@ -201,57 +240,6 @@ export default function ReceivedInvoices({
prevMonth.current = statsMonth; prevMonth.current = statsMonth;
prevYear.current = statsYear; prevYear.current = statsYear;
const fetchList = useCallback(async () => {
if (!hasLoadedOnce.current) setLoading(true);
try {
const params = new URLSearchParams({
month: String(statsMonth),
year: String(statsYear),
});
if (search) {
params.set("search", search);
}
if (sort) {
params.set("sort", sort);
}
if (order) {
params.set("order", order);
}
const res = await apiFetch(`${API_BASE}/received-invoices?${params}`);
const data = await res.json();
if (data.success) {
setInvoices(Array.isArray(data.data) ? data.data : []);
}
} catch {
/* ignore */
} finally {
setLoading(false);
hasLoadedOnce.current = true;
}
}, [statsMonth, statsYear, search, sort, order]);
useEffect(() => {
fetchList();
}, [fetchList]);
useEffect(() => {
apiFetch(`${API_BASE}/received-invoices/suppliers`)
.then((r) => r.json())
.then((d) => {
if (d.success) setSupplierNames(d.data || []);
})
.catch(() => {});
}, []);
useEffect(() => {
apiFetch(`${API_BASE}/company-settings`)
.then((r) => r.json())
.then((d) => {
if (d.success) setCompanySettings(d.data);
})
.catch(() => {});
}, []);
const currencyOptions = const currencyOptions =
companySettings?.available_currencies || DEFAULT_CURRENCIES; companySettings?.available_currencies || DEFAULT_CURRENCIES;
const vatRateOptions = const vatRateOptions =
@@ -259,45 +247,6 @@ export default function ReceivedInvoices({
const defaultCurrency = companySettings?.default_currency || "CZK"; const defaultCurrency = companySettings?.default_currency || "CZK";
const defaultVatRate = String(companySettings?.default_vat_rate ?? 21); const defaultVatRate = String(companySettings?.default_vat_rate ?? 21);
// Fetch stats (silent refresh without animation)
const refreshStats = useCallback(async () => {
try {
const res = await apiFetch(
`${API_BASE}/received-invoices/stats?month=${statsMonth}&year=${statsYear}`,
);
const data = await res.json();
if (data.success) {
setStats(data.data);
hasLoadedOnce.current = true;
}
} catch {
/* ignore */
}
}, [statsMonth, statsYear]);
// Fetch stats on month change (with slide animation)
useEffect(() => {
setStatsLoading(true);
const load = async () => {
try {
const res = await apiFetch(
`${API_BASE}/received-invoices/stats?month=${statsMonth}&year=${statsYear}`,
);
const data = await res.json();
if (data.success) {
setStats(data.data);
hasLoadedOnce.current = true;
setSlideKey((k) => k + 1);
}
} catch {
/* ignore */
} finally {
setStatsLoading(false);
}
};
load();
}, [statsMonth, statsYear]);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = Array.from(e.target.files || []); const selected = Array.from(e.target.files || []);
if (selected.length === 0) { if (selected.length === 0) {
@@ -395,8 +344,9 @@ export default function ReceivedInvoices({
setUploadFiles([]); setUploadFiles([]);
setUploadMeta([]); setUploadMeta([]);
setUploadErrors({}); setUploadErrors({});
fetchList(); queryClient.invalidateQueries({
refreshStats(); queryKey: ["invoices", "received"],
});
} else { } else {
alert.error(data.error || "Chyba při nahrávání"); alert.error(data.error || "Chyba při nahrávání");
} }
@@ -465,8 +415,9 @@ export default function ReceivedInvoices({
alert.success(data.message || "Faktura byla aktualizována"); alert.success(data.message || "Faktura byla aktualizována");
setEditOpen(false); setEditOpen(false);
setEditInvoice(null); setEditInvoice(null);
fetchList(); queryClient.invalidateQueries({
refreshStats(); queryKey: ["invoices", "received"],
});
} else { } else {
alert.error(data.error || "Chyba při ukládání"); alert.error(data.error || "Chyba při ukládání");
} }
@@ -493,8 +444,9 @@ export default function ReceivedInvoices({
if (data.success) { if (data.success) {
alert.success(data.message || "Faktura byla smazána"); alert.success(data.message || "Faktura byla smazána");
setDeleteConfirm({ show: false, invoice: null }); setDeleteConfirm({ show: false, invoice: null });
fetchList(); queryClient.invalidateQueries({
refreshStats(); queryKey: ["invoices", "received"],
});
} else { } else {
alert.error(data.error || "Chyba při mazání"); alert.error(data.error || "Chyba při mazání");
} }
@@ -538,8 +490,9 @@ export default function ReceivedInvoices({
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
alert.success("Faktura označena jako uhrazená"); alert.success("Faktura označena jako uhrazená");
fetchList(); queryClient.invalidateQueries({
refreshStats(); queryKey: ["invoices", "received"],
});
} else { } else {
alert.error(data.error || "Nepodařilo se změnit stav"); alert.error(data.error || "Nepodařilo se změnit stav");
} }
@@ -551,26 +504,15 @@ export default function ReceivedInvoices({
const monthLabel = `${MONTH_NAMES[statsMonth - 1]}`; const monthLabel = `${MONTH_NAMES[statsMonth - 1]}`;
const renderKpi = () => { const renderKpi = () => {
if (!hasLoadedOnce.current && statsLoading) { if (statsQuery.isPending && !hasLoadedOnce.current) {
return ( return (
<div className="admin-kpi-grid admin-kpi-4 mb-6"> <Skeleton
{[0, 1, 2, 3].map((i) => ( name="received-invoices-kpi"
<div key={i} className="admin-stat-card"> loading={statsQuery.isPending && !hasLoadedOnce.current}
<div fixture={<ReceivedInvoicesFixture />}
className="admin-skeleton-line" >
style={{ width: "60%", height: "11px", marginBottom: "0.5rem" }} <div />
/> </Skeleton>
<div
className="admin-skeleton-line"
style={{ width: "40%", height: "28px", marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "50%", height: "12px" }}
/>
</div>
))}
</div>
); );
} }
if (!stats) { if (!stats) {
@@ -680,18 +622,16 @@ export default function ReceivedInvoices({
/> />
</div> </div>
{loading && ( {showListSkeleton && (
<div className="admin-skeleton" style={{ gap: "1rem" }}> <Skeleton
{[0, 1, 2, 3, 4].map((i) => ( name="received-invoices-list"
<div key={i} className="admin-skeleton-row"> loading={showListSkeleton}
<div className="admin-skeleton-line w-1/4" /> fixture={<ReceivedInvoicesFixture />}
<div className="admin-skeleton-line w-1/4" /> >
<div className="admin-skeleton-line w-1/4" /> <div />
</div> </Skeleton>
))}
</div>
)} )}
{!loading && invoices.length === 0 && ( {!showListSkeleton && invoices.length === 0 && (
<div className="admin-empty-state"> <div className="admin-empty-state">
<div className="admin-empty-icon"> <div className="admin-empty-icon">
<svg <svg
@@ -723,7 +663,7 @@ export default function ReceivedInvoices({
)} )}
</div> </div>
)} )}
{!loading && invoices.length > 0 && ( {!showListSkeleton && invoices.length > 0 && (
<div className="admin-table-responsive"> <div className="admin-table-responsive">
<table className="admin-table"> <table className="admin-table">
<thead> <thead>

View File

@@ -1,14 +1,22 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { useNavigate, Navigate, useSearchParams } from "react-router-dom"; import { Navigate, useSearchParams } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal"; import ConfirmModal from "../components/ConfirmModal";
import FormField from "../components/FormField"; import FormField from "../components/FormField";
import useModalLock from "../hooks/useModalLock"; import useModalLock from "../hooks/useModalLock";
import CompanySettings from "./CompanySettings"; import CompanySettings from "./CompanySettings";
import {
companySettingsOptions,
systemInfoOptions,
require2FAOptions,
} from "../lib/queries/settings";
import apiFetch from "../utils/api"; import apiFetch from "../utils/api";
import { Skeleton } from "boneyard-js/react";
import SettingsFixture from "../fixtures/SettingsFixture";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
interface SystemSettingsData { interface SystemSettingsData {
@@ -70,61 +78,7 @@ interface RoleForm {
permissions: string[]; permissions: string[];
} }
export default function Settings() { const DEFAULT_SYS_FORM: Omit<SystemSettingsData, "app_version"> = {
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [roles, setRoles] = useState<Role[]>([]);
const [users, setUsers] = useState<{ role_id: number }[]>([]);
const [, setAllPermissions] = useState<Permission[]>([]);
const [permissionGroups, setPermissionGroups] = useState<
Record<string, Permission[]>
>({});
const [require2FA, setRequire2FA] = useState(false);
const [require2FALoading, setRequire2FALoading] = useState(true);
const [require2FASaving, setRequire2FASaving] = useState(false);
const [showModal, setShowModal] = useState(false);
const [editingRole, setEditingRole] = useState<Role | null>(null);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<RoleForm>({
name: "",
display_name: "",
description: "",
permissions: [],
});
const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
role: Role | null;
}>({ show: false, role: null });
const [deleting, setDeleting] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const tabParam = searchParams.get("tab");
const activeTab = (
tabParam === "system"
? "system"
: tabParam === "firma"
? "firma"
: "security"
) as "security" | "system" | "firma";
const setActiveTab = (tab: "security" | "system" | "firma") =>
setSearchParams({ tab }, { replace: true });
const [sysSettings, setSysSettings] = useState<SystemSettingsData | null>(
null,
);
const [sysSettingsLoading, setSysSettingsLoading] = useState(false);
const [sysSettingsSaving, setSysSettingsSaving] = useState(false);
const [systemInfo, setSystemInfo] = useState<Record<string, any> | null>(
null,
);
const [sysForm, setSysForm] = useState<
Omit<SystemSettingsData, "app_version">
>({
break_threshold_hours: 6, break_threshold_hours: 6,
break_duration_short: 15, break_duration_short: 15,
break_duration_long: 30, break_duration_long: 30,
@@ -146,22 +100,19 @@ export default function Settings() {
offer_number_pattern: "{YYYY}/{PREFIX}/{NNN}", offer_number_pattern: "{YYYY}/{PREFIX}/{NNN}",
order_number_pattern: "{YY}{CODE}{NNNN}", order_number_pattern: "{YY}{CODE}{NNNN}",
invoice_number_pattern: "{YY}{CODE}{NNNN}", invoice_number_pattern: "{YY}{CODE}{NNNN}",
}); };
export default function Settings() {
const alert = useAlert();
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const canManage = hasPermission("settings.manage"); const canManage = hasPermission("settings.manage");
if (!canManage) { // ── TanStack Query: roles, permissions, users ──
return <Navigate to="/" replace />; const { data: rolesData, isPending: rolesLoading } = useQuery({
} queryKey: ["roles"],
queryFn: async () => {
useModalLock(showModal);
const fetchData = useCallback(async () => {
if (!canManage) {
setLoading(false);
return;
}
try {
const [rolesRes, permsRes, usersRes] = await Promise.all([ const [rolesRes, permsRes, usersRes] = await Promise.all([
apiFetch(`${API_BASE}/roles`), apiFetch(`${API_BASE}/roles`),
apiFetch(`${API_BASE}/roles/permissions`), apiFetch(`${API_BASE}/roles/permissions`),
@@ -170,126 +121,133 @@ export default function Settings() {
const rolesResult = await rolesRes.json(); const rolesResult = await rolesRes.json();
const permsResult = await permsRes.json(); const permsResult = await permsRes.json();
const usersResult = await usersRes.json(); const usersResult = await usersRes.json();
if (!rolesResult.success)
throw new Error(rolesResult.error || "Nepodařilo se načíst role");
if (!permsResult.success)
throw new Error(permsResult.error || "Nepodařilo se načíst oprávnění");
if (!usersResult.success)
throw new Error(usersResult.error || "Nepodařilo se načíst uživatele");
return {
roles: Array.isArray(rolesResult.data) ? rolesResult.data : [],
permissions: Array.isArray(permsResult.data) ? permsResult.data : [],
users: Array.isArray(usersResult.data) ? usersResult.data : [],
};
},
enabled: canManage,
});
if (rolesResult.success) { const roles = rolesData?.roles ?? [];
setRoles(Array.isArray(rolesResult.data) ? rolesResult.data : []); const users = rolesData?.users ?? [];
} else {
alert.error(rolesResult.error || "Nepodařilo se načíst role");
}
if (permsResult.success) { // Group permissions by module
const perms: Permission[] = Array.isArray(permsResult.data) const permissionGroups = useMemo<Record<string, Permission[]>>(() => {
? permsResult.data const perms: Permission[] = rolesData?.permissions ?? [];
: [];
setAllPermissions(perms);
// Group by module (part before '.')
const groups: Record<string, Permission[]> = {}; const groups: Record<string, Permission[]> = {};
for (const p of perms) { for (const p of perms) {
const mod = p.name.split(".")[0] || "other"; const mod = p.name.split(".")[0] || "other";
if (!groups[mod]) groups[mod] = []; if (!groups[mod]) groups[mod] = [];
groups[mod].push(p); groups[mod].push(p);
} }
setPermissionGroups(groups); return groups;
} }, [rolesData?.permissions]);
if (usersResult.success) { // ── TanStack Query: 2FA required ──
setUsers(Array.isArray(usersResult.data) ? usersResult.data : []); const { data: totpData, isPending: require2FALoading } =
} useQuery(require2FAOptions());
} catch { const require2FA = totpData?.require_2fa ?? false;
alert.error("Chyba připojení");
} finally {
setLoading(false);
}
}, [alert, canManage]);
// ── TanStack Query: system settings (lazy, only on system/security tab) ──
const [searchParams, setSearchParams] = useSearchParams();
const tabParam = searchParams.get("tab");
const activeTab = (
tabParam === "system"
? "system"
: tabParam === "firma"
? "firma"
: "security"
) as "security" | "system" | "firma";
const setActiveTab = (tab: "security" | "system" | "firma") =>
setSearchParams({ tab }, { replace: true });
const { data: sysSettingsData, isPending: sysSettingsLoading } = useQuery({
...companySettingsOptions(),
enabled: canManage && (activeTab === "system" || activeTab === "security"),
});
const { data: systemInfo } = useQuery({
...systemInfoOptions(),
enabled: canManage && activeTab === "system",
});
// ── Local state ──
const [require2FASaving, setRequire2FASaving] = useState(false);
const [showModal, setShowModal] = useState(false);
const [editingRole, setEditingRole] = useState<Role | null>(null);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<RoleForm>({
name: "",
display_name: "",
description: "",
permissions: [],
});
const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
role: Role | null;
}>({ show: false, role: null });
const [deleting, setDeleting] = useState(false);
const [sysSettingsSaving, setSysSettingsSaving] = useState(false);
const [sysForm, setSysForm] = useState(DEFAULT_SYS_FORM);
const [sysFormInitialized, setSysFormInitialized] = useState(false);
// ── Populate sysForm from query data ──
useEffect(() => { useEffect(() => {
fetchData(); if (!sysSettingsData || sysFormInitialized) return;
}, [fetchData]); const d = sysSettingsData as Record<string, unknown>;
const fetch2FARequired = useCallback(async () => {
if (!canManage) {
setRequire2FALoading(false);
return;
}
try {
const response = await apiFetch(`${API_BASE}/totp/required`);
const result = await response.json();
if (result.success) {
setRequire2FA(result.data.require_2fa);
}
} catch {
/* ignore */
} finally {
setRequire2FALoading(false);
}
}, [canManage]);
useEffect(() => {
fetch2FARequired();
}, [fetch2FARequired]);
const fetchSystemSettings = useCallback(async () => {
if (!canManage) return;
setSysSettingsLoading(true);
try {
const response = await apiFetch(`${API_BASE}/company-settings`);
const result = await response.json();
if (result.success) {
const d: SystemSettingsData = result.data;
setSysSettings(d);
setSysForm({ setSysForm({
break_threshold_hours: d.break_threshold_hours ?? 6, break_threshold_hours: (d.break_threshold_hours as number) ?? 6,
break_duration_short: d.break_duration_short ?? 15, break_duration_short: (d.break_duration_short as number) ?? 15,
break_duration_long: d.break_duration_long ?? 30, break_duration_long: (d.break_duration_long as number) ?? 30,
clock_rounding_minutes: d.clock_rounding_minutes ?? 15, clock_rounding_minutes: (d.clock_rounding_minutes as number) ?? 15,
invoice_alert_email: d.invoice_alert_email || "", invoice_alert_email: (d.invoice_alert_email as string) || "",
leave_notify_email: d.leave_notify_email || "", leave_notify_email: (d.leave_notify_email as string) || "",
smtp_from: d.smtp_from || "", smtp_from: (d.smtp_from as string) || "",
smtp_from_name: d.smtp_from_name || "", smtp_from_name: (d.smtp_from_name as string) || "",
max_login_attempts: d.max_login_attempts ?? 5, max_login_attempts: (d.max_login_attempts as number) ?? 5,
lockout_minutes: d.lockout_minutes ?? 15, lockout_minutes: (d.lockout_minutes as number) ?? 15,
max_requests_per_minute: d.max_requests_per_minute ?? 300, max_requests_per_minute: (d.max_requests_per_minute as number) ?? 300,
default_currency: d.default_currency || "CZK", default_currency: (d.default_currency as string) || "CZK",
default_vat_rate: d.default_vat_rate ?? 21, default_vat_rate: (d.default_vat_rate as number) ?? 21,
available_vat_rates: available_vat_rates:
Array.isArray(d.available_vat_rates) && Array.isArray(d.available_vat_rates) && d.available_vat_rates.length > 0
d.available_vat_rates.length > 0 ? (d.available_vat_rates as number[])
? d.available_vat_rates
: [0, 10, 12, 15, 21], : [0, 10, 12, 15, 21],
available_currencies: available_currencies:
Array.isArray(d.available_currencies) && Array.isArray(d.available_currencies) &&
d.available_currencies.length > 0 d.available_currencies.length > 0
? d.available_currencies ? (d.available_currencies as string[])
: ["CZK", "EUR", "USD", "GBP"], : ["CZK", "EUR", "USD", "GBP"],
quotation_prefix: d.quotation_prefix || "NA", quotation_prefix: (d.quotation_prefix as string) || "NA",
order_type_code: d.order_type_code || "71", order_type_code: (d.order_type_code as string) || "71",
invoice_type_code: d.invoice_type_code || "81", invoice_type_code: (d.invoice_type_code as string) || "81",
offer_number_pattern: offer_number_pattern:
d.offer_number_pattern || "{YYYY}/{PREFIX}/{NNN}", (d.offer_number_pattern as string) || "{YYYY}/{PREFIX}/{NNN}",
order_number_pattern: d.order_number_pattern || "{YY}{CODE}{NNNN}", order_number_pattern:
(d.order_number_pattern as string) || "{YY}{CODE}{NNNN}",
invoice_number_pattern: invoice_number_pattern:
d.invoice_number_pattern || "{YY}{CODE}{NNNN}", (d.invoice_number_pattern as string) || "{YY}{CODE}{NNNN}",
}); });
} else { setSysFormInitialized(true);
alert.error(result.error || "Nepodařilo se načíst systémová nastavení"); }, [sysSettingsData, sysFormInitialized]);
}
} catch {
alert.error("Chyba připojení");
} finally {
setSysSettingsLoading(false);
}
// Fetch system info
apiFetch(`${API_BASE}/company-settings/system-info`)
.then((r) => r.json())
.then((d) => {
if (d.success) setSystemInfo(d.data);
})
.catch(() => {});
}, [alert, canManage]);
useEffect(() => { useModalLock(showModal);
fetchSystemSettings();
}, [fetchSystemSettings]); // ── Early return after all hooks ──
if (!canManage) {
return <Navigate to="/" replace />;
}
const handleSaveSystemSettings = async () => { const handleSaveSystemSettings = async () => {
setSysSettingsSaving(true); setSysSettingsSaving(true);
@@ -302,7 +260,7 @@ export default function Settings() {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
alert.success(result.message || "Systémová nastavení byla uložena"); alert.success(result.message || "Systémová nastavení byla uložena");
fetchSystemSettings(); queryClient.invalidateQueries({ queryKey: ["company-settings"] });
} else { } else {
alert.error(result.error || "Nepodařilo se uložit nastavení"); alert.error(result.error || "Nepodařilo se uložit nastavení");
} }
@@ -323,8 +281,8 @@ export default function Settings() {
}); });
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
setRequire2FA(!require2FA);
alert.success(result.message || "2FA nastavení uloženo"); alert.success(result.message || "2FA nastavení uloženo");
queryClient.invalidateQueries({ queryKey: ["settings", "2fa"] });
} else { } else {
alert.error(result.error || "Nepodařilo se uložit nastavení"); alert.error(result.error || "Nepodařilo se uložit nastavení");
} }
@@ -339,7 +297,7 @@ export default function Settings() {
return text return text
.toLowerCase() .toLowerCase()
.normalize("NFD") .normalize("NFD")
.replace(/[\u0300-\u036f]/g, "") .replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-") .replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, ""); .replace(/^-+|-+$/g, "");
}; };
@@ -443,7 +401,7 @@ export default function Settings() {
result.message || result.message ||
(editingRole ? "Role byla aktualizována" : "Role byla vytvořena"), (editingRole ? "Role byla aktualizována" : "Role byla vytvořena"),
); );
fetchData(); queryClient.invalidateQueries({ queryKey: ["roles"] });
} else { } else {
alert.error(result.error || "Nepodařilo se uložit roli"); alert.error(result.error || "Nepodařilo se uložit roli");
} }
@@ -471,7 +429,7 @@ export default function Settings() {
if (result.success) { if (result.success) {
setDeleteConfirm({ show: false, role: null }); setDeleteConfirm({ show: false, role: null });
alert.success(result.message || "Role byla smazána"); alert.success(result.message || "Role byla smazána");
fetchData(); queryClient.invalidateQueries({ queryKey: ["roles"] });
} else { } else {
alert.error(result.error || "Nepodařilo se smazat roli"); alert.error(result.error || "Nepodařilo se smazat roli");
} }
@@ -482,39 +440,15 @@ export default function Settings() {
} }
}; };
if (loading) { if (rolesLoading) {
return ( return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}> <Skeleton
<div name="settings"
className="admin-skeleton-row" loading={rolesLoading}
style={{ justifyContent: "space-between" }} fixture={<SettingsFixture />}
> >
<div> <div />
<div </Skeleton>
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].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/3 mb-2" />
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
); );
} }
@@ -523,10 +457,13 @@ export default function Settings() {
const get2FADescription = (): React.ReactNode => { const get2FADescription = (): React.ReactNode => {
if (require2FALoading) { if (require2FALoading) {
return ( return (
<div <Skeleton
className="admin-skeleton-line" name="settings-2fa"
style={{ width: "200px", height: "12px" }} loading={require2FALoading}
/> fixture={<SettingsFixture />}
>
<span />
</Skeleton>
); );
} }
if (require2FA) if (require2FA)
@@ -783,7 +720,7 @@ export default function Settings() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{roles.map((role) => ( {roles.map((role: Role) => (
<tr key={role.id}> <tr key={role.id}>
<td> <td>
<div <div
@@ -804,7 +741,7 @@ export default function Settings() {
</div> </div>
</td> </td>
<td style={{ color: "var(--text-secondary)" }}> <td style={{ color: "var(--text-secondary)" }}>
{role.description || "\u2014"} {role.description || ""}
</td> </td>
<td> <td>
<span className="admin-badge admin-badge-info"> <span className="admin-badge admin-badge-info">
@@ -815,7 +752,11 @@ export default function Settings() {
</td> </td>
<td> <td>
<span className="admin-badge admin-badge-secondary"> <span className="admin-badge admin-badge-secondary">
{users.filter((u) => u.role_id === role.id).length} {
users.filter(
(u: { role_id: number }) => u.role_id === role.id,
).length
}
</span> </span>
</td> </td>
<td> <td>
@@ -845,20 +786,26 @@ export default function Settings() {
} }
className="admin-btn-icon danger" className="admin-btn-icon danger"
title={ title={
users.filter((u) => u.role_id === role.id) users.filter(
.length > 0 (u: { role_id: number }) =>
u.role_id === role.id,
).length > 0
? "Nelze smazat roli s přiřazenými uživateli" ? "Nelze smazat roli s přiřazenými uživateli"
: "Smazat" : "Smazat"
} }
aria-label={ aria-label={
users.filter((u) => u.role_id === role.id) users.filter(
.length > 0 (u: { role_id: number }) =>
u.role_id === role.id,
).length > 0
? "Nelze smazat roli s přiřazenými uživateli" ? "Nelze smazat roli s přiřazenými uživateli"
: "Smazat" : "Smazat"
} }
disabled={ disabled={
users.filter((u) => u.role_id === role.id) users.filter(
.length > 0 (u: { role_id: number }) =>
u.role_id === role.id,
).length > 0
} }
> >
<svg <svg
@@ -888,21 +835,14 @@ export default function Settings() {
{/* System Settings Tab */} {/* System Settings Tab */}
{activeTab === "system" && canManage && ( {activeTab === "system" && canManage && (
<> <>
{sysSettingsLoading ? ( {sysSettingsLoading && !sysFormInitialized ? (
<div <Skeleton
className="admin-skeleton" name="settings-system"
style={{ padding: 0, gap: "1.5rem" }} loading={sysSettingsLoading && !sysFormInitialized}
fixture={<SettingsFixture />}
> >
{[0, 1, 2].map((i) => ( <div />
<div key={i} className="admin-card"> </Skeleton>
<div className="admin-skeleton" style={{ gap: "1rem" }}>
<div className="admin-skeleton-line w-1/3 mb-2" />
<div className="admin-skeleton-line w-full" />
<div className="admin-skeleton-line w-full" />
</div>
</div>
))}
</div>
) : ( ) : (
<> <>
{/* Section 1: Docházka */} {/* Section 1: Docházka */}
@@ -1374,12 +1314,33 @@ export default function Settings() {
<tbody> <tbody>
{( {(
[ [
["Verze", systemInfo.app_version], [
["Node.js", systemInfo.node_version], "Verze",
["Platforma", systemInfo.platform], (systemInfo as Record<string, unknown>)
["Uptime", systemInfo.uptime], .app_version,
["Prostředí", systemInfo.environment], ],
["Časová zóna", systemInfo.timezone], [
"Node.js",
(systemInfo as Record<string, unknown>)
.node_version,
],
[
"Platforma",
(systemInfo as Record<string, unknown>).platform,
],
[
"Uptime",
(systemInfo as Record<string, unknown>).uptime,
],
[
"Prostředí",
(systemInfo as Record<string, unknown>)
.environment,
],
[
"Časová zóna",
(systemInfo as Record<string, unknown>).timezone,
],
] as [string, string][] ] as [string, string][]
).map(([label, val]) => ( ).map(([label, val]) => (
<tr key={label}> <tr key={label}>
@@ -1415,14 +1376,22 @@ export default function Settings() {
</tr> </tr>
{( {(
[ [
["Proces (RSS)", systemInfo.memory?.rss], [
"Proces (RSS)",
(
systemInfo as Record<
string,
Record<string, unknown>
>
).memory?.rss as string,
],
[ [
"Heap", "Heap",
`${systemInfo.memory?.heap_used} / ${systemInfo.memory?.heap_total}`, `${(systemInfo as Record<string, Record<string, unknown>>).memory?.heap_used} / ${(systemInfo as Record<string, Record<string, unknown>>).memory?.heap_total}`,
], ],
[ [
"Systém", "Systém",
`${systemInfo.memory?.system_free} volné z ${systemInfo.memory?.system_total}`, `${(systemInfo as Record<string, Record<string, unknown>>).memory?.system_free} volné z ${(systemInfo as Record<string, Record<string, unknown>>).memory?.system_total}`,
], ],
] as [string, string][] ] as [string, string][]
).map(([label, val]) => ( ).map(([label, val]) => (
@@ -1464,9 +1433,14 @@ export default function Settings() {
</td> </td>
<td style={{ padding: "4px 0" }}> <td style={{ padding: "4px 0" }}>
<span <span
className={`admin-badge ${systemInfo.database?.status === "ok" ? "admin-badge-success" : "admin-badge-danger"}`} className={`admin-badge ${(systemInfo as Record<string, Record<string, unknown>>).database?.status === "ok" ? "admin-badge-success" : "admin-badge-danger"}`}
> >
{systemInfo.database?.status === "ok" {(
systemInfo as Record<
string,
Record<string, unknown>
>
).database?.status === "ok"
? "Připojeno" ? "Připojeno"
: "Chyba"} : "Chyba"}
</span> </span>
@@ -1482,7 +1456,14 @@ export default function Settings() {
Migrace Migrace
</td> </td>
<td style={{ padding: "4px 0" }}> <td style={{ padding: "4px 0" }}>
{systemInfo.database?.migrations_applied} {
(
systemInfo as Record<
string,
Record<string, unknown>
>
).database?.migrations_applied as string
}
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -1502,9 +1483,33 @@ export default function Settings() {
</tr> </tr>
{( {(
[ [
["Projekty", systemInfo.nas?.projects], [
["Finance", systemInfo.nas?.financials], "Projekty",
["Nabídky", systemInfo.nas?.offers], (
systemInfo as Record<
string,
Record<string, Record<string, unknown>>
>
).nas?.projects,
],
[
"Finance",
(
systemInfo as Record<
string,
Record<string, Record<string, unknown>>
>
).nas?.financials,
],
[
"Nabídky",
(
systemInfo as Record<
string,
Record<string, Record<string, unknown>>
>
).nas?.offers,
],
] as [string, Record<string, any>][] ] as [string, Record<string, any>][]
).map(([label, info]) => ( ).map(([label, info]) => (
<tr key={label}> <tr key={label}>
@@ -1541,10 +1546,15 @@ export default function Settings() {
</tbody> </tbody>
</table> </table>
) : ( ) : (
<div <Skeleton
className="admin-skeleton-line" name="settings-permissions"
style={{ width: "60%", height: 14 }} loading={
/> !role.permissions || role.permissions.length === 0
}
fixture={<span>...</span>}
>
<span>{role.permissions?.length || 0} oprávnění</span>
</Skeleton>
)} )}
</div> </div>
</motion.div> </motion.div>

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from "react"; import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
@@ -12,6 +13,9 @@ import Forbidden from "../components/Forbidden";
import { formatDate } from "../utils/attendanceHelpers"; import { formatDate } from "../utils/attendanceHelpers";
import { formatKm } from "../utils/formatters"; import { formatKm } from "../utils/formatters";
import apiFetch from "../utils/api"; import apiFetch from "../utils/api";
import { tripListOptions, tripVehiclesOptions } from "../lib/queries/trips";
import { Skeleton } from "boneyard-js/react";
import TripsFixture from "../fixtures/TripsFixture";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
interface Vehicle { interface Vehicle {
@@ -49,10 +53,20 @@ interface TripForm {
export default function Trips() { export default function Trips() {
const alert = useAlert(); const alert = useAlert();
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true); const queryClient = useQueryClient();
const { data: tripsData, isPending: tripsLoading } = useQuery(
tripListOptions({}),
);
const { data: vehiclesData } = useQuery(tripVehiclesOptions());
const trips = (tripsData ?? []) as Record<string, unknown>[] as Trip[];
const vehicles = (vehiclesData ?? []) as Record<
string,
unknown
>[] as Vehicle[];
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [trips, setTrips] = useState<Trip[]>([]);
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editingTrip, setEditingTrip] = useState<Trip | null>(null); const [editingTrip, setEditingTrip] = useState<Trip | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ const [deleteConfirm, setDeleteConfirm] = useState<{
@@ -72,37 +86,6 @@ export default function Trips() {
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
const [, setLastKm] = useState(0); const [, setLastKm] = useState(0);
const fetchData = useCallback(
async (showLoading = true) => {
if (showLoading) setLoading(true);
try {
const [tripsRes, vehiclesRes] = await Promise.all([
apiFetch(`${API_BASE}/trips`),
apiFetch(`${API_BASE}/vehicles`),
]);
const tripsResult = await tripsRes.json();
const vehiclesResult = await vehiclesRes.json();
if (tripsResult.success) {
setTrips(Array.isArray(tripsResult.data) ? tripsResult.data : []);
}
if (vehiclesResult.success) {
setVehicles(
Array.isArray(vehiclesResult.data) ? vehiclesResult.data : [],
);
}
} catch {
alert.error("Nepodařilo se načíst data");
} finally {
if (showLoading) setLoading(false);
}
},
[alert],
);
useEffect(() => {
fetchData();
}, [fetchData]);
useModalLock(showModal); useModalLock(showModal);
if (!hasPermission("trips.record")) return <Forbidden />; if (!hasPermission("trips.record")) return <Forbidden />;
@@ -208,8 +191,7 @@ export default function Trips() {
if (result.success) { if (result.success) {
setShowModal(false); setShowModal(false);
await fetchData(false); queryClient.invalidateQueries({ queryKey: ["trips"] });
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(result.message); alert.success(result.message);
} else { } else {
alert.error(result.error); alert.error(result.error);
@@ -230,7 +212,7 @@ export default function Trips() {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
await fetchData(false); queryClient.invalidateQueries({ queryKey: ["trips"] });
alert.success(result.message); alert.success(result.message);
} else { } else {
alert.error(result.error); alert.error(result.error);
@@ -248,65 +230,11 @@ export default function Trips() {
return end > start ? end - start : 0; return end > start ? end - start : 0;
}; };
if (loading) { if (tripsLoading) {
return ( return (
<div> <Skeleton name="trips" loading={tripsLoading} fixture={<TripsFixture />}>
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}> <div />
<div </Skeleton>
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
<div
className="admin-skeleton-line h-10"
style={{ width: "140px", borderRadius: "8px" }}
/>
</div>
<div className="admin-grid admin-grid-4">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-stat-card">
<div
className="admin-skeleton-line"
style={{
width: "60%",
height: "11px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{
width: "40%",
height: "28px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{ width: "50%", height: "12px" }}
/>
</div>
))}
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
</div>
); );
} }

View File

@@ -1,10 +1,17 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal"; import ConfirmModal from "../components/ConfirmModal";
import {
tripListOptions,
tripVehiclesOptions,
tripUsersOptions,
} from "../lib/queries/trips";
import { companySettingsOptions } from "../lib/queries/settings";
import AdminDatePicker from "../components/AdminDatePicker"; import AdminDatePicker from "../components/AdminDatePicker";
import FormField from "../components/FormField"; import FormField from "../components/FormField";
@@ -12,6 +19,8 @@ import useModalLock from "../hooks/useModalLock";
import { formatDate } from "../utils/attendanceHelpers"; import { formatDate } from "../utils/attendanceHelpers";
import { formatKm } from "../utils/formatters"; import { formatKm } from "../utils/formatters";
import apiFetch from "../utils/api"; import apiFetch from "../utils/api";
import { Skeleton } from "boneyard-js/react";
import TripsAdminFixture from "../fixtures/TripsAdminFixture";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
interface Vehicle { interface Vehicle {
@@ -88,8 +97,7 @@ function mapTrip(bt: BackendTrip): Trip {
export default function TripsAdmin() { export default function TripsAdmin() {
const alert = useAlert(); const alert = useAlert();
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true); const queryClient = useQueryClient();
const [companyName, setCompanyName] = useState("");
const [filterMonth, setFilterMonth] = useState(() => const [filterMonth, setFilterMonth] = useState(() =>
String(new Date().getMonth() + 1), String(new Date().getMonth() + 1),
); );
@@ -98,9 +106,6 @@ export default function TripsAdmin() {
); );
const [filterVehicleId, setFilterVehicleId] = useState(""); const [filterVehicleId, setFilterVehicleId] = useState("");
const [filterUserId, setFilterUserId] = useState(""); const [filterUserId, setFilterUserId] = useState("");
const [trips, setTrips] = useState<Trip[]>([]);
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
const [users, setUsers] = useState<UserShort[]>([]);
const printRef = useRef<HTMLDivElement>(null); const printRef = useRef<HTMLDivElement>(null);
const [showEditModal, setShowEditModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false);
@@ -121,56 +126,27 @@ export default function TripsAdmin() {
trip: Trip | null; trip: Trip | null;
}>({ show: false, trip: null }); }>({ show: false, trip: null });
// Fetch vehicles and users once on mount const { data: vehiclesData = [] } = useQuery(tripVehiclesOptions());
useEffect(() => { const vehicles = vehiclesData as Vehicle[];
const fetchLookups = async () => {
try {
const [vRes, uRes, csRes] = await Promise.all([
apiFetch(`${API_BASE}/vehicles`),
apiFetch(`${API_BASE}/trips/users`),
apiFetch(`${API_BASE}/company-settings`),
]);
const vJson = await vRes.json();
const uJson = await uRes.json();
const csJson = await csRes.json();
if (vJson.success) setVehicles(vJson.data);
if (csJson.success) setCompanyName(csJson.data.company_name || "");
if (uJson.success) {
setUsers(uJson.data);
}
} catch {
// silently fail, filters will just be empty
}
};
fetchLookups();
}, []);
const fetchData = useCallback( const { data: tripUsersData = [] } = useQuery(tripUsersOptions());
async (showLoading = true) => { const tripUsers = tripUsersData as UserShort[];
if (showLoading) setLoading(true);
try {
let url = `${API_BASE}/trips?limit=1000&month=${filterMonth}&year=${filterYear}`;
if (filterVehicleId) url += `&vehicle_id=${filterVehicleId}`;
if (filterUserId) url += `&user_id=${filterUserId}`;
const response = await apiFetch(url); const { data: companySettings } = useQuery(companySettingsOptions());
const result = await response.json(); const companyName =
if (result.success) { ((companySettings as Record<string, unknown> | undefined)
const mapped = (result.data as BackendTrip[]).map(mapTrip); ?.company_name as string) ?? "";
setTrips(mapped);
} const { data: tripsData, isPending } = useQuery(
} catch { tripListOptions({
alert.error("Nepodařilo se načíst data"); month: Number(filterMonth) || undefined,
} finally { year: Number(filterYear) || undefined,
if (showLoading) setLoading(false); vehicleId: filterVehicleId ? Number(filterVehicleId) : undefined,
} userId: filterUserId ? Number(filterUserId) : undefined,
}, perPage: 100,
[filterMonth, filterYear, filterVehicleId, filterUserId, alert], }),
); );
const trips = ((tripsData ?? []) as BackendTrip[]).map(mapTrip);
useEffect(() => {
fetchData();
}, [fetchData]);
useModalLock(showEditModal); useModalLock(showEditModal);
@@ -211,8 +187,7 @@ export default function TripsAdmin() {
if (result.success) { if (result.success) {
setShowEditModal(false); setShowEditModal(false);
await fetchData(false); queryClient.invalidateQueries({ queryKey: ["trips"] });
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(result.message); alert.success(result.message);
} else { } else {
alert.error(result.error); alert.error(result.error);
@@ -237,7 +212,7 @@ export default function TripsAdmin() {
if (result.success) { if (result.success) {
setDeleteConfirm({ show: false, trip: null }); setDeleteConfirm({ show: false, trip: null });
await fetchData(false); queryClient.invalidateQueries({ queryKey: ["trips"] });
alert.success(result.message); alert.success(result.message);
} else { } else {
alert.error(result.error); alert.error(result.error);
@@ -259,7 +234,7 @@ export default function TripsAdmin() {
}; };
const getSelectedUserName = () => { const getSelectedUserName = () => {
if (!filterUserId) return null; if (!filterUserId) return null;
const u = users.find((u) => String(u.id) === filterUserId); const u = tripUsers.find((u) => String(u.id) === filterUserId);
return u?.name || null; return u?.name || null;
}; };
@@ -468,7 +443,7 @@ export default function TripsAdmin() {
className="admin-form-select" className="admin-form-select"
> >
<option value="">Všichni řidiči</option> <option value="">Všichni řidiči</option>
{users.map((u) => ( {tripUsers.map((u) => (
<option key={u.id} value={u.id}> <option key={u.id} value={u.id}>
{u.name} {u.name}
</option> </option>
@@ -565,23 +540,18 @@ export default function TripsAdmin() {
transition={{ duration: 0.25, delay: 0.12 }} transition={{ duration: 0.25, delay: 0.12 }}
> >
<div className="admin-card-body"> <div className="admin-card-body">
{loading && ( <Skeleton
<div className="admin-skeleton" style={{ gap: "1.25rem" }}> name="trips-admin"
{[0, 1, 2, 3, 4].map((i) => ( loading={isPending}
<div key={i} className="admin-skeleton-row"> fixture={<TripsAdminFixture />}
<div className="admin-skeleton-line w-1/4" /> >
<div className="admin-skeleton-line w-1/3" /> <>
<div className="admin-skeleton-line w-1/4" /> {trips.length === 0 && (
</div>
))}
</div>
)}
{!loading && trips.length === 0 && (
<div className="admin-empty-state"> <div className="admin-empty-state">
<p>Žádné záznamy jízd pro vybrané období.</p> <p>Žádné záznamy jízd pro vybrané období.</p>
</div> </div>
)} )}
{!loading && trips.length > 0 && ( {trips.length > 0 && (
<div className="admin-table-responsive"> <div className="admin-table-responsive">
<table className="admin-table"> <table className="admin-table">
<thead> <thead>
@@ -613,7 +583,8 @@ export default function TripsAdmin() {
</td> </td>
<td className="admin-mono"> <td className="admin-mono">
<span style={{ whiteSpace: "nowrap" }}> <span style={{ whiteSpace: "nowrap" }}>
{formatKm(trip.start_km)} - {formatKm(trip.end_km)} {formatKm(trip.start_km)} -{" "}
{formatKm(trip.end_km)}
</span> </span>
</td> </td>
<td className="admin-mono"> <td className="admin-mono">
@@ -678,6 +649,8 @@ export default function TripsAdmin() {
</table> </table>
</div> </div>
)} )}
</>
</Skeleton>
</div> </div>
</motion.div> </motion.div>

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