Update package dependencies and remove unused API routes
- Added `pocketbase` as a dependency in `package.json` and `package-lock.json`. - Updated the `dev` script in `package.json` to run on port 6004 instead of 3000. - Removed unused API routes: `src/app/api/health/route.ts`, `src/app/api/mcp/route.ts`, `src/app/api/upload/route.ts`, and their associated logic to streamline the codebase. - Cleaned up `package-lock.json` by removing unnecessary peer dependencies and ensuring consistency in the dependency tree.
This commit is contained in:
parent
8aca2aed20
commit
c5b1acfc7f
38 changed files with 1426 additions and 4467 deletions
Binary file not shown.
|
|
@ -0,0 +1,227 @@
|
|||
2026-Aug-09 00:23:01.909052 Docker 27.0.3 with BuildKit and Buildx detected on deployment server (localhost).
|
||||
2026-Aug-09 00:23:01.959521 Starting deployment of http://forgejo-b82b4lpyyzyg40thdsjdd38i.163.245.214.68.sslip.io/David/MEGAINFRA-WEBAPP.git:<REDACTED>@\(.*\):#https://\1/#g" '\''/artifacts/hyq204tdb0sbkisvotvcoxjk'\''/.gitmodules || true && git -c http.version=HTTP/1.1 submodule sync && GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git -c http.version=HTTP/1.1 submodule update --init --recursive --depth=1; fi && cd '\''/artifacts/hyq204tdb0sbkisvotvcoxjk'\'' && GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git -c http.version=HTTP/1.1 lfs pull'
|
||||
2026-Aug-09 00:<REDACTED>@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
|
||||
2026-Aug-09 00:23:21.200991 [DEBUG] #3 resolve docker.io/docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e 0.0s done
|
||||
2026-Aug-09 00:23:21.200991 [DEBUG] #3 sha256:b5f3b260a9678e1d83d2fce86eeddf79420b79147eaba2a25986f47133d73720 482B / 482B done
|
||||
2026-Aug-09 00:23:21.200991 [DEBUG] #3 sha256:68ebc061390d9a7d6e194f9d58309c754a53cb8b4e3b0d89593920f42f4a9da4 1.26kB / 1.26kB done
|
||||
2026-Aug-09 00:23:21.200991 [DEBUG] #3 sha256:96918c57e42509b97f10c074d80672ecdbd3bb7dcd38c1bd95960cf291207416 0B / 11.98MB 0.1s
|
||||
2026-Aug-09 00:23:21.200991 [DEBUG] #3 sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e 8.40kB / 8.40kB done
|
||||
2026-Aug-09 00:23:21.601003 [DEBUG] #3 sha256:96918c57e42509b97f10c074d80672ecdbd3bb7dcd38c1bd95960cf291207416 1.05MB / 11.98MB 0.5s
|
||||
2026-Aug-09 00:23:21.701293 [DEBUG] #3 sha256:96918c57e42509b97f10c074d80672ecdbd3bb7dcd38c1bd95960cf291207416 2.10MB / 11.98MB 0.6s
|
||||
2026-Aug-09 00:23:21.809998 [DEBUG] #3 sha256:96918c57e42509b97f10c074d80672ecdbd3bb7dcd38c1bd95960cf291207416 4.19MB / 11.98MB 0.7s
|
||||
2026-Aug-09 00:23:22.005677 [DEBUG] #3 sha256:96918c57e42509b97f10c074d80672ecdbd3bb7dcd38c1bd95960cf291207416 6.29MB / 11.98MB 0.9s
|
||||
2026-Aug-09 00:23:22.101507 [DEBUG] #3 sha256:96918c57e42509b97f10c074d80672ecdbd3bb7dcd38c1bd95960cf291207416 7.34MB / 11.98MB 1.0s
|
||||
2026-Aug-09 00:23:22.301175 [DEBUG] #3 sha256:96918c57e42509b97f10c074d80672ecdbd3bb7dcd38c1bd95960cf291207416 9.44MB / 11.98MB 1.2s
|
||||
2026-Aug-09 00:23:22.502023 [DEBUG] #3 sha256:96918c57e42509b97f10c074d80672ecdbd3bb7dcd38c1bd95960cf291207416 11.98MB / 11.98MB 1.4s
|
||||
2026-Aug-09 00:23:22.666058 [DEBUG] #3 extracting sha256:96918c57e42509b97f10c074d80672ecdbd3bb7dcd38c1bd95960cf291207416
|
||||
2026-Aug-09 00:23:22.768008 [DEBUG] #3 sha256:96918c57e42509b97f10c074d80672ecdbd3bb7dcd38c1bd95960cf291207416 11.98MB / 11.98MB 1.5s done
|
||||
2026-Aug-09 00:23:23.041840 [DEBUG] #3 extracting sha256:96918c57e42509b97f10c074d80672ecdbd3bb7dcd38c1bd95960cf291207416 0.3s done
|
||||
2026-Aug-09 00:23:23.041840 [DEBUG] #3 DONE 2.0s
|
||||
2026-Aug-09 00:23:23.296406 [DEBUG] #4 [internal] load metadata for docker.io/library/node:20-alpine
|
||||
2026-Aug-09 00:23:26.399481 [DEBUG] #4 DONE 2.9s
|
||||
2026-Aug-09 00:23:26.399481 [DEBUG]
|
||||
2026-Aug-09 00:23:26.399481 [DEBUG] #5 [internal] load .dockerignore
|
||||
2026-Aug-09 00:23:26.399481 [DEBUG] #5 transferring context: 780B done
|
||||
2026-Aug-09 00:23:26.399481 [DEBUG] #5 DONE 0.1s
|
||||
2026-Aug-09 00:23:26.724389 [DEBUG] #6 [runner 1/6] FROM docker.io/library/node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293
|
||||
2026-Aug-09 00:23:26.724389 [DEBUG] #6 resolve docker.io/library/node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 0.0s done
|
||||
2026-Aug-09 00:23:26.856268 [DEBUG] #6 sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 7.67kB / 7.67kB done
|
||||
2026-Aug-09 00:23:26.856268 [DEBUG] #6 sha256:afdf98210b07b586eb71fa22ba2e432e058e4cd1304d31ed60888755b8c865fb 1.72kB / 1.72kB done
|
||||
2026-Aug-09 00:23:26.856268 [DEBUG] #6 sha256:11cedc39e663e7c5d5cb9cc77a461a0d2adc25537b94e6831a6108f09cb2001b 6.52kB / 6.52kB done
|
||||
2026-Aug-09 00:23:26.856268 [DEBUG] #6 DONE 0.3s
|
||||
2026-Aug-09 00:23:26.856268 [DEBUG]
|
||||
2026-Aug-09 00:23:26.856268 [DEBUG] #7 [internal] load build context
|
||||
2026-Aug-09 00:23:26.856268 [DEBUG] #7 transferring context: 683.75kB 0.1s done
|
||||
2026-Aug-09 00:23:26.856268 [DEBUG] #7 DONE 0.2s
|
||||
2026-Aug-09 00:23:26.985511 [DEBUG] #8 [runner 2/6] WORKDIR /app
|
||||
2026-Aug-09 00:23:26.985511 [DEBUG] #8 DONE 0.1s
|
||||
2026-Aug-09 00:23:26.985511 [DEBUG]
|
||||
2026-Aug-09 00:23:26.985511 [DEBUG] #9 [deps 2/5] RUN apk add --no-cache libc6-compat
|
||||
2026-Aug-09 00:23:27.501315 [DEBUG] #9 ...
|
||||
2026-Aug-09 00:23:27.501315 [DEBUG]
|
||||
2026-Aug-09 00:23:27.501315 [DEBUG] #10 [runner 3/6] RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
||||
2026-Aug-09 00:23:27.501315 [DEBUG] #10 DONE 0.5s
|
||||
2026-Aug-09 00:23:27.649321 [DEBUG] #9 [deps 2/5] RUN apk add --no-cache libc6-compat
|
||||
2026-Aug-09 00:23:29.544546 [DEBUG] #9 2.682 (1/3) Installing musl-obstack (1.2.3-r2)
|
||||
2026-Aug-09 00:23:29.710634 [DEBUG] #9 2.847 (2/3) Installing libucontext (1.3.3-r0)
|
||||
2026-Aug-09 00:23:29.874415 [DEBUG] #9 3.012 (3/3) Installing gcompat (1.1.0-r4)
|
||||
2026-Aug-09 00:23:30.043974 [DEBUG] #9 3.182 OK: 11.0 MiB in 21 packages
|
||||
2026-Aug-09 00:23:30.183605 [DEBUG] #9 DONE 3.3s
|
||||
2026-Aug-09 00:23:30.298352 [DEBUG] #11 [deps 3/5] WORKDIR /app
|
||||
2026-Aug-09 00:23:30.298352 [DEBUG] #11 DONE 0.1s
|
||||
2026-Aug-09 00:23:30.469332 [DEBUG] #12 [deps 4/5] COPY package.json package-lock.json* ./
|
||||
2026-Aug-09 00:23:30.512418 [DEBUG] #12 DONE 0.2s
|
||||
2026-Aug-09 00:23:30.720961 [DEBUG] #13 [deps 5/5] RUN npm ci --no-audit --no-fund
|
||||
2026-Aug-09 00:23:41.768774 [DEBUG] #13 11.21 npm warn deprecated sourcemap-codec@1.4.8: Please use @jridgewell/sourcemap-codec instead
|
||||
2026-Aug-09 00:23:43.780782 [DEBUG] #13 13.23 npm warn deprecated inflight@1.0.6: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
|
||||
2026-Aug-09 00:23:43.883361 [DEBUG] #13 13.33 npm warn deprecated glob@7.2.3: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting <REDACTED>
|
||||
2026-Aug-09 00:23:57.071969 [DEBUG] #13 26.52 npm warn deprecated recharts@2.15.4: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide
|
||||
2026-Aug-09 00:<REDACTED>@1.0.0 postinstall
|
||||
2026-Aug-09 00:24:28.719823 [DEBUG] #13 58.16 > node scripts/patch-next-dev-blocker.cjs
|
||||
2026-Aug-09 00:24:28.719823 [DEBUG] #13 58.16
|
||||
2026-Aug-09 00:24:28.864640 [DEBUG] #13 58.31 node:internal/modules/cjs/loader:1210
|
||||
2026-Aug-09 00:24:28.864640 [DEBUG] #13 58.31 throw err;
|
||||
2026-Aug-09 00:24:28.864640 [DEBUG] #13 58.31 ^
|
||||
2026-Aug-09 00:24:28.864640 [DEBUG] #13 58.31
|
||||
2026-Aug-09 00:24:28.864640 [DEBUG] #13 58.31 Error: Cannot find module '/app/scripts/patch-next-dev-blocker.cjs'
|
||||
2026-Aug-09 00:24:28.864640 [DEBUG] #13 58.31 at Module._resolveFilename (node:internal/modules/cjs/loader:1207:15)
|
||||
2026-Aug-09 00:24:28.864640 [DEBUG] #13 58.31 at Module._load (node:internal/modules/cjs/loader:1038:27)
|
||||
2026-Aug-09 00:24:28.864640 [DEBUG] #13 58.31 at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:164:12)
|
||||
2026-Aug-09 00:24:28.864640 [DEBUG] #13 58.31 at node:internal/main/run_main_module:28:49 {
|
||||
2026-Aug-09 00:24:28.864640 [DEBUG] #13 58.31 code: 'MODULE_NOT_FOUND',
|
||||
2026-Aug-09 00:24:28.864640 [DEBUG] #13 58.31 requireStack: []
|
||||
2026-Aug-09 00:24:28.864640 [DEBUG] #13 58.31 }
|
||||
2026-Aug-09 00:24:28.864640 [DEBUG] #13 58.31
|
||||
2026-Aug-09 00:24:28.864640 [DEBUG] #13 58.31 Node.js v20.20.2
|
||||
2026-Aug-09 00:24:29.049282 [DEBUG] #13 58.34 npm error code 1
|
||||
2026-Aug-09 00:24:29.049282 [DEBUG] #13 58.34 npm error path /app
|
||||
2026-Aug-09 00:24:29.049282 [DEBUG] #13 58.34 npm error command failed
|
||||
2026-Aug-09 00:24:29.049282 [DEBUG] #13 58.34 npm error command sh -c node scripts/patch-next-dev-blocker.cjs
|
||||
2026-Aug-09 00:24:29.049282 [DEBUG] #13 58.34 npm notice
|
||||
2026-Aug-09 00:24:29.049282 [DEBUG] #13 58.34 npm notice New major version of npm available! 10.8.2 -> 12.0.2
|
||||
2026-Aug-09 00:24:29.049282 [DEBUG] #13 58.34 npm notice Changelog: https://github.com/npm/cli/releases/tag/v12.0.2
|
||||
2026-Aug-09 00:<REDACTED>@12.0.2
|
||||
2026-Aug-09 00:24:29.049282 [DEBUG] #13 58.34 npm notice
|
||||
2026-Aug-09 00:24:29.049282 [DEBUG] #13 58.34 npm error A complete log of this run can be found in: /root/.npm/_logs/2026-08-09T00_23_31_502Z-debug-0.log
|
||||
2026-Aug-09 00:24:29.697366 [DEBUG] #13 ERROR: process "/bin/sh -c npm ci --no-audit --no-fund" did not complete successfully: exit code: 1
|
||||
2026-Aug-09 00:24:29.844583 [DEBUG] ------
|
||||
2026-Aug-09 00:24:29.844583 [DEBUG] > [deps 5/5] RUN npm ci --no-audit --no-fund:
|
||||
2026-Aug-09 00:24:29.844583 [DEBUG] 58.34 npm error code 1
|
||||
2026-Aug-09 00:24:29.844583 [DEBUG] 58.34 npm error path /app
|
||||
2026-Aug-09 00:24:29.844583 [DEBUG] 58.34 npm error command failed
|
||||
2026-Aug-09 00:24:29.844583 [DEBUG] 58.34 npm error command sh -c node scripts/patch-next-dev-blocker.cjs
|
||||
2026-Aug-09 00:24:29.844583 [DEBUG] 58.34 npm notice
|
||||
2026-Aug-09 00:24:29.844583 [DEBUG] 58.34 npm notice New major version of npm available! 10.8.2 -> 12.0.2
|
||||
2026-Aug-09 00:24:29.844583 [DEBUG] 58.34 npm notice Changelog: https://github.com/npm/cli/releases/tag/v12.0.2
|
||||
2026-Aug-09 00:<REDACTED>@12.0.2
|
||||
2026-Aug-09 00:24:29.844583 [DEBUG] 58.34 npm notice
|
||||
2026-Aug-09 00:24:29.844583 [DEBUG] 58.34 npm error A complete log of this run can be found in: /root/.npm/_logs/2026-08-09T00_23_31_502Z-debug-0.log
|
||||
2026-Aug-09 00:24:29.844583 [DEBUG] ------
|
||||
2026-Aug-09 00:24:29.869776 [DEBUG] Dockerfile:30
|
||||
2026-Aug-09 00:24:29.869776 [DEBUG] --------------------
|
||||
2026-Aug-09 00:24:29.869776 [DEBUG] 28 | # as dependencies don't change.
|
||||
2026-Aug-09 00:24:29.869776 [DEBUG] 29 | COPY package.json package-lock.json* ./
|
||||
2026-Aug-09 00:24:29.869776 [DEBUG] 30 | >>> RUN npm ci --no-audit --no-fund
|
||||
2026-Aug-09 00:24:29.869776 [DEBUG] 31 |
|
||||
2026-Aug-09 00:24:29.869776 [DEBUG] 32 | # ---------------------------------------------------------------------------
|
||||
2026-Aug-09 00:24:29.869776 [DEBUG] --------------------
|
||||
2026-Aug-09 00:24:29.869776 [DEBUG] ERROR: failed to build: failed to solve: process "/bin/sh -c npm ci --no-audit --no-fund" did not complete successfully: exit code: 1
|
||||
2026-Aug-09 00:24:29.909663 [DEBUG] exit status 1
|
||||
2026-Aug-09 00:24:30.046605 ========================================
|
||||
2026-Aug-09 00:24:30.064835 Deployment failed: Command execution failed (exit code 1): docker exec hyq204tdb0sbkisvotvcoxjk bash -c 'bash /artifacts/build.sh'
|
||||
2026-Aug-09 00:24:30.064835 Error: #0 building with "default" instance using docker driver
|
||||
2026-Aug-09 00:24:30.064835
|
||||
2026-Aug-09 00:24:30.064835 #1 [internal] load build definition from Dockerfile
|
||||
2026-Aug-09 00:24:30.064835 #1 transferring dockerfile: 3.58kB 0.0s done
|
||||
2026-Aug-09 00:24:30.064835 #1 DONE 0.1s
|
||||
2026-Aug-09 00:24:30.064835
|
||||
2026-Aug-09 00:24:30.064835 #2 resolve image config for docker-image://docker.io/docker/dockerfile:1.7
|
||||
2026-Aug-09 00:24:30.064835 #2 DONE 4.0s
|
||||
2026-Aug-09 00:24:30.064835
|
||||
2026-Aug-09 00:24:30.064835 #3 docker-image://docker.io/docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
|
||||
2026-Aug-09 00:24:30.064835 #3 resolve docker.io/docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e 0.0s done
|
||||
2026-Aug-09 00:24:30.064835 #3 sha256:b5f3b260a9678e1d83d2fce86eeddf79420b79147eaba2a25986f47133d73720 482B / 482B done
|
||||
2026-Aug-09 00:24:30.064835 #3 sha256:68ebc061390d9a7d6e194f9d58309c754a53cb8b4e3b0d89593920f42f4a9da4 1.26kB / 1.26kB done
|
||||
2026-Aug-09 00:24:30.064835 #3 sha256:96918c57e42509b97f10c074d80672ecdbd3bb7dcd38c1bd95960cf291207416 0B / 11.98MB 0.1s
|
||||
2026-Aug-09 00:24:30.064835 #3 sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e 8.40kB / 8.40kB done
|
||||
2026-Aug-09 00:24:30.064835 #3 sha256:96918c57e42509b97f10c074d80672ecdbd3bb7dcd38c1bd95960cf291207416 1.05MB / 11.98MB 0.5s
|
||||
2026-Aug-09 00:24:30.064835 #3 sha256:96918c57e42509b97f10c074d80672ecdbd3bb7dcd38c1bd95960cf291207416 2.10MB / 11.98MB 0.6s
|
||||
2026-Aug-09 00:24:30.064835 #3 sha256:96918c57e42509b97f10c074d80672ecdbd3bb7dcd38c1bd95960cf291207416 4.19MB / 11.98MB 0.7s
|
||||
2026-Aug-09 00:24:30.064835 #3 sha256:96918c57e42509b97f10c074d80672ecdbd3bb7dcd38c1bd95960cf291207416 6.29MB / 11.98MB 0.9s
|
||||
2026-Aug-09 00:24:30.064835 #3 sha256:96918c57e42509b97f10c074d80672ecdbd3bb7dcd38c1bd95960cf291207416 7.34MB / 11.98MB 1.0s
|
||||
2026-Aug-09 00:24:30.064835 #3 sha256:96918c57e42509b97f10c074d80672ecdbd3bb7dcd38c1bd95960cf291207416 9.44MB / 11.98MB 1.2s
|
||||
2026-Aug-09 00:24:30.064835 #3 sha256:96918c57e42509b97f10c074d80672ecdbd3bb7dcd38c1bd95960cf291207416 11.98MB / 11.98MB 1.4s
|
||||
2026-Aug-09 00:24:30.064835 #3 extracting sha256:96918c57e42509b97f10c074d80672ecdbd3bb7dcd38c1bd95960cf291207416
|
||||
2026-Aug-09 00:24:30.064835 #3 sha256:96918c57e42509b97f10c074d80672ecdbd3bb7dcd38c1bd95960cf291207416 11.98MB / 11.98MB 1.5s done
|
||||
2026-Aug-09 00:24:30.064835 #3 extracting sha256:96918c57e42509b97f10c074d80672ecdbd3bb7dcd38c1bd95960cf291207416 0.3s done
|
||||
2026-Aug-09 00:24:30.064835 #3 DONE 2.0s
|
||||
2026-Aug-09 00:24:30.064835
|
||||
2026-Aug-09 00:24:30.064835 #4 [internal] load metadata for docker.io/library/node:20-alpine
|
||||
2026-Aug-09 00:24:30.064835 #4 DONE 2.9s
|
||||
2026-Aug-09 00:24:30.064835
|
||||
2026-Aug-09 00:24:30.064835 #5 [internal] load .dockerignore
|
||||
2026-Aug-09 00:24:30.064835 #5 transferring context: 780B done
|
||||
2026-Aug-09 00:24:30.064835 #5 DONE 0.1s
|
||||
2026-Aug-09 00:24:30.064835
|
||||
2026-Aug-09 00:24:30.064835 #6 [runner 1/6] FROM docker.io/library/node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293
|
||||
2026-Aug-09 00:24:30.064835 #6 resolve docker.io/library/node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 0.0s done
|
||||
2026-Aug-09 00:24:30.064835 #6 sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 7.67kB / 7.67kB done
|
||||
2026-Aug-09 00:24:30.064835 #6 sha256:afdf98210b07b586eb71fa22ba2e432e058e4cd1304d31ed60888755b8c865fb 1.72kB / 1.72kB done
|
||||
2026-Aug-09 00:24:30.064835 #6 sha256:11cedc39e663e7c5d5cb9cc77a461a0d2adc25537b94e6831a6108f09cb2001b 6.52kB / 6.52kB done
|
||||
2026-Aug-09 00:24:30.064835 #6 DONE 0.3s
|
||||
2026-Aug-09 00:24:30.064835
|
||||
2026-Aug-09 00:24:30.064835 #7 [internal] load build context
|
||||
2026-Aug-09 00:24:30.064835 #7 transferring context: 683.75kB 0.1s done
|
||||
2026-Aug-09 00:24:30.064835 #7 DONE 0.2s
|
||||
2026-Aug-09 00:24:30.064835
|
||||
2026-Aug-09 00:24:30.064835 #8 [runner 2/6] WORKDIR /app
|
||||
2026-Aug-09 00:24:30.064835 #8 DONE 0.1s
|
||||
2026-Aug-09 00:24:30.064835
|
||||
2026-Aug-09 00:24:30.064835 #9 [deps 2/5] RUN apk add --no-cache libc6-compat
|
||||
2026-Aug-09 00:24:30.064835 #9 ...
|
||||
2026-Aug-09 00:24:30.064835
|
||||
2026-Aug-09 00:24:30.064835 #10 [runner 3/6] RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
||||
2026-Aug-09 00:24:30.064835 #10 DONE 0.5s
|
||||
2026-Aug-09 00:24:30.064835
|
||||
2026-Aug-09 00:24:30.064835 #9 [deps 2/5] RUN apk add --no-cache libc6-compat
|
||||
2026-Aug-09 00:24:30.064835 #9 2.682 (1/3) Installing musl-obstack (1.2.3-r2)
|
||||
2026-Aug-09 00:24:30.064835 #9 2.847 (2/3) Installing libucontext (1.3.3-r0)
|
||||
2026-Aug-09 00:24:30.064835 #9 3.012 (3/3) Installing gcompat (1.1.0-r4)
|
||||
2026-Aug-09 00:24:30.064835 #9 3.182 OK: 11.0 MiB in 21 packages
|
||||
2026-Aug-09 00:24:30.064835 #9 DONE 3.3s
|
||||
2026-Aug-09 00:24:30.064835
|
||||
2026-Aug-09 00:24:30.064835 #11 [deps 3/5] WORKDIR /app
|
||||
2026-Aug-09 00:24:30.064835 #11 DONE 0.1s
|
||||
2026-Aug-09 00:24:30.064835
|
||||
2026-Aug-09 00:24:30.064835 #12 [deps 4/5] COPY package.json package-lock.json* ./
|
||||
2026-Aug-09 00:24:30.064835 #12 DONE 0.2s
|
||||
2026-Aug-09 00:24:30.064835
|
||||
2026-Aug-09 00:24:30.064835 #13 [deps 5/5] RUN npm ci --no-audit --no-fund
|
||||
2026-Aug-09 00:24:30.064835 #13 11.21 npm warn deprecated sourcemap-codec@1.4.8: Please use @jridgewell/sourcemap-codec instead
|
||||
2026-Aug-09 00:24:30.064835 #13 13.23 npm warn deprecated inflight@1.0.6: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
|
||||
2026-Aug-09 00:24:30.064835 #13 13.33 npm warn deprecated glob@7.2.3: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting <REDACTED>
|
||||
2026-Aug-09 00:24:30.064835 #13 26.52 npm warn deprecated recharts@2.15.4: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide
|
||||
2026-Aug-09 00:<REDACTED>@1.0.0 postinstall
|
||||
2026-Aug-09 00:24:30.064835 #13 58.16 > node scripts/patch-next-dev-blocker.cjs
|
||||
2026-Aug-09 00:24:30.064835 #13 58.16
|
||||
2026-Aug-09 00:24:30.064835 #13 58.31 node:<REDACTED>@12.0.2
|
||||
2026-Aug-09 00:24:30.064835 #13 58.34 npm notice
|
||||
2026-Aug-09 00:24:30.064835 #13 58.34 npm error A complete log of this run can be found in: /root/.npm/_logs/2026-08-09T00_23_31_502Z-debug-0.log
|
||||
2026-Aug-09 00:24:30.064835 #13 ERROR: process "/bin/sh -c npm ci --no-audit --no-fund" did not complete successfully: exit code: 1
|
||||
2026-Aug-09 00:24:30.064835 ------
|
||||
2026-Aug-09 00:24:30.064835 > [deps 5/5] RUN npm ci --no-audit --no-fund:
|
||||
2026-Aug-09 00:24:30.064835 58.34 npm error code 1
|
||||
2026-Aug-09 00:24:30.064835 58.34 npm error path /app
|
||||
2026-Aug-09 00:24:30.064835 58.34 npm error command failed
|
||||
2026-Aug-09 00:24:30.064835 58.34 npm error command sh -c node scripts/patch-next-dev-blocker.cjs
|
||||
2026-Aug-09 00:24:30.064835 58.34 npm notice
|
||||
2026-Aug-09 00:24:30.064835 58.34 npm notice New major version of npm available! 10.8.2 -> 12.0.2
|
||||
2026-Aug-09 00:24:30.064835 58.34 npm notice Changelog: https://github.com/npm/cli/releases/tag/v12.0.2
|
||||
2026-Aug-09 00:<REDACTED>@12.0.2
|
||||
2026-Aug-09 00:24:30.064835 58.34 npm notice
|
||||
2026-Aug-09 00:24:30.064835 58.34 npm error A complete log of this run can be found in: /root/.npm/_logs/2026-08-09T00_23_31_502Z-debug-0.log
|
||||
2026-Aug-09 00:24:30.064835 ------
|
||||
2026-Aug-09 00:24:30.064835 Dockerfile:30
|
||||
2026-Aug-09 00:24:30.064835 --------------------
|
||||
2026-Aug-09 00:24:30.064835 28 | # as dependencies don't change.
|
||||
2026-Aug-09 00:24:30.064835 29 | COPY package.json package-lock.json* ./
|
||||
2026-Aug-09 00:24:30.064835 30 | >>> RUN npm ci --no-audit --no-fund
|
||||
2026-Aug-09 00:24:30.064835 31 |
|
||||
2026-Aug-09 00:24:30.064835 32 | # ---------------------------------------------------------------------------
|
||||
2026-Aug-09 00:24:30.064835 --------------------
|
||||
2026-Aug-09 00:24:30.064835 ERROR: failed to build: failed to solve: process "/bin/sh -c npm ci --no-audit --no-fund" did not complete successfully: exit code: 1
|
||||
2026-Aug-09 00:24:30.064835 exit status 1
|
||||
2026-Aug-09 00:24:30.088572 [DEBUG] Error type: App\Exceptions\DeploymentException
|
||||
2026-Aug-09 00:24:30.108446 [DEBUG] Error code: 0
|
||||
2026-Aug-09 00:24:30.131136 [DEBUG] Location: /var/www/html/app/Traits/ExecuteRemoteCommand.php:242
|
||||
2026-Aug-09 00:24:30.152173 [DEBUG] Stack trace (first 5 lines):
|
||||
2026-Aug-09 00:24:30.176971 [DEBUG] #0 /var/www/html/app/Traits/ExecuteRemoteCommand.php(106): App\Jobs\ApplicationDeploymentJob->executeCommandWithProcess()
|
||||
2026-Aug-09 00:24:30.201045 [DEBUG] #1 /var/www/html/vendor/laravel/framework/src/Illuminate/Collections/Traits/EnumeratesValues.php(275): App\Jobs\ApplicationDeploymentJob->{closure:App\Traits\ExecuteRemoteCommand::execute_remote_command():72}()
|
||||
2026-Aug-09 00:24:30.226193 [DEBUG] #2 /var/www/html/app/Traits/ExecuteRemoteCommand.php(72): Illuminate\Support\Collection->each()
|
||||
2026-Aug-09 00:24:30.245142 [DEBUG] #3 /var/www/html/app/Jobs/ApplicationDeploymentJob.php(3821): App\Jobs\ApplicationDeploymentJob->execute_remote_command()
|
||||
2026-Aug-09 00:24:30.265233 [DEBUG] #4 /var/www/html/app/Jobs/ApplicationDeploymentJob.php(912): App\Jobs\ApplicationDeploymentJob->build_image()
|
||||
2026-Aug-09 00:24:30.284294 ========================================
|
||||
2026-Aug-09 00:24:30.308183 Deployment failed. Removing the new version of your application.
|
||||
2026-Aug-09 00:24:31.172542 Gracefully shutting down build container: hyq204tdb0sbkisvotvcoxjk
|
||||
2026-Aug-09 00:24:31.888162 [DEBUG] [CMD]: docker stop --time=30 hyq204tdb0sbkisvotvcoxjk
|
||||
2026-Aug-09 00:24:31.888162 [DEBUG] hyq204tdb0sbkisvotvcoxjk
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
2026-Aug-09 18:30:56.374118 Docker 27.0.3 with BuildKit and Buildx detected on deployment server (localhost).
|
||||
2026-Aug-09 18:30:56.396461 Starting deployment of http://forgejo-b82b4lpyyzyg40thdsjdd38i.163.245.214.68.sslip.io/David/MEGAINFRA-WEBAPP.git:<REDACTED>@\(.*\):#https://\1/#g" '\''/artifacts/ddwhqdpyhz5ypb0ahlsx06z8'\''/.gitmodules || true && git -c http.version=HTTP/1.1 submodule sync && GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git -c http.version=HTTP/1.1 submodule update --init --recursive --depth=1; fi && cd '\''/artifacts/ddwhqdpyhz5ypb0ahlsx06z8'\'' && GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git -c http.version=HTTP/1.1 lfs pull'
|
||||
2026-Aug-09 18:<REDACTED>@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
|
||||
2026-Aug-09 18:31:24.636144 [DEBUG] #3 CACHED
|
||||
2026-Aug-09 18:31:24.925384 [DEBUG] #4 [internal] load metadata for docker.io/library/node:20-alpine
|
||||
2026-Aug-09 18:31:26.396240 [DEBUG] #4 DONE 1.3s
|
||||
2026-Aug-09 18:31:26.396240 [DEBUG]
|
||||
2026-Aug-09 18:31:26.396240 [DEBUG] #5 [internal] load .dockerignore
|
||||
2026-Aug-09 18:31:26.396240 [DEBUG] #5 transferring context:
|
||||
2026-Aug-09 18:31:26.607741 [DEBUG] #5 transferring context: 764B 0.0s done
|
||||
2026-Aug-09 18:31:26.607741 [DEBUG] #5 DONE 0.2s
|
||||
2026-Aug-09 18:31:26.650259 [DEBUG] #6 [runner 2/6] WORKDIR /app
|
||||
2026-Aug-09 18:31:26.650259 [DEBUG] #6 CACHED
|
||||
2026-Aug-09 18:31:26.650259 [DEBUG]
|
||||
2026-Aug-09 18:31:26.650259 [DEBUG] #7 [runner 1/6] FROM docker.io/library/node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293
|
||||
2026-Aug-09 18:31:26.650259 [DEBUG] #7 CACHED
|
||||
2026-Aug-09 18:31:26.758801 [DEBUG] #8 [internal] load build context
|
||||
2026-Aug-09 18:31:27.027641 [DEBUG] #8 transferring context: 969.58kB 0.2s done
|
||||
2026-Aug-09 18:31:27.226125 [DEBUG] #8 DONE 0.4s
|
||||
2026-Aug-09 18:31:27.226125 [DEBUG]
|
||||
2026-Aug-09 18:31:27.226125 [DEBUG] #9 [runner 3/6] RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
||||
2026-Aug-09 18:31:27.336687 [DEBUG] #9 DONE 0.6s
|
||||
2026-Aug-09 18:31:27.336687 [DEBUG]
|
||||
2026-Aug-09 18:31:27.336687 [DEBUG] #10 [deps 2/5] RUN apk add --no-cache libc6-compat
|
||||
2026-Aug-09 18:31:33.800673 [DEBUG] #10 7.070 (1/3) Installing musl-obstack (1.2.3-r2)
|
||||
2026-Aug-09 18:31:33.919334 [DEBUG] #10 7.238 (2/3) Installing libucontext (1.3.3-r0)
|
||||
2026-Aug-09 18:31:34.092642 [DEBUG] #10 7.408 (3/3) Installing gcompat (1.1.0-r4)
|
||||
2026-Aug-09 18:31:34.300133 [DEBUG] #10 7.618 OK: 11.0 MiB in 21 packages
|
||||
2026-Aug-09 18:31:34.466745 [DEBUG] #10 DONE 7.8s
|
||||
2026-Aug-09 18:31:34.605571 [DEBUG] #11 [deps 3/5] WORKDIR /app
|
||||
2026-Aug-09 18:31:34.605571 [DEBUG] #11 DONE 0.1s
|
||||
2026-Aug-09 18:31:34.773809 [DEBUG] #12 [deps 4/5] COPY package.json package-lock.json* ./
|
||||
2026-Aug-09 18:31:34.809310 [DEBUG] #12 DONE 0.2s
|
||||
2026-Aug-09 18:31:34.977430 [DEBUG] #13 [deps 5/5] RUN npm ci --ignore-scripts --no-audit --no-fund
|
||||
2026-Aug-09 18:32:15.587184 [DEBUG] #13 40.76 npm warn deprecated sourcemap-codec@1.4.8: Please use @jridgewell/sourcemap-codec instead
|
||||
2026-Aug-09 18:32:26.239527 [DEBUG] #13 51.42 npm warn deprecated inflight@1.0.6: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
|
||||
2026-Aug-09 18:32:26.358184 [DEBUG] #13 51.54 npm warn deprecated glob@7.2.3: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting <REDACTED>
|
||||
2026-Aug-09 18:32:37.716653 ========================================
|
||||
2026-Aug-09 18:32:37.763836 Deployment failed: Command execution failed (exit code 255): docker exec ddwhqdpyhz5ypb0ahlsx06z8 bash -c 'bash /artifacts/build.sh'
|
||||
2026-Aug-09 18:32:37.763836 Error: #0 building with "default" instance using docker driver
|
||||
2026-Aug-09 18:32:37.763836
|
||||
2026-Aug-09 18:32:37.763836 #1 [internal] load build definition from Dockerfile
|
||||
2026-Aug-09 18:32:37.763836 #1 transferring dockerfile:
|
||||
2026-Aug-09 18:32:37.763836 #1 transferring dockerfile: 4.02kB 0.1s done
|
||||
2026-Aug-09 18:32:37.763836 #1 DONE 0.3s
|
||||
2026-Aug-09 18:32:37.763836
|
||||
2026-Aug-09 18:32:37.763836 #2 resolve image config for docker-image://docker.io/docker/dockerfile:1.7
|
||||
2026-Aug-09 18:32:37.763836 #2 DONE 1.9s
|
||||
2026-Aug-09 18:32:37.763836
|
||||
2026-Aug-09 18:32:37.763836 #3 docker-image://docker.io/docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
|
||||
2026-Aug-09 18:32:37.763836 #3 CACHED
|
||||
2026-Aug-09 18:32:37.763836
|
||||
2026-Aug-09 18:32:37.763836 #4 [internal] load metadata for docker.io/library/node:20-alpine
|
||||
2026-Aug-09 18:32:37.763836 #4 DONE 1.3s
|
||||
2026-Aug-09 18:32:37.763836
|
||||
2026-Aug-09 18:32:37.763836 #5 [internal] load .dockerignore
|
||||
2026-Aug-09 18:32:37.763836 #5 transferring context:
|
||||
2026-Aug-09 18:32:37.763836 #5 transferring context: 764B 0.0s done
|
||||
2026-Aug-09 18:32:37.763836 #5 DONE 0.2s
|
||||
2026-Aug-09 18:32:37.763836
|
||||
2026-Aug-09 18:32:37.763836 #6 [runner 2/6] WORKDIR /app
|
||||
2026-Aug-09 18:32:37.763836 #6 CACHED
|
||||
2026-Aug-09 18:32:37.763836
|
||||
2026-Aug-09 18:32:37.763836 #7 [runner 1/6] FROM docker.io/library/node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293
|
||||
2026-Aug-09 18:32:37.763836 #7 CACHED
|
||||
2026-Aug-09 18:32:37.763836
|
||||
2026-Aug-09 18:32:37.763836 #8 [internal] load build context
|
||||
2026-Aug-09 18:32:37.763836 #8 transferring context: 969.58kB 0.2s done
|
||||
2026-Aug-09 18:32:37.763836 #8 DONE 0.4s
|
||||
2026-Aug-09 18:32:37.763836
|
||||
2026-Aug-09 18:32:37.763836 #9 [runner 3/6] RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
||||
2026-Aug-09 18:32:37.763836 #9 DONE 0.6s
|
||||
2026-Aug-09 18:32:37.763836
|
||||
2026-Aug-09 18:32:37.763836 #10 [deps 2/5] RUN apk add --no-cache libc6-compat
|
||||
2026-Aug-09 18:32:37.763836 #10 7.070 (1/3) Installing musl-obstack (1.2.3-r2)
|
||||
2026-Aug-09 18:32:37.763836 #10 7.238 (2/3) Installing libucontext (1.3.3-r0)
|
||||
2026-Aug-09 18:32:37.763836 #10 7.408 (3/3) Installing gcompat (1.1.0-r4)
|
||||
2026-Aug-09 18:32:37.763836 #10 7.618 OK: 11.0 MiB in 21 packages
|
||||
2026-Aug-09 18:32:37.763836 #10 DONE 7.8s
|
||||
2026-Aug-09 18:32:37.763836
|
||||
2026-Aug-09 18:32:37.763836 #11 [deps 3/5] WORKDIR /app
|
||||
2026-Aug-09 18:32:37.763836 #11 DONE 0.1s
|
||||
2026-Aug-09 18:32:37.763836
|
||||
2026-Aug-09 18:32:37.763836 #12 [deps 4/5] COPY package.json package-lock.json* ./
|
||||
2026-Aug-09 18:32:37.763836 #12 DONE 0.2s
|
||||
2026-Aug-09 18:32:37.763836
|
||||
2026-Aug-09 18:32:37.763836 #13 [deps 5/5] RUN npm ci --ignore-scripts --no-audit --no-fund
|
||||
2026-Aug-09 18:32:37.763836 #13 40.76 npm warn deprecated sourcemap-codec@1.4.8: Please use @jridgewell/sourcemap-codec instead
|
||||
2026-Aug-09 18:32:37.763836 #13 51.42 npm warn deprecated inflight@1.0.6: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
|
||||
2026-Aug-09 18:32:37.763836 #13 51.54 npm warn deprecated glob@7.2.3: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting <REDACTED>
|
||||
2026-Aug-09 18:32:37.799350 [DEBUG] Error type: App\Exceptions\DeploymentException
|
||||
2026-Aug-09 18:32:37.846153 [DEBUG] Error code: 0
|
||||
2026-Aug-09 18:32:37.909961 [DEBUG] Location: /var/www/html/app/Traits/ExecuteRemoteCommand.php:242
|
||||
2026-Aug-09 18:32:37.934826 [DEBUG] Stack trace (first 5 lines):
|
||||
2026-Aug-09 18:32:37.978692 [DEBUG] #0 /var/www/html/app/Traits/ExecuteRemoteCommand.php(106): App\Jobs\ApplicationDeploymentJob->executeCommandWithProcess()
|
||||
2026-Aug-09 18:32:38.016770 [DEBUG] #1 /var/www/html/vendor/laravel/framework/src/Illuminate/Collections/Traits/EnumeratesValues.php(275): App\Jobs\ApplicationDeploymentJob->{closure:App\Traits\ExecuteRemoteCommand::execute_remote_command():72}()
|
||||
2026-Aug-09 18:32:38.055046 [DEBUG] #2 /var/www/html/app/Traits/ExecuteRemoteCommand.php(72): Illuminate\Support\Collection->each()
|
||||
2026-Aug-09 18:32:38.091398 [DEBUG] #3 /var/www/html/app/Jobs/ApplicationDeploymentJob.php(3821): App\Jobs\ApplicationDeploymentJob->execute_remote_command()
|
||||
2026-Aug-09 18:32:38.132071 [DEBUG] #4 /var/www/html/app/Jobs/ApplicationDeploymentJob.php(912): App\Jobs\ApplicationDeploymentJob->build_image()
|
||||
2026-Aug-09 18:32:38.157232 ========================================
|
||||
2026-Aug-09 18:32:38.187298 Deployment failed. Removing the new version of your application.
|
||||
2026-Aug-09 18:32:39.374733 Gracefully shutting down build container: ddwhqdpyhz5ypb0ahlsx06z8
|
||||
2026-Aug-09 18:32:41.667553 [DEBUG] [CMD]: docker stop --time=30 ddwhqdpyhz5ypb0ahlsx06z8
|
||||
2026-Aug-09 18:32:41.667553 [DEBUG] ddwhqdpyhz5ypb0ahlsx06z8
|
||||
Binary file not shown.
|
|
@ -0,0 +1,226 @@
|
|||
2026-Aug-09 18:25:11.686798 Docker 27.0.3 with BuildKit and Buildx detected on deployment server (localhost).
|
||||
2026-Aug-09 18:25:11.705078 Starting deployment of http://forgejo-b82b4lpyyzyg40thdsjdd38i.163.245.214.68.sslip.io/David/MEGAINFRA-WEBAPP.git:<REDACTED>@\(.*\):#https://\1/#g" '\''/artifacts/qkvx1xuygl2okqnv67v1020c'\''/.gitmodules || true && git -c http.version=HTTP/1.1 submodule sync && GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git -c http.version=HTTP/1.1 submodule update --init --recursive --depth=1; fi && cd '\''/artifacts/qkvx1xuygl2okqnv67v1020c'\'' && GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git -c http.version=HTTP/1.1 lfs pull'
|
||||
2026-Aug-09 18:<REDACTED>@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
|
||||
2026-Aug-09 18:25:26.173454 [DEBUG] #3 CACHED
|
||||
2026-Aug-09 18:25:26.314695 [DEBUG] #4 [internal] load metadata for docker.io/library/node:20-alpine
|
||||
2026-Aug-09 18:25:27.915533 [DEBUG] #4 DONE 1.6s
|
||||
2026-Aug-09 18:25:28.078519 [DEBUG] #5 [internal] load .dockerignore
|
||||
2026-Aug-09 18:25:28.078519 [DEBUG] #5 transferring context: 780B done
|
||||
2026-Aug-09 18:25:28.078519 [DEBUG] #5 DONE 0.0s
|
||||
2026-Aug-09 18:25:28.220347 [DEBUG] #6 [runner 2/6] WORKDIR /app
|
||||
2026-Aug-09 18:25:28.220347 [DEBUG] #6 CACHED
|
||||
2026-Aug-09 18:25:28.220347 [DEBUG]
|
||||
2026-Aug-09 18:25:28.220347 [DEBUG] #7 [runner 1/6] FROM docker.io/library/node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293
|
||||
2026-Aug-09 18:25:28.220347 [DEBUG] #7 CACHED
|
||||
2026-Aug-09 18:25:28.220347 [DEBUG]
|
||||
2026-Aug-09 18:25:28.220347 [DEBUG] #8 [internal] load build context
|
||||
2026-Aug-09 18:25:28.390813 [DEBUG] #8 transferring context: 961.23kB 0.1s done
|
||||
2026-Aug-09 18:25:28.390813 [DEBUG] #8 DONE 0.2s
|
||||
2026-Aug-09 18:25:28.390813 [DEBUG]
|
||||
2026-Aug-09 18:25:28.390813 [DEBUG] #9 [runner 3/6] RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
||||
2026-Aug-09 18:25:28.390813 [DEBUG] #9 DONE 0.3s
|
||||
2026-Aug-09 18:25:28.390813 [DEBUG]
|
||||
2026-Aug-09 18:25:28.390813 [DEBUG] #10 [deps 2/5] RUN apk add --no-cache libc6-compat
|
||||
2026-Aug-09 18:25:48.625054 [DEBUG] #10 20.54 (1/3) Installing musl-obstack (1.2.3-r2)
|
||||
2026-Aug-09 18:25:48.814112 [DEBUG] #10 20.73 (2/3) Installing libucontext (1.3.3-r0)
|
||||
2026-Aug-09 18:25:48.997554 [DEBUG] #10 20.92 (3/3) Installing gcompat (1.1.0-r4)
|
||||
2026-Aug-09 18:25:49.428313 [DEBUG] #10 21.33 OK: 11.0 MiB in 21 packages
|
||||
2026-Aug-09 18:25:49.553636 [DEBUG] #10 DONE 21.5s
|
||||
2026-Aug-09 18:25:49.703504 [DEBUG]
|
||||
2026-Aug-09 18:25:49.710468 [DEBUG] #11 [deps 3/5] WORKDIR /app
|
||||
2026-Aug-09 18:25:49.710468 [DEBUG] #11 DONE 0.1s
|
||||
2026-Aug-09 18:25:49.710468 [DEBUG]
|
||||
2026-Aug-09 18:25:49.710468 [DEBUG] #12 [deps 4/5] COPY package.json package-lock.json* ./
|
||||
2026-Aug-09 18:25:49.710468 [DEBUG] #12 DONE 0.1s
|
||||
2026-Aug-09 18:25:49.857306 [DEBUG] #13 [deps 5/5] RUN npm ci --ignore-scripts --no-audit --no-fund
|
||||
2026-Aug-09 18:25:59.507619 [DEBUG] #13 9.800 npm warn deprecated sourcemap-codec@1.4.8: Please use @jridgewell/sourcemap-codec instead
|
||||
2026-Aug-09 18:26:03.447281 [DEBUG] #13 13.73 npm warn deprecated inflight@1.0.6: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
|
||||
2026-Aug-09 18:26:03.691330 [DEBUG] #13 13.97 npm warn deprecated glob@7.2.3: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting <REDACTED>
|
||||
2026-Aug-09 18:26:22.858528 [DEBUG] #13 33.15 npm warn deprecated recharts@2.15.4: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide
|
||||
2026-Aug-09 18:<REDACTED>@12.0.2
|
||||
2026-Aug-09 18:26:48.093519 [DEBUG] #13 58.23 npm notice
|
||||
2026-Aug-09 18:26:49.296198 [DEBUG] #13 DONE 59.6s
|
||||
2026-Aug-09 18:27:36.190888 [DEBUG] #14 [builder 3/5] COPY --from=deps /app/node_modules ./node_modules
|
||||
2026-Aug-09 18:28:20.456063 [DEBUG] #14 DONE 44.3s
|
||||
2026-Aug-09 18:28:20.610735 [DEBUG] #15 [builder 4/5] COPY . .
|
||||
2026-Aug-09 18:28:20.660383 [DEBUG] #15 DONE 0.2s
|
||||
2026-Aug-09 18:28:20.816605 [DEBUG] #16 [builder 5/5] RUN npm run build
|
||||
2026-Aug-09 18:28:22.946942 [DEBUG] #16 2.283
|
||||
2026-Aug-09 18:28:22.946942 [DEBUG] #16 2.283 > mega-infrastructure-dashboard@1.0.0 build
|
||||
2026-Aug-09 18:28:22.946942 [DEBUG] #16 2.283 > npm run og-image && next build --webpack
|
||||
2026-Aug-09 18:28:22.946942 [DEBUG] #16 2.283
|
||||
2026-Aug-09 18:28:23.473138 [DEBUG] #16 2.807
|
||||
2026-Aug-09 18:28:23.473138 [DEBUG] #16 2.807 > mega-infrastructure-dashboard@1.0.0 og-image
|
||||
2026-Aug-09 18:28:23.473138 [DEBUG] #16 2.807 > node scripts/build-og-image.mjs
|
||||
2026-Aug-09 18:28:23.473138 [DEBUG] #16 2.807
|
||||
2026-Aug-09 18:28:23.676595 [DEBUG] #16 3.009 node:internal/modules/cjs/loader:1210
|
||||
2026-Aug-09 18:28:23.676595 [DEBUG] #16 3.009 throw err;
|
||||
2026-Aug-09 18:28:23.676595 [DEBUG] #16 3.009 ^
|
||||
2026-Aug-09 18:28:23.676595 [DEBUG] #16 3.009
|
||||
2026-Aug-09 18:28:23.676595 [DEBUG] #16 3.009 Error: Cannot find module '/app/scripts/build-og-image.mjs'
|
||||
2026-Aug-09 18:28:23.676595 [DEBUG] #16 3.009 at Module._resolveFilename (node:internal/modules/cjs/loader:1207:15)
|
||||
2026-Aug-09 18:28:23.676595 [DEBUG] #16 3.009 at Module._load (node:internal/modules/cjs/loader:1038:27)
|
||||
2026-Aug-09 18:28:23.676595 [DEBUG] #16 3.009 at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:164:12)
|
||||
2026-Aug-09 18:28:23.676595 [DEBUG] #16 3.009 at node:internal/main/run_main_module:28:49 {
|
||||
2026-Aug-09 18:28:23.676595 [DEBUG] #16 3.009 code: 'MODULE_NOT_FOUND',
|
||||
2026-Aug-09 18:28:23.676595 [DEBUG] #16 3.009 requireStack: []
|
||||
2026-Aug-09 18:28:23.676595 [DEBUG] #16 3.009 }
|
||||
2026-Aug-09 18:28:23.860081 [DEBUG] #16 3.017
|
||||
2026-Aug-09 18:28:23.860081 [DEBUG] #16 3.017 Node.js v20.20.2
|
||||
2026-Aug-09 18:28:23.860081 [DEBUG] #16 3.067 npm notice
|
||||
2026-Aug-09 18:28:23.860081 [DEBUG] #16 3.067 npm notice New major version of npm available! 10.8.2 -> 12.0.2
|
||||
2026-Aug-09 18:28:23.860081 [DEBUG] #16 3.067 npm notice Changelog: https://github.com/npm/cli/releases/tag/v12.0.2
|
||||
2026-Aug-09 18:<REDACTED>@12.0.2
|
||||
2026-Aug-09 18:28:23.860081 [DEBUG] #16 3.067 npm notice
|
||||
2026-Aug-09 18:28:23.860081 [DEBUG] #16 ERROR: process "/bin/sh -c npm run build" did not complete successfully: exit code: 1
|
||||
2026-Aug-09 18:28:24.006330 [DEBUG] ------
|
||||
2026-Aug-09 18:28:24.006330 [DEBUG] > [builder 5/5] RUN npm run build:
|
||||
2026-Aug-09 18:28:24.006330 [DEBUG] 3.009 code: 'MODULE_NOT_FOUND',
|
||||
2026-Aug-09 18:28:24.006330 [DEBUG] 3.009 requireStack: []
|
||||
2026-Aug-09 18:28:24.006330 [DEBUG] 3.009 }
|
||||
2026-Aug-09 18:28:24.006330 [DEBUG] 3.017
|
||||
2026-Aug-09 18:28:24.006330 [DEBUG] 3.017 Node.js v20.20.2
|
||||
2026-Aug-09 18:28:24.006330 [DEBUG] 3.067 npm notice
|
||||
2026-Aug-09 18:28:24.006330 [DEBUG] 3.067 npm notice New major version of npm available! 10.8.2 -> 12.0.2
|
||||
2026-Aug-09 18:28:24.006330 [DEBUG] 3.067 npm notice Changelog: https://github.com/npm/cli/releases/tag/v12.0.2
|
||||
2026-Aug-09 18:<REDACTED>@12.0.2
|
||||
2026-Aug-09 18:28:24.006330 [DEBUG] 3.067 npm notice
|
||||
2026-Aug-09 18:28:24.006330 [DEBUG] ------
|
||||
2026-Aug-09 18:28:24.026217 [DEBUG] Dockerfile:62
|
||||
2026-Aug-09 18:28:24.026217 [DEBUG] --------------------
|
||||
2026-Aug-09 18:28:24.026217 [DEBUG] 60 | # already contains the trimmed `node_modules` needed at runtime plus a
|
||||
2026-Aug-09 18:28:24.026217 [DEBUG] 61 | # ready-to-run `server.js`.
|
||||
2026-Aug-09 18:28:24.026217 [DEBUG] 62 | >>> RUN npm run build
|
||||
2026-Aug-09 18:28:24.026217 [DEBUG] 63 |
|
||||
2026-Aug-09 18:28:24.026217 [DEBUG] 64 | # ---------------------------------------------------------------------------
|
||||
2026-Aug-09 18:28:24.026217 [DEBUG] --------------------
|
||||
2026-Aug-09 18:28:24.026217 [DEBUG] ERROR: failed to build: failed to solve: process "/bin/sh -c npm run build" did not complete successfully: exit code: 1
|
||||
2026-Aug-09 18:28:24.051956 [DEBUG] exit status 1
|
||||
2026-Aug-09 18:28:24.231858 ========================================
|
||||
2026-Aug-09 18:28:24.249189 Deployment failed: Command execution failed (exit code 1): docker exec qkvx1xuygl2okqnv67v1020c bash -c 'bash /artifacts/build.sh'
|
||||
2026-Aug-09 18:28:24.249189 Error: #0 building with "default" instance using docker driver
|
||||
2026-Aug-09 18:28:24.249189
|
||||
2026-Aug-09 18:28:24.249189 #1 [internal] load build definition from Dockerfile
|
||||
2026-Aug-09 18:28:24.249189 #1 transferring dockerfile: 4.02kB done
|
||||
2026-Aug-09 18:28:24.249189 #1 DONE 0.1s
|
||||
2026-Aug-09 18:28:24.249189
|
||||
2026-Aug-09 18:28:24.249189 #2 resolve image config for docker-image://docker.io/docker/dockerfile:1.7
|
||||
2026-Aug-09 18:28:24.249189 #2 DONE 1.8s
|
||||
2026-Aug-09 18:28:24.249189
|
||||
2026-Aug-09 18:28:24.249189 #3 docker-image://docker.io/docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
|
||||
2026-Aug-09 18:28:24.249189 #3 CACHED
|
||||
2026-Aug-09 18:28:24.249189
|
||||
2026-Aug-09 18:28:24.249189 #4 [internal] load metadata for docker.io/library/node:20-alpine
|
||||
2026-Aug-09 18:28:24.249189 #4 DONE 1.6s
|
||||
2026-Aug-09 18:28:24.249189
|
||||
2026-Aug-09 18:28:24.249189 #5 [internal] load .dockerignore
|
||||
2026-Aug-09 18:28:24.249189 #5 transferring context: 780B done
|
||||
2026-Aug-09 18:28:24.249189 #5 DONE 0.0s
|
||||
2026-Aug-09 18:28:24.249189
|
||||
2026-Aug-09 18:28:24.249189 #6 [runner 2/6] WORKDIR /app
|
||||
2026-Aug-09 18:28:24.249189 #6 CACHED
|
||||
2026-Aug-09 18:28:24.249189
|
||||
2026-Aug-09 18:28:24.249189 #7 [runner 1/6] FROM docker.io/library/node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293
|
||||
2026-Aug-09 18:28:24.249189 #7 CACHED
|
||||
2026-Aug-09 18:28:24.249189
|
||||
2026-Aug-09 18:28:24.249189 #8 [internal] load build context
|
||||
2026-Aug-09 18:28:24.249189 #8 transferring context: 961.23kB 0.1s done
|
||||
2026-Aug-09 18:28:24.249189 #8 DONE 0.2s
|
||||
2026-Aug-09 18:28:24.249189
|
||||
2026-Aug-09 18:28:24.249189 #9 [runner 3/6] RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
||||
2026-Aug-09 18:28:24.249189 #9 DONE 0.3s
|
||||
2026-Aug-09 18:28:24.249189
|
||||
2026-Aug-09 18:28:24.249189 #10 [deps 2/5] RUN apk add --no-cache libc6-compat
|
||||
2026-Aug-09 18:28:24.249189 #10 20.54 (1/3) Installing musl-obstack (1.2.3-r2)
|
||||
2026-Aug-09 18:28:24.249189 #10 20.73 (2/3) Installing libucontext (1.3.3-r0)
|
||||
2026-Aug-09 18:28:24.249189 #10 20.92 (3/3) Installing gcompat (1.1.0-r4)
|
||||
2026-Aug-09 18:28:24.249189 #10 21.33 OK: 11.0 MiB in 21 packages
|
||||
2026-Aug-09 18:28:24.249189 #10 DONE 21.5s
|
||||
2026-Aug-09 18:28:24.249189
|
||||
2026-Aug-09 18:28:24.249189 #11 [deps 3/5] WORKDIR /app
|
||||
2026-Aug-09 18:28:24.249189 #11 DONE 0.1s
|
||||
2026-Aug-09 18:28:24.249189
|
||||
2026-Aug-09 18:28:24.249189 #12 [deps 4/5] COPY package.json package-lock.json* ./
|
||||
2026-Aug-09 18:28:24.249189 #12 DONE 0.1s
|
||||
2026-Aug-09 18:28:24.249189
|
||||
2026-Aug-09 18:28:24.249189 #13 [deps 5/5] RUN npm ci --ignore-scripts --no-audit --no-fund
|
||||
2026-Aug-09 18:28:24.249189 #13 9.800 npm warn deprecated sourcemap-codec@1.4.8: Please use @jridgewell/sourcemap-codec instead
|
||||
2026-Aug-09 18:28:24.249189 #13 13.73 npm warn deprecated inflight@1.0.6: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
|
||||
2026-Aug-09 18:28:24.249189 #13 13.97 npm warn deprecated glob@7.2.3: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting <REDACTED>
|
||||
2026-Aug-09 18:28:24.249189 #13 33.15 npm warn deprecated recharts@2.15.4: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide
|
||||
2026-Aug-09 18:<REDACTED>@12.0.2
|
||||
2026-Aug-09 18:28:24.249189 #13 58.23 npm notice
|
||||
2026-Aug-09 18:28:24.249189 #13 DONE 59.6s
|
||||
2026-Aug-09 18:28:24.249189
|
||||
2026-Aug-09 18:28:24.249189 #14 [builder 3/5] COPY --from=deps /app/node_modules ./node_modules
|
||||
2026-Aug-09 18:28:24.249189 #14 DONE 44.3s
|
||||
2026-Aug-09 18:28:24.249189
|
||||
2026-Aug-09 18:28:24.249189 #15 [builder 4/5] COPY . .
|
||||
2026-Aug-09 18:28:24.249189 #15 DONE 0.2s
|
||||
2026-Aug-09 18:28:24.249189
|
||||
2026-Aug-09 18:28:24.249189 #16 [builder 5/5] RUN npm run build
|
||||
2026-Aug-09 18:28:24.249189 #16 2.283
|
||||
2026-Aug-09 18:28:24.249189 #16 2.283 > mega-infrastructure-dashboard@1.0.0 build
|
||||
2026-Aug-09 18:28:24.249189 #16 2.283 > npm run og-image && next build --webpack
|
||||
2026-Aug-09 18:28:24.249189 #16 2.283
|
||||
2026-Aug-09 18:28:24.249189 #16 2.807
|
||||
2026-Aug-09 18:28:24.249189 #16 2.807 > mega-infrastructure-dashboard@1.0.0 og-image
|
||||
2026-Aug-09 18:28:24.249189 #16 2.807 > node scripts/build-og-image.mjs
|
||||
2026-Aug-09 18:28:24.249189 #16 2.807
|
||||
2026-Aug-09 18:28:24.249189 #16 3.009 node:internal/modules/cjs/loader:1210
|
||||
2026-Aug-09 18:28:24.249189 #16 3.009 throw err;
|
||||
2026-Aug-09 18:28:24.249189 #16 3.009 ^
|
||||
2026-Aug-09 18:28:24.249189 #16 3.009
|
||||
2026-Aug-09 18:28:24.249189 #16 3.009 Error: Cannot find module '/app/scripts/build-og-image.mjs'
|
||||
2026-Aug-09 18:28:24.249189 #16 3.009 at Module._resolveFilename (node:internal/modules/cjs/loader:1207:15)
|
||||
2026-Aug-09 18:28:24.249189 #16 3.009 at Module._load (node:internal/modules/cjs/loader:1038:27)
|
||||
2026-Aug-09 18:28:24.249189 #16 3.009 at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:164:12)
|
||||
2026-Aug-09 18:28:24.249189 #16 3.009 at node:internal/main/run_main_module:28:49 {
|
||||
2026-Aug-09 18:28:24.249189 #16 3.009 code: 'MODULE_NOT_FOUND',
|
||||
2026-Aug-09 18:28:24.249189 #16 3.009 requireStack: []
|
||||
2026-Aug-09 18:28:24.249189 #16 3.009 }
|
||||
2026-Aug-09 18:28:24.249189 #16 3.017
|
||||
2026-Aug-09 18:28:24.249189 #16 3.017 Node.js v20.20.2
|
||||
2026-Aug-09 18:28:24.249189 #16 3.067 npm notice
|
||||
2026-Aug-09 18:28:24.249189 #16 3.067 npm notice New major version of npm available! 10.8.2 -> 12.0.2
|
||||
2026-Aug-09 18:28:24.249189 #16 3.067 npm notice Changelog: https://github.com/npm/cli/releases/tag/v12.0.2
|
||||
2026-Aug-09 18:<REDACTED>@12.0.2
|
||||
2026-Aug-09 18:28:24.249189 #16 3.067 npm notice
|
||||
2026-Aug-09 18:28:24.249189 #16 ERROR: process "/bin/sh -c npm run build" did not complete successfully: exit code: 1
|
||||
2026-Aug-09 18:28:24.249189 ------
|
||||
2026-Aug-09 18:28:24.249189 > [builder 5/5] RUN npm run build:
|
||||
2026-Aug-09 18:28:24.249189 3.009 code: 'MODULE_NOT_FOUND',
|
||||
2026-Aug-09 18:28:24.249189 3.009 requireStack: []
|
||||
2026-Aug-09 18:28:24.249189 3.009 }
|
||||
2026-Aug-09 18:28:24.249189 3.017
|
||||
2026-Aug-09 18:28:24.249189 3.017 Node.js v20.20.2
|
||||
2026-Aug-09 18:28:24.249189 3.067 npm notice
|
||||
2026-Aug-09 18:28:24.249189 3.067 npm notice New major version of npm available! 10.8.2 -> 12.0.2
|
||||
2026-Aug-09 18:28:24.249189 3.067 npm notice Changelog: https://github.com/npm/cli/releases/tag/v12.0.2
|
||||
2026-Aug-09 18:<REDACTED>@12.0.2
|
||||
2026-Aug-09 18:28:24.249189 3.067 npm notice
|
||||
2026-Aug-09 18:28:24.249189 ------
|
||||
2026-Aug-09 18:28:24.249189 Dockerfile:62
|
||||
2026-Aug-09 18:28:24.249189 --------------------
|
||||
2026-Aug-09 18:28:24.249189 60 | # already contains the trimmed `node_modules` needed at runtime plus a
|
||||
2026-Aug-09 18:28:24.249189 61 | # ready-to-run `server.js`.
|
||||
2026-Aug-09 18:28:24.249189 62 | >>> RUN npm run build
|
||||
2026-Aug-09 18:28:24.249189 63 |
|
||||
2026-Aug-09 18:28:24.249189 64 | # ---------------------------------------------------------------------------
|
||||
2026-Aug-09 18:28:24.249189 --------------------
|
||||
2026-Aug-09 18:28:24.249189 ERROR: failed to build: failed to solve: process "/bin/sh -c npm run build" did not complete successfully: exit code: 1
|
||||
2026-Aug-09 18:28:24.249189 exit status 1
|
||||
2026-Aug-09 18:28:24.264866 [DEBUG] Error type: App\Exceptions\DeploymentException
|
||||
2026-Aug-09 18:28:24.280388 [DEBUG] Error code: 0
|
||||
2026-Aug-09 18:28:24.297395 [DEBUG] Location: /var/www/html/app/Traits/ExecuteRemoteCommand.php:242
|
||||
2026-Aug-09 18:28:24.313067 [DEBUG] Stack trace (first 5 lines):
|
||||
2026-Aug-09 18:28:24.328382 [DEBUG] #0 /var/www/html/app/Traits/ExecuteRemoteCommand.php(106): App\Jobs\ApplicationDeploymentJob->executeCommandWithProcess()
|
||||
2026-Aug-09 18:28:24.347507 [DEBUG] #1 /var/www/html/vendor/laravel/framework/src/Illuminate/Collections/Traits/EnumeratesValues.php(275): App\Jobs\ApplicationDeploymentJob->{closure:App\Traits\ExecuteRemoteCommand::execute_remote_command():72}()
|
||||
2026-Aug-09 18:28:24.365150 [DEBUG] #2 /var/www/html/app/Traits/ExecuteRemoteCommand.php(72): Illuminate\Support\Collection->each()
|
||||
2026-Aug-09 18:28:24.382636 [DEBUG] #3 /var/www/html/app/Jobs/ApplicationDeploymentJob.php(3821): App\Jobs\ApplicationDeploymentJob->execute_remote_command()
|
||||
2026-Aug-09 18:28:24.401357 [DEBUG] #4 /var/www/html/app/Jobs/ApplicationDeploymentJob.php(912): App\Jobs\ApplicationDeploymentJob->build_image()
|
||||
2026-Aug-09 18:28:24.418246 ========================================
|
||||
2026-Aug-09 18:28:24.434314 Deployment failed. Removing the new version of your application.
|
||||
2026-Aug-09 18:28:25.369820 Gracefully shutting down build container: qkvx1xuygl2okqnv67v1020c
|
||||
2026-Aug-09 18:28:25.989661 [DEBUG] [CMD]: docker stop --time=30 qkvx1xuygl2okqnv67v1020c
|
||||
2026-Aug-09 18:28:25.989661 [DEBUG] qkvx1xuygl2okqnv67v1020c
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
88
package-lock.json
generated
88
package-lock.json
generated
|
|
@ -7,6 +7,7 @@
|
|||
"": {
|
||||
"name": "mega-infrastructure-dashboard",
|
||||
"version": "1.0.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@ducanh2912/next-pwa": "^10.2.9",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
|
|
@ -23,6 +24,7 @@
|
|||
"lucide-react": "^0.469.0",
|
||||
"next": "^16",
|
||||
"papaparse": "^5.4.1",
|
||||
"pocketbase": "^0.27.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^2.15.0",
|
||||
|
|
@ -82,7 +84,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
|
|
@ -1597,7 +1598,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"tslib": "^2.4.0"
|
||||
|
|
@ -1609,7 +1609,6 @@
|
|||
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
|
|
@ -4082,7 +4081,6 @@
|
|||
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
|
|
@ -4093,7 +4091,6 @@
|
|||
"integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
|
|
@ -4155,7 +4152,6 @@
|
|||
"integrity": "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.66.0",
|
||||
"@typescript-eslint/types": "8.66.0",
|
||||
|
|
@ -4735,6 +4731,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
|
||||
"integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@webassemblyjs/helper-numbers": "1.13.2",
|
||||
"@webassemblyjs/helper-wasm-bytecode": "1.13.2"
|
||||
|
|
@ -4744,25 +4741,29 @@
|
|||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz",
|
||||
"integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@webassemblyjs/helper-api-error": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz",
|
||||
"integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@webassemblyjs/helper-buffer": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz",
|
||||
"integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@webassemblyjs/helper-numbers": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz",
|
||||
"integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@webassemblyjs/floating-point-hex-parser": "1.13.2",
|
||||
"@webassemblyjs/helper-api-error": "1.13.2",
|
||||
|
|
@ -4773,13 +4774,15 @@
|
|||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz",
|
||||
"integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@webassemblyjs/helper-wasm-section": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz",
|
||||
"integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@webassemblyjs/ast": "1.14.1",
|
||||
"@webassemblyjs/helper-buffer": "1.14.1",
|
||||
|
|
@ -4792,6 +4795,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz",
|
||||
"integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@xtuc/ieee754": "^1.2.0"
|
||||
}
|
||||
|
|
@ -4801,6 +4805,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz",
|
||||
"integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@xtuc/long": "4.2.2"
|
||||
}
|
||||
|
|
@ -4809,13 +4814,15 @@
|
|||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz",
|
||||
"integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@webassemblyjs/wasm-edit": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz",
|
||||
"integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@webassemblyjs/ast": "1.14.1",
|
||||
"@webassemblyjs/helper-buffer": "1.14.1",
|
||||
|
|
@ -4832,6 +4839,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz",
|
||||
"integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@webassemblyjs/ast": "1.14.1",
|
||||
"@webassemblyjs/helper-wasm-bytecode": "1.13.2",
|
||||
|
|
@ -4845,6 +4853,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz",
|
||||
"integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@webassemblyjs/ast": "1.14.1",
|
||||
"@webassemblyjs/helper-buffer": "1.14.1",
|
||||
|
|
@ -4857,6 +4866,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz",
|
||||
"integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@webassemblyjs/ast": "1.14.1",
|
||||
"@webassemblyjs/helper-api-error": "1.13.2",
|
||||
|
|
@ -4871,6 +4881,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz",
|
||||
"integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@webassemblyjs/ast": "1.14.1",
|
||||
"@xtuc/long": "4.2.2"
|
||||
|
|
@ -4880,20 +4891,21 @@
|
|||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
|
||||
"integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==",
|
||||
"license": "BSD-3-Clause"
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@xtuc/long": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz",
|
||||
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
|
||||
"license": "Apache-2.0"
|
||||
"license": "Apache-2.0",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
|
||||
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -4933,6 +4945,7 @@
|
|||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
|
||||
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
|
|
@ -4950,6 +4963,7 @@
|
|||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
|
|
@ -4965,7 +4979,8 @@
|
|||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
|
|
@ -5343,7 +5358,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.11.12",
|
||||
"caniuse-lite": "^1.0.30001809",
|
||||
|
|
@ -5463,6 +5477,7 @@
|
|||
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz",
|
||||
"integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
|
|
@ -6079,7 +6094,8 @@
|
|||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
|
||||
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.2",
|
||||
|
|
@ -6169,7 +6185,6 @@
|
|||
"integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
|
|
@ -6604,6 +6619,7 @@
|
|||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
|
|
@ -7735,6 +7751,7 @@
|
|||
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz",
|
||||
"integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"merge-stream": "^2.0.0",
|
||||
|
|
@ -7749,6 +7766,7 @@
|
|||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
|
|
@ -7765,7 +7783,6 @@
|
|||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
|
|
@ -8283,7 +8300,8 @@
|
|||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
|
|
@ -8312,6 +8330,7 @@
|
|||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
|
|
@ -8343,6 +8362,7 @@
|
|||
"resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.6.1.tgz",
|
||||
"integrity": "sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.25",
|
||||
"jest-worker": "^27.4.5",
|
||||
|
|
@ -8449,14 +8469,14 @@
|
|||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
|
||||
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.3.0.tgz",
|
||||
"integrity": "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@next/env": "16.3.0",
|
||||
"@swc/helpers": "0.5.15",
|
||||
|
|
@ -8839,6 +8859,12 @@
|
|||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pocketbase": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.27.3.tgz",
|
||||
"integrity": "sha512-n2rtQrg3kTF44066B+2eVvQdiYRGZ+RI7isIZqmAptfElLCSDIZrEKLhSrIQA2k5pvQM29Gx5JqGv5n+gtm6dg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
|
|
@ -8954,7 +8980,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -8964,7 +8989,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
|
||||
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
|
|
@ -9280,7 +9304,6 @@
|
|||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz",
|
||||
"integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"rollup": "dist/bin/rollup"
|
||||
},
|
||||
|
|
@ -9397,6 +9420,7 @@
|
|||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
|
||||
"integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.9",
|
||||
"ajv": "^8.9.0",
|
||||
|
|
@ -9433,6 +9457,7 @@
|
|||
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
|
||||
"integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3"
|
||||
},
|
||||
|
|
@ -9444,7 +9469,8 @@
|
|||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.6.3",
|
||||
|
|
@ -10067,7 +10093,6 @@
|
|||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -10237,7 +10262,6 @@
|
|||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
@ -10513,6 +10537,7 @@
|
|||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz",
|
||||
"integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2"
|
||||
},
|
||||
|
|
@ -10525,6 +10550,7 @@
|
|||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.109.2.tgz",
|
||||
"integrity": "sha512-U9/cvLzxObKNEZ9+TtdqrHM5/9z3lgl2c+c4BzbqGxFQvQvBAq87yql5A8pQ+rrMbS496MZJeF5enVBndIy2hw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.8",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
|
|
@ -10568,6 +10594,7 @@
|
|||
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.1.tgz",
|
||||
"integrity": "sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
|
|
@ -10577,6 +10604,7 @@
|
|||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
|
||||
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esrecurse": "^4.3.0",
|
||||
"estraverse": "^4.1.1"
|
||||
|
|
@ -10590,6 +10618,7 @@
|
|||
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
|
||||
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
|
|
@ -10793,7 +10822,6 @@
|
|||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
|
|
@ -10974,7 +11002,6 @@
|
|||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
|
|
@ -11100,7 +11127,6 @@
|
|||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -H 0.0.0.0 -p 3000 --webpack",
|
||||
"dev": "next dev -H 0.0.0.0 -p 6004 --webpack",
|
||||
"build": "npm run og-image && next build --webpack",
|
||||
"og-image": "node scripts/build-og-image.mjs",
|
||||
"start": "next start -H 0.0.0.0 -p 3000",
|
||||
|
|
@ -27,12 +27,14 @@
|
|||
"lucide-react": "^0.469.0",
|
||||
"next": "^16",
|
||||
"papaparse": "^5.4.1",
|
||||
"pocketbase": "^0.27.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^2.15.0",
|
||||
"tailwind-merge": "^2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/papaparse": "^5.3.15",
|
||||
"@types/react": "^19.0.2",
|
||||
|
|
@ -41,7 +43,6 @@
|
|||
"eslint-config-next": "^16",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
295
scripts/pb-bootstrap-schema.py
Normal file
295
scripts/pb-bootstrap-schema.py
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Bootstrap PocketBase schema for MEGA INFRASTRUCTURE.
|
||||
|
||||
PocketBase v0.39 JSON shape: type-specific options (values, maxSelect,
|
||||
mimeTypes, collectionId, cascadeDelete, min, max) live at the field
|
||||
top level — NOT nested under "options".
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
PB = "https://pb.mega.infra.apphub.co.za"
|
||||
EMAIL = "admin@mega.co.za"
|
||||
PASSWORD = "12345678"
|
||||
|
||||
|
||||
def http(method, path, body=None, token=None):
|
||||
url = f"{PB}{path}"
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
if body is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if token:
|
||||
req.add_header("Authorization", token)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as r:
|
||||
txt = r.read().decode()
|
||||
return r.status, json.loads(txt) if txt else None
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, json.loads(e.read().decode() or "{}")
|
||||
|
||||
|
||||
code, auth = http("POST", "/api/collections/_superusers/auth-with-password",
|
||||
{"identity": EMAIL, "password": PASSWORD})
|
||||
if code != 200:
|
||||
sys.exit(f"AUTH FAILED: {code} {auth}")
|
||||
TOKEN = auth["token"]
|
||||
print(f"auth ok, superuser id={auth['record']['id']}\n")
|
||||
|
||||
LOCKED = { # superuser-only access
|
||||
"listRule": None, "viewRule": None,
|
||||
"createRule": None, "updateRule": None, "deleteRule": None,
|
||||
}
|
||||
|
||||
|
||||
def field(name, type_, **kw):
|
||||
"""Build a field. Extra kwargs (required, values, maxSelect, etc.)
|
||||
go at the field top level."""
|
||||
f = {"name": name, "type": type_}
|
||||
f.update(kw)
|
||||
return f
|
||||
|
||||
|
||||
DEFS = {}
|
||||
|
||||
DEFS["data_import_batches"] = {
|
||||
"name": "DATA_IMPORT_BATCHES", "type": "base",
|
||||
"fields": [
|
||||
field("batch_type", "select", required=True,
|
||||
values=["timesheets", "fuel_logs"], maxSelect=1),
|
||||
field("original_file", "file", required=False,
|
||||
maxSelect=1, maxSize=20971520,
|
||||
mimeTypes=["text/csv", "text/plain", "application/vnd.ms-excel", "application/csv"]),
|
||||
field("date_uploaded", "date", required=False),
|
||||
field("uploaded_by", "text", required=False),
|
||||
],
|
||||
"indexes": [],
|
||||
}
|
||||
|
||||
DEFS["sites"] = {
|
||||
"name": "SITES", "type": "base",
|
||||
"fields": [
|
||||
field("project_code", "text", required=True, min=1, max=64),
|
||||
field("name", "text", required=False),
|
||||
field("duration", "text", required=False),
|
||||
field("contract_value", "number", required=False),
|
||||
field("work_hours", "number", required=False),
|
||||
field("hours_per_week", "number", required=False),
|
||||
field("wages_hours_per_day", "number", required=False),
|
||||
],
|
||||
"indexes": [
|
||||
"CREATE UNIQUE INDEX `idx_sites_project_code` ON `sites` (`project_code`)",
|
||||
],
|
||||
}
|
||||
|
||||
DEFS["employees"] = {
|
||||
"name": "EMPLOYEES", "type": "base",
|
||||
"fields": [
|
||||
field("project_code", "text", required=False),
|
||||
field("site", "relation", required=False,
|
||||
collectionId="__SITES_ID__", cascadeDelete=False, maxSelect=1),
|
||||
field("name", "text", required=False),
|
||||
field("employee_no", "text", required=False),
|
||||
field("position", "text", required=False),
|
||||
field("rate_per_hour", "number", required=False),
|
||||
field("hours_per_day_standard", "number", required=False),
|
||||
field("worker_type", "select", required=False,
|
||||
values=["permanent", "casual"], maxSelect=1),
|
||||
],
|
||||
"indexes": [],
|
||||
}
|
||||
|
||||
DEFS["purchase_orders"] = {
|
||||
"name": "PURCHASE_ORDERS", "type": "base",
|
||||
"fields": [
|
||||
field("purchase_order_number", "text", required=True, min=1, max=128),
|
||||
field("project_code", "text", required=False),
|
||||
field("site", "relation", required=False,
|
||||
collectionId="__SITES_ID__", cascadeDelete=False, maxSelect=1),
|
||||
field("supplier", "text", required=False),
|
||||
field("required_by_date", "date", required=False),
|
||||
field("ordered_by", "text", required=False),
|
||||
field("items_count", "number", required=False),
|
||||
field("total_value", "number", required=False),
|
||||
field("purchase_order_file", "file", required=False,
|
||||
maxSelect=1, maxSize=20971520,
|
||||
mimeTypes=["application/pdf", "image/jpeg", "image/png", "image/webp"]),
|
||||
field("raw_ocr_data", "json", required=False),
|
||||
field("is_verified", "bool", required=False),
|
||||
],
|
||||
"indexes": [
|
||||
"CREATE UNIQUE INDEX `idx_po_number` ON `purchase_orders` (`purchase_order_number`)",
|
||||
],
|
||||
}
|
||||
|
||||
DEFS["purchase_order_items"] = {
|
||||
"name": "PURCHASE_ORDER_ITEMS", "type": "base",
|
||||
"fields": [
|
||||
field("purchase_order", "relation", required=False,
|
||||
collectionId="__PURCHASE_ORDERS_ID__", cascadeDelete=True, maxSelect=1),
|
||||
field("material_code", "text", required=False),
|
||||
field("cost_code", "text", required=False),
|
||||
field("description", "text", required=False),
|
||||
field("qty", "number", required=False),
|
||||
field("qty_outstanding", "number", required=False),
|
||||
field("unit", "text", required=False),
|
||||
field("rate", "number", required=False),
|
||||
field("disc_percent", "number", required=False),
|
||||
field("vat_percent", "number", required=False),
|
||||
field("amount", "number", required=False),
|
||||
],
|
||||
"indexes": [],
|
||||
}
|
||||
|
||||
DEFS["fixed_costs"] = {
|
||||
"name": "FIXED_COSTS", "type": "base",
|
||||
"fields": [
|
||||
field("project_code", "text", required=False),
|
||||
field("site", "relation", required=False,
|
||||
collectionId="__SITES_ID__", cascadeDelete=False, maxSelect=1),
|
||||
field("date_logged", "date", required=False),
|
||||
field("description", "text", required=False),
|
||||
field("cost", "number", required=False),
|
||||
field("notes", "text", required=False),
|
||||
],
|
||||
"indexes": [],
|
||||
}
|
||||
|
||||
DEFS["plant"] = {
|
||||
"name": "PLANT", "type": "base",
|
||||
"fields": [
|
||||
field("project_code", "text", required=False),
|
||||
field("site", "relation", required=False,
|
||||
collectionId="__SITES_ID__", cascadeDelete=False, maxSelect=1),
|
||||
field("description", "text", required=False),
|
||||
field("standard_work_hours", "text", required=False),
|
||||
field("cost_per_hour", "number", required=False),
|
||||
],
|
||||
"indexes": [],
|
||||
}
|
||||
|
||||
DEFS["vehicles"] = {
|
||||
"name": "VEHICLES", "type": "base",
|
||||
"fields": [
|
||||
field("project_code", "text", required=False),
|
||||
field("site", "relation", required=False,
|
||||
collectionId="__SITES_ID__", cascadeDelete=False, maxSelect=1),
|
||||
field("description", "text", required=False),
|
||||
field("standard_work_hours", "text", required=False),
|
||||
field("fuel_limits", "text", required=False),
|
||||
],
|
||||
"indexes": [],
|
||||
}
|
||||
|
||||
DEFS["timesheets"] = {
|
||||
"name": "TIMESHEETS", "type": "base",
|
||||
"fields": [
|
||||
field("employee", "relation", required=False,
|
||||
collectionId="__EMPLOYEES_ID__", cascadeDelete=False, maxSelect=1),
|
||||
field("batch_reference", "relation", required=False,
|
||||
collectionId="__DATA_IMPORT_BATCHES_ID__", cascadeDelete=False, maxSelect=1),
|
||||
field("date_logged", "date", required=False),
|
||||
field("hours_worked", "number", required=False),
|
||||
],
|
||||
"indexes": [],
|
||||
}
|
||||
|
||||
DEFS["fuel_logs"] = {
|
||||
"name": "FUEL_LOGS", "type": "base",
|
||||
"fields": [
|
||||
field("project_code", "text", required=False),
|
||||
field("site", "relation", required=False,
|
||||
collectionId="__SITES_ID__", cascadeDelete=False, maxSelect=1),
|
||||
field("vehicle", "relation", required=False,
|
||||
collectionId="__VEHICLES_ID__", cascadeDelete=False, maxSelect=1),
|
||||
field("batch_reference", "relation", required=False,
|
||||
collectionId="__DATA_IMPORT_BATCHES_ID__", cascadeDelete=False, maxSelect=1),
|
||||
field("date_logged", "date", required=False),
|
||||
field("litres", "number", required=False),
|
||||
field("odo_reading", "number", required=False),
|
||||
],
|
||||
"indexes": [],
|
||||
}
|
||||
|
||||
DEFS["delivery_notes"] = {
|
||||
"name": "DELIVERY_NOTES", "type": "base",
|
||||
"fields": [
|
||||
field("delivery_note_number", "text", required=False),
|
||||
field("project_code", "text", required=False),
|
||||
field("site", "relation", required=False,
|
||||
collectionId="__SITES_ID__", cascadeDelete=False, maxSelect=1),
|
||||
field("purchase_order", "relation", required=False,
|
||||
collectionId="__PURCHASE_ORDERS_ID__", cascadeDelete=False, maxSelect=1),
|
||||
field("date_received", "date", required=False),
|
||||
field("received_by", "text", required=False),
|
||||
field("slip_image", "file", required=False,
|
||||
maxSelect=1, maxSize=20971520,
|
||||
mimeTypes=["image/jpeg", "image/png", "image/webp", "application/pdf"]),
|
||||
field("raw_ocr_data", "json", required=False),
|
||||
field("is_verified", "bool", required=False),
|
||||
],
|
||||
"indexes": [],
|
||||
}
|
||||
|
||||
DEFS["received_items"] = {
|
||||
"name": "RECEIVED_ITEMS", "type": "base",
|
||||
"fields": [
|
||||
field("delivery_note", "relation", required=False,
|
||||
collectionId="__DELIVERY_NOTES_ID__", cascadeDelete=True, maxSelect=1),
|
||||
field("purchase_order_item", "relation", required=False,
|
||||
collectionId="__PURCHASE_ORDER_ITEMS_ID__", cascadeDelete=False, maxSelect=1),
|
||||
field("qty_received", "number", required=False),
|
||||
field("condition_notes", "text", required=False),
|
||||
],
|
||||
"indexes": [],
|
||||
}
|
||||
|
||||
ORDER = [
|
||||
"data_import_batches",
|
||||
"sites",
|
||||
"employees",
|
||||
"purchase_orders",
|
||||
"purchase_order_items",
|
||||
"fixed_costs",
|
||||
"plant",
|
||||
"vehicles",
|
||||
"timesheets",
|
||||
"fuel_logs",
|
||||
"delivery_notes",
|
||||
"received_items",
|
||||
]
|
||||
|
||||
ids = {}
|
||||
for key in ORDER:
|
||||
body = dict(DEFS[key])
|
||||
body.update(LOCKED)
|
||||
for f in body["fields"]:
|
||||
if f.get("type") == "relation":
|
||||
cid = f.get("collectionId", "")
|
||||
# placeholders use collection table name in upper, e.g. __SITES_ID__
|
||||
for other_key, other_id in ids.items():
|
||||
collection_name_upper = DEFS[other_key]["name"] # e.g. "SITES"
|
||||
cid = cid.replace(f"__{collection_name_upper}_ID__", other_id)
|
||||
if "__" in cid:
|
||||
sys.exit(f"unresolved placeholder in {key}.{f['name']}: {cid}")
|
||||
f["collectionId"] = cid
|
||||
|
||||
code, resp = http("POST", "/api/collections", body, TOKEN)
|
||||
if code == 200:
|
||||
ids[key] = resp["id"]
|
||||
print(f" ✓ {body['name']:25s} → {resp['id']}")
|
||||
elif code == 400:
|
||||
code2, existing = http("GET", f"/api/collections/{body['name']}", token=TOKEN)
|
||||
if code2 == 200:
|
||||
ids[key] = existing["id"]
|
||||
print(f" • {body['name']:25s} → {existing['id']} (already existed)")
|
||||
else:
|
||||
print(f" ✗ {body['name']:25s} FAILED: {resp}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f" ✗ {body['name']:25s} FAILED: {code} {resp}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\nDone. {len(ids)} collections on {PB}.")
|
||||
79
scripts/pb-relax-rules.py
Normal file
79
scripts/pb-relax-rules.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Relax API rules on the 12 MEGA INFRASTRUCTURE collections.
|
||||
|
||||
Pattern:
|
||||
list/view/create → any authenticated user
|
||||
update/delete → superuser only (audit-safe for OCR-verified records)
|
||||
|
||||
Override per-collection with the RULES dict if you need different behaviour.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
PB = "https://pb.mega.infra.apphub.co.za"
|
||||
EMAIL = "admin@mega.co.za"
|
||||
PASSWORD = "12345678"
|
||||
|
||||
AUTH_RULE = "@request.auth.id != ''" # any logged-in user
|
||||
NONE_RULE = None # superuser only
|
||||
|
||||
RULES = {
|
||||
"DATA_IMPORT_BATCHES": (AUTH_RULE, AUTH_RULE, AUTH_RULE, NONE_RULE, NONE_RULE),
|
||||
"SITES": (AUTH_RULE, AUTH_RULE, AUTH_RULE, NONE_RULE, NONE_RULE),
|
||||
"EMPLOYEES": (AUTH_RULE, AUTH_RULE, AUTH_RULE, NONE_RULE, NONE_RULE),
|
||||
"PURCHASE_ORDERS": (AUTH_RULE, AUTH_RULE, AUTH_RULE, NONE_RULE, NONE_RULE),
|
||||
"PURCHASE_ORDER_ITEMS": (AUTH_RULE, AUTH_RULE, AUTH_RULE, NONE_RULE, NONE_RULE),
|
||||
"FIXED_COSTS": (AUTH_RULE, AUTH_RULE, AUTH_RULE, NONE_RULE, NONE_RULE),
|
||||
"PLANT": (AUTH_RULE, AUTH_RULE, AUTH_RULE, NONE_RULE, NONE_RULE),
|
||||
"VEHICLES": (AUTH_RULE, AUTH_RULE, AUTH_RULE, NONE_RULE, NONE_RULE),
|
||||
"TIMESHEETS": (AUTH_RULE, AUTH_RULE, AUTH_RULE, NONE_RULE, NONE_RULE),
|
||||
"FUEL_LOGS": (AUTH_RULE, AUTH_RULE, AUTH_RULE, NONE_RULE, NONE_RULE),
|
||||
"DELIVERY_NOTES": (AUTH_RULE, AUTH_RULE, AUTH_RULE, NONE_RULE, NONE_RULE),
|
||||
"RECEIVED_ITEMS": (AUTH_RULE, AUTH_RULE, AUTH_RULE, NONE_RULE, NONE_RULE),
|
||||
}
|
||||
|
||||
|
||||
def http(method, path, body=None, token=None):
|
||||
url = f"{PB}{path}"
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
if body is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if token:
|
||||
req.add_header("Authorization", token)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as r:
|
||||
txt = r.read().decode()
|
||||
return r.status, json.loads(txt) if txt else None
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, json.loads(e.read().decode() or "{}")
|
||||
|
||||
|
||||
code, auth = http("POST", "/api/collections/_superusers/auth-with-password",
|
||||
{"identity": EMAIL, "password": PASSWORD})
|
||||
if code != 200:
|
||||
sys.exit(f"AUTH FAILED: {code} {auth}")
|
||||
TOKEN = auth["token"]
|
||||
|
||||
# fetch all collections to map name → id
|
||||
code, listing = http("GET", "/api/collections?perPage=100", token=TOKEN)
|
||||
if code != 200:
|
||||
sys.exit(f"could not list collections: {code}")
|
||||
by_name = {c["name"]: c["id"] for c in listing["items"]}
|
||||
|
||||
for name, (l, v, c, u, d) in RULES.items():
|
||||
cid = by_name.get(name)
|
||||
if not cid:
|
||||
print(f" ✗ {name:25s} not found, skipping")
|
||||
continue
|
||||
code, resp = http("PATCH", f"/api/collections/{cid}",
|
||||
{"listRule": l, "viewRule": v, "createRule": c,
|
||||
"updateRule": u, "deleteRule": d}, TOKEN)
|
||||
if code == 200:
|
||||
print(f" ✓ {name:25s} updated")
|
||||
else:
|
||||
print(f" ✗ {name:25s} FAILED: {code} {resp}")
|
||||
|
||||
print("\nDone.")
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { EMPLOYEES, FUEL_READINGS, ORDERS, VEHICLES } from "@/lib/mock-data";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
company: "MEGA INFRASTRUCTURE",
|
||||
counts: {
|
||||
vehicles: VEHICLES.length,
|
||||
fuelReadings: FUEL_READINGS.length,
|
||||
orders: ORDERS.length,
|
||||
employees: EMPLOYEES.length,
|
||||
},
|
||||
sites: Array.from(new Set(VEHICLES.map((v) => v.assignedSite))),
|
||||
modules: ["/dashboard", "/dashboard/fuel", "/dashboard/orders", "/dashboard/employees"],
|
||||
reporting: "/api/mcp?module=all|fuel|orders|employees",
|
||||
uploads: "/api/upload",
|
||||
});
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { EMPLOYEES, FUEL_READINGS, ORDERS, VEHICLES } from "@/lib/mock-data";
|
||||
import { analyseFuel } from "@/lib/fuel";
|
||||
|
||||
/**
|
||||
* MCP-style reporting endpoint for the AI agent.
|
||||
*
|
||||
* Returns the same data shape the dashboard uses, in a compact JSON form so
|
||||
* an external agent (e.g. an MCP server, a Make / n8n automation, or the
|
||||
* site manager's copilot) can answer questions like:
|
||||
* "how many fuel anomalies this week?"
|
||||
* "which orders are overdue?"
|
||||
* "who is overworked?"
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
const module = url.searchParams.get("module") ?? "all";
|
||||
const anomalies = analyseFuel(FUEL_READINGS, VEHICLES);
|
||||
const flagged = anomalies.filter((a) => a.severity !== "ok");
|
||||
|
||||
const payload: Record<string, unknown> = {};
|
||||
|
||||
if (module === "all" || module === "fuel") {
|
||||
payload.fuel = {
|
||||
readingsCount: FUEL_READINGS.length,
|
||||
vehiclesCount: VEHICLES.length,
|
||||
anomaliesCount: flagged.length,
|
||||
critical: flagged.filter((a) => a.severity === "critical").length,
|
||||
alert: flagged.filter((a) => a.severity === "alert").length,
|
||||
watch: flagged.filter((a) => a.severity === "watch").length,
|
||||
topAnomalies: flagged.slice(0, 5),
|
||||
};
|
||||
}
|
||||
|
||||
if (module === "all" || module === "orders") {
|
||||
const overdue = ORDERS.filter(
|
||||
(o) => o.status !== "Delivered" && o.status !== "Cancelled" && new Date(o.expectedAt) < new Date()
|
||||
);
|
||||
payload.orders = {
|
||||
total: ORDERS.length,
|
||||
open: ORDERS.filter((o) => o.status !== "Delivered" && o.status !== "Cancelled").length,
|
||||
overdue: overdue.length,
|
||||
totalValue: ORDERS.reduce((s, o) => s + o.totalValue, 0),
|
||||
outstandingLines: ORDERS.reduce(
|
||||
(s, o) => s + o.lines.filter((l) => l.quantityDelivered < l.quantityOrdered).length,
|
||||
0
|
||||
),
|
||||
byStatus: Array.from(
|
||||
ORDERS.reduce((m, o) => m.set(o.status, (m.get(o.status) ?? 0) + 1), new Map<string, number>())
|
||||
).map(([status, count]) => ({ status, count })),
|
||||
};
|
||||
}
|
||||
|
||||
if (module === "all" || module === "employees") {
|
||||
const overworked = EMPLOYEES.filter((e) => e.weeklyOvertimeHours > 6);
|
||||
const onLeave = EMPLOYEES.filter((e) => e.status === "On Leave");
|
||||
const absent = EMPLOYEES.filter(
|
||||
(e) => e.dailyHours.slice(1).every((d) => d.hours === 0) && e.status === "Active"
|
||||
);
|
||||
payload.employees = {
|
||||
total: EMPLOYEES.length,
|
||||
onLeave: onLeave.length,
|
||||
absent: absent.length,
|
||||
overworked: overworked.length,
|
||||
weeklyHoursTotal: EMPLOYEES.reduce((s, e) => s + e.weeklyHours, 0),
|
||||
};
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
generatedAt: new Date().toISOString(),
|
||||
company: "MEGA INFRASTRUCTURE",
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import type { UploadKind } from "@/lib/types";
|
||||
import { UPLOAD_ACCEPTANCE } from "@/lib/uploads";
|
||||
|
||||
const VALID_KINDS: UploadKind[] = [
|
||||
"orders",
|
||||
"delivery-notes",
|
||||
"fuel-logs",
|
||||
"time-sheets",
|
||||
];
|
||||
|
||||
function isUploadKind(value: string): value is UploadKind {
|
||||
return (VALID_KINDS as string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* File upload endpoint. Accepts multipart/form-data with:
|
||||
* - `type` — one of `orders`, `delivery-notes`, `fuel-logs`, `time-sheets`
|
||||
* - `file` — one or more files matching the type's allowed MIME list
|
||||
*
|
||||
* Returns a JSON descriptor with the accepted and rejected files. Each
|
||||
* type has its own MIME allowlist and max size (see `lib/uploads.ts`).
|
||||
*
|
||||
* NOTE: this is a stub — when PocketBase is wired the body will be
|
||||
* forwarded to the matching collection.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const form = await request.formData();
|
||||
const typeRaw = (form.get("type") as string | null) ?? "";
|
||||
if (!isUploadKind(typeRaw)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Missing or invalid 'type' field. Expected one of: ${VALID_KINDS.join(", ")}.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const rules = UPLOAD_ACCEPTANCE[typeRaw];
|
||||
const files = form.getAll("file");
|
||||
if (files.length === 0) {
|
||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||
}
|
||||
|
||||
const accepted: Array<{ name: string; size: number; mime: string }> = [];
|
||||
const rejected: Array<{ name: string; reason: string }> = [];
|
||||
|
||||
for (const entry of files) {
|
||||
if (!(entry instanceof File)) {
|
||||
rejected.push({ name: "?", reason: "Not a file" });
|
||||
continue;
|
||||
}
|
||||
if (!rules.mimes.includes(entry.type)) {
|
||||
rejected.push({
|
||||
name: entry.name,
|
||||
reason: `Unsupported type ${entry.type || "unknown"} for ${typeRaw}. Expected ${rules.extensions.join(", ")}.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (entry.size > rules.maxBytes) {
|
||||
const maxMb = (rules.maxBytes / 1024 / 1024).toFixed(0);
|
||||
rejected.push({
|
||||
name: entry.name,
|
||||
reason: `Exceeds ${maxMb}MB limit`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
accepted.push({ name: entry.name, size: entry.size, mime: entry.type });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
type: typeRaw,
|
||||
accepted,
|
||||
rejected,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
endpoint: "/api/upload",
|
||||
method: "POST",
|
||||
body: "multipart/form-data with fields 'type' (one of: orders, delivery-notes, fuel-logs, time-sheets) and 'file'.",
|
||||
acceptedTypes: VALID_KINDS.reduce<Record<string, { extensions: string[]; maxBytes: number }>>(
|
||||
(acc, k) => {
|
||||
acc[k] = {
|
||||
extensions: UPLOAD_ACCEPTANCE[k].extensions,
|
||||
maxBytes: UPLOAD_ACCEPTANCE[k].maxBytes,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
),
|
||||
});
|
||||
}
|
||||
|
|
@ -1,378 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type SortingState,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bed,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Search,
|
||||
TrendingUp,
|
||||
UserCheck,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Topbar } from "@/components/layout/sidebar";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { KpiCard } from "@/components/kpi-card";
|
||||
import { WorkforceTrendBars } from "@/components/charts/workforce-trend-bars";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
import { EMPLOYEES, SITES } from "@/lib/mock-data";
|
||||
import type { Employee, EmployeeRole } from "@/lib/types";
|
||||
import { cn, formatCurrency } from "@/lib/utils";
|
||||
|
||||
type Flag = "ok" | "overworked" | "underworked" | "absent" | "leave";
|
||||
|
||||
const flagFor = (e: Employee): Flag => {
|
||||
if (e.status === "On Leave") return "leave";
|
||||
const weekHours = e.dailyHours.slice(1).reduce((s, d) => s + d.hours, 0);
|
||||
if (weekHours === 0) return "absent";
|
||||
if (e.weeklyOvertimeHours > 6) return "overworked";
|
||||
if (weekHours < 20) return "underworked";
|
||||
return "ok";
|
||||
};
|
||||
|
||||
const flagMeta: Record<Flag, { label: string; variant: "success" | "warning" | "danger" | "info" | "secondary"; icon: React.ReactNode }> = {
|
||||
ok: { label: "On track", variant: "success", icon: <UserCheck className="h-3 w-3" /> },
|
||||
overworked: { label: "Overworked", variant: "warning", icon: <TrendingUp className="h-3 w-3" /> },
|
||||
underworked: { label: "Underworked", variant: "info", icon: <Clock className="h-3 w-3" /> },
|
||||
absent: { label: "No hours", variant: "danger", icon: <AlertTriangle className="h-3 w-3" /> },
|
||||
leave: { label: "On leave", variant: "secondary", icon: <Bed className="h-3 w-3" /> },
|
||||
};
|
||||
|
||||
const ROLES: EmployeeRole[] = [
|
||||
"Site Foreman", "Operator", "Driver", "Mechanic", "Electrician", "Welder", "Labourer", "Surveyor", "Safety Officer",
|
||||
];
|
||||
|
||||
export default function EmployeesPage() {
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [siteFilter, setSiteFilter] = React.useState<string>("all");
|
||||
const [roleFilter, setRoleFilter] = React.useState<string>("all");
|
||||
const [flagFilter, setFlagFilter] = React.useState<Flag | "all">("all");
|
||||
const [sorting, setSorting] = React.useState<SortingState>([{ id: "weeklyHours", desc: true }]);
|
||||
const [pageSize, setPageSize] = React.useState(8);
|
||||
|
||||
const filtered = React.useMemo(
|
||||
() =>
|
||||
EMPLOYEES.filter((e) => {
|
||||
if (siteFilter !== "all" && e.site !== siteFilter) return false;
|
||||
if (roleFilter !== "all" && e.role !== roleFilter) return false;
|
||||
if (flagFilter !== "all" && flagFor(e) !== flagFilter) return false;
|
||||
if (search && !`${e.fullName} ${e.role}`.toLowerCase().includes(search.toLowerCase()))
|
||||
return false;
|
||||
return true;
|
||||
}),
|
||||
[search, siteFilter, roleFilter, flagFilter]
|
||||
);
|
||||
|
||||
const columns = React.useMemo<ColumnDef<Employee>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "fullName",
|
||||
header: "Employee",
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p className="font-semibold">{row.original.fullName}</p>
|
||||
<p className="text-xs text-muted-foreground">{row.original.phone}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ accessorKey: "role", header: "Role" },
|
||||
{ accessorKey: "site", header: "Site" },
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
const f = flagFor(row.original);
|
||||
return (
|
||||
<Badge variant={flagMeta[f].variant} className="gap-1">
|
||||
{flagMeta[f].icon}
|
||||
{flagMeta[f].label}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "weeklyHours",
|
||||
header: "This week",
|
||||
cell: ({ row }) => {
|
||||
const e = row.original;
|
||||
const pct = Math.min(100, (e.weeklyHours / e.weeklyTargetHours) * 100);
|
||||
const tone = e.weeklyOvertimeHours > 6 ? "warning" : e.weeklyHours === 0 ? "danger" : "default";
|
||||
return (
|
||||
<div className="min-w-[160px]">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="font-medium">{e.weeklyHours.toFixed(1)}h</span>
|
||||
<span className="text-muted-foreground">/ {e.weeklyTargetHours}h</span>
|
||||
</div>
|
||||
<Progress value={pct} tone={tone} className="mt-1" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "weeklyOvertimeHours",
|
||||
header: "Overtime",
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
row.original.weeklyOvertimeHours > 6 ? "font-semibold text-amber-600" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{row.original.weeklyOvertimeHours.toFixed(1)}h
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "cost",
|
||||
header: "Cost",
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">
|
||||
{formatCurrency(row.original.weeklyHours * row.original.hourlyRate)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "trend",
|
||||
header: "Trend",
|
||||
cell: ({ row }) => (
|
||||
<WorkforceTrendBars
|
||||
data={row.original.dailyHours}
|
||||
height={48}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: filtered,
|
||||
columns,
|
||||
state: { sorting },
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
initialState: { pagination: { pageSize } },
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
table.setPageSize(pageSize);
|
||||
}, [pageSize, table]);
|
||||
|
||||
const totalHours = EMPLOYEES.reduce((s, e) => s + e.weeklyHours, 0);
|
||||
const overworked = EMPLOYEES.filter((e) => flagFor(e) === "overworked").length;
|
||||
const underworked = EMPLOYEES.filter((e) => flagFor(e) === "underworked").length;
|
||||
const absent = EMPLOYEES.filter((e) => flagFor(e) === "absent").length;
|
||||
const onLeave = EMPLOYEES.filter((e) => flagFor(e) === "leave").length;
|
||||
const totalCost = EMPLOYEES.reduce((s, e) => s + e.weeklyHours * e.hourlyRate, 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Topbar
|
||||
title="Workforce"
|
||||
subtitle="Weekly hours, overtime and attendance across all sites"
|
||||
right={
|
||||
<Button size="sm" variant="outline">
|
||||
Export timesheet
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-6 p-4 sm:p-6">
|
||||
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<KpiCard
|
||||
label="Hours this week"
|
||||
value={`${totalHours.toFixed(0)}h`}
|
||||
hint={`${EMPLOYEES.length} active employees`}
|
||||
tone="default"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Overworked"
|
||||
value={`${overworked}`}
|
||||
hint="More than 6h overtime"
|
||||
tone="warning"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Underworked"
|
||||
value={`${underworked}`}
|
||||
hint="Less than 20h this week"
|
||||
tone="info"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Not on site"
|
||||
value={`${absent + onLeave}`}
|
||||
delta={`${onLeave} on leave`}
|
||||
trend={absent > 0 ? "down" : "flat"}
|
||||
hint="Includes absent + on leave"
|
||||
tone={absent > 0 ? "danger" : "default"}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<CardTitle>Employees</CardTitle>
|
||||
<CardDescription>
|
||||
{filtered.length} of {EMPLOYEES.length} employees · {formatCurrency(totalCost)} weekly payroll
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search name or role…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-56 pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Select value={roleFilter} onValueChange={setRoleFilter}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All roles</SelectItem>
|
||||
{ROLES.map((r) => (
|
||||
<SelectItem key={r} value={r}>{r}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={siteFilter} onValueChange={setSiteFilter}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All sites</SelectItem>
|
||||
{SITES.map((s) => (
|
||||
<SelectItem key={s} value={s}>{s}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={flagFilter} onValueChange={(v) => setFlagFilter(v as Flag | "all")}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All flags</SelectItem>
|
||||
<SelectItem value="ok">On track</SelectItem>
|
||||
<SelectItem value="overworked">Overworked</SelectItem>
|
||||
<SelectItem value="underworked">Underworked</SelectItem>
|
||||
<SelectItem value="absent">No hours</SelectItem>
|
||||
<SelectItem value="leave">On leave</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(siteFilter !== "all" || roleFilter !== "all" || flagFilter !== "all" || search) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSiteFilter("all");
|
||||
setRoleFilter("all");
|
||||
setFlagFilter("all");
|
||||
setSearch("");
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-lg border border-border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((hg) => (
|
||||
<TableRow key={hg.id}>
|
||||
{hg.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder ? null : (
|
||||
<button
|
||||
className="flex items-center gap-1"
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{{
|
||||
asc: <ChevronDown className="h-3 w-3" />,
|
||||
desc: <ChevronDown className="h-3 w-3 rotate-180" />,
|
||||
}[header.column.getIsSorted() as string] ?? null}
|
||||
</button>
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="py-10 text-center text-sm text-muted-foreground">
|
||||
No employees match the current filters.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<Pagination
|
||||
page={table.getState().pagination.pageIndex + 1}
|
||||
pageCount={table.getPageCount()}
|
||||
pageSize={pageSize}
|
||||
total={filtered.length}
|
||||
onPageChange={(p) => table.setPageIndex(p - 1)}
|
||||
onPageSizeChange={(s) => setPageSize(s)}
|
||||
showPageSize
|
||||
pageSizeOptions={[5, 8, 12, 25]}
|
||||
label="Employees"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,388 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Download,
|
||||
Fuel,
|
||||
ShieldAlert,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { Topbar } from "@/components/layout/sidebar";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
import { KpiCard } from "@/components/kpi-card";
|
||||
import { FuelTrendChart } from "@/components/charts/fuel-trend-chart";
|
||||
import { FuelByVehicleChart } from "@/components/charts/fuel-by-vehicle-chart";
|
||||
import { EMPLOYEES, FUEL_READINGS, VEHICLES } from "@/lib/mock-data";
|
||||
import { fuelCsvTemplate, parseFuelCsv } from "@/lib/csv";
|
||||
import {
|
||||
analyseFuel,
|
||||
consumptionPerHour,
|
||||
litresByPeriod,
|
||||
severityFor,
|
||||
vehiclesWithAnomalies,
|
||||
} from "@/lib/fuel";
|
||||
import type { FuelAnomaly } from "@/lib/types";
|
||||
import { cn, formatCurrency, formatNumber } from "@/lib/utils";
|
||||
|
||||
type Period = "day" | "week";
|
||||
|
||||
const ANOMALY_PAGE_SIZE = 10;
|
||||
const VEHICLE_PAGE_SIZE = 5;
|
||||
|
||||
export default function FuelPage() {
|
||||
const [period, setPeriod] = React.useState<Period>("day");
|
||||
const [vehicleFilter, setVehicleFilter] = React.useState<string>("all");
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [anomalyPage, setAnomalyPage] = React.useState(1);
|
||||
const [vehiclePage, setVehiclePage] = React.useState(1);
|
||||
const fileRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const anomalies = React.useMemo(() => analyseFuel(FUEL_READINGS, VEHICLES), []);
|
||||
const filteredReadings = React.useMemo(
|
||||
() =>
|
||||
FUEL_READINGS.filter((r) =>
|
||||
vehicleFilter === "all" ? true : r.vehicleId === vehicleFilter
|
||||
),
|
||||
[vehicleFilter]
|
||||
);
|
||||
const trend = React.useMemo(() => litresByPeriod(filteredReadings, period), [filteredReadings, period]);
|
||||
const perVehicle = React.useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const r of filteredReadings) {
|
||||
map.set(r.vehicleId, (map.get(r.vehicleId) ?? 0) + r.litresFilled);
|
||||
}
|
||||
return Array.from(map.entries()).map(([id, litres]) => ({
|
||||
vehicle: VEHICLES.find((v) => v.id === id)!,
|
||||
litres,
|
||||
}));
|
||||
}, [filteredReadings]);
|
||||
|
||||
const totalLitres = filteredReadings.reduce((s, r) => s + r.litresFilled, 0);
|
||||
const totalCost = filteredReadings.reduce((s, r) => s + r.litresFilled * r.unitPrice, 0);
|
||||
const totalHours = filteredReadings.reduce((s, r) => s + r.hoursWorked, 0);
|
||||
const flagged = anomalies.filter((a) => a.severity !== "ok");
|
||||
const perVehicleFlags = vehiclesWithAnomalies(VEHICLES, anomalies);
|
||||
|
||||
const filteredAnomalies = React.useMemo(
|
||||
() =>
|
||||
anomalies.filter((a) => {
|
||||
if (vehicleFilter !== "all" && a.vehicleId !== vehicleFilter) return false;
|
||||
if (search) {
|
||||
const v = VEHICLES.find((x) => x.id === a.vehicleId);
|
||||
if (!v) return false;
|
||||
const q = search.toLowerCase();
|
||||
if (!`${v.registration} ${v.type}`.toLowerCase().includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
[anomalies, vehicleFilter, search]
|
||||
);
|
||||
|
||||
// Reset pagination when filters change
|
||||
React.useEffect(() => {
|
||||
setAnomalyPage(1);
|
||||
}, [vehicleFilter, search]);
|
||||
|
||||
const anomalyPageCount = Math.max(1, Math.ceil(filteredAnomalies.length / ANOMALY_PAGE_SIZE));
|
||||
const anomalySlice = React.useMemo(
|
||||
() =>
|
||||
filteredAnomalies.slice(
|
||||
(anomalyPage - 1) * ANOMALY_PAGE_SIZE,
|
||||
anomalyPage * ANOMALY_PAGE_SIZE
|
||||
),
|
||||
[filteredAnomalies, anomalyPage]
|
||||
);
|
||||
|
||||
const vehiclePageCount = Math.max(1, Math.ceil(perVehicleFlags.length / VEHICLE_PAGE_SIZE));
|
||||
const vehicleSlice = React.useMemo(
|
||||
() =>
|
||||
perVehicleFlags.slice(
|
||||
(vehiclePage - 1) * VEHICLE_PAGE_SIZE,
|
||||
vehiclePage * VEHICLE_PAGE_SIZE
|
||||
),
|
||||
[perVehicleFlags, vehiclePage]
|
||||
);
|
||||
React.useEffect(() => {
|
||||
if (vehiclePage > vehiclePageCount) setVehiclePage(1);
|
||||
}, [vehiclePage, vehiclePageCount]);
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
// Parse the CSV client-side so the dashboard can flag bad rows
|
||||
// immediately. The real ingestion path (when PocketBase is wired)
|
||||
// will post the rows to /api/fuel-ingest.
|
||||
const text = await file.text();
|
||||
parseFuelCsv(text, VEHICLES);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
};
|
||||
|
||||
const downloadTemplate = () => {
|
||||
const blob = new Blob([fuelCsvTemplate()], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "fuel-readings-template.csv";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Topbar
|
||||
title="Fuel Tracking"
|
||||
subtitle="Per-vehicle consumption, theft detection and CSV ingest"
|
||||
right={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={downloadTemplate}>
|
||||
<Download className="h-4 w-4" />
|
||||
Template
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => fileRef.current?.click()}>
|
||||
<Upload className="h-4 w-4" />
|
||||
Upload CSV
|
||||
</Button>
|
||||
<input ref={fileRef} type="file" accept=".csv,text/csv" className="hidden" onChange={handleUpload} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-6 p-4 sm:p-6">
|
||||
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<KpiCard
|
||||
label="Total Litres"
|
||||
value={`${formatNumber(totalLitres)} L`}
|
||||
hint={`${filteredReadings.length} readings`}
|
||||
icon={Fuel}
|
||||
tone="warning"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Spend"
|
||||
value={formatCurrency(totalCost)}
|
||||
hint="Based on weighted unit price"
|
||||
icon={TrendingUp}
|
||||
tone="default"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Avg L/h"
|
||||
value={consumptionPerHour(filteredReadings).toFixed(2)}
|
||||
hint={`${formatNumber(totalHours)} hours worked`}
|
||||
icon={TrendingDown}
|
||||
tone="info"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Anomalies"
|
||||
value={`${flagged.length}`}
|
||||
delta={`${flagged.filter((a) => a.severity === "critical").length} critical`}
|
||||
trend={flagged.length > 0 ? "down" : "flat"}
|
||||
hint="Above 12% expected burn"
|
||||
icon={ShieldAlert}
|
||||
tone={flagged.length > 0 ? "danger" : "success"}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<CardTitle>Consumption over time</CardTitle>
|
||||
<CardDescription>Group by {period === "day" ? "day" : "week"} · {filteredReadings.length} readings</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select value={vehicleFilter} onValueChange={setVehicleFilter}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Vehicle" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All vehicles</SelectItem>
|
||||
{VEHICLES.map((v) => (
|
||||
<SelectItem key={v.id} value={v.id}>
|
||||
{v.registration} · {v.type}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Tabs value={period} onValueChange={(v) => setPeriod(v as Period)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="day">Day</TabsTrigger>
|
||||
<TabsTrigger value="week">Week</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FuelTrendChart data={trend} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Litres filled per vehicle</CardTitle>
|
||||
<CardDescription>Stack-ranked across the selected window</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FuelByVehicleChart data={perVehicle} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||
Per-vehicle anomaly count
|
||||
</CardTitle>
|
||||
<CardDescription>Critical / alert / watch</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<ul className="space-y-2">
|
||||
{vehicleSlice.map(({ vehicle, critical, alert, watch }) => {
|
||||
const total = critical + alert + watch;
|
||||
return (
|
||||
<li key={vehicle.id} className="flex items-center justify-between rounded-md border border-border p-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold">{vehicle.registration}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{vehicle.type} · {vehicle.assignedSite}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{critical > 0 && <Badge variant="danger">{critical} crit</Badge>}
|
||||
{alert > 0 && <Badge variant="warning">{alert} alert</Badge>}
|
||||
{watch > 0 && <Badge variant="info">{watch} watch</Badge>}
|
||||
{total === 0 && <Badge variant="success">OK</Badge>}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<Pagination
|
||||
page={vehiclePage}
|
||||
pageCount={vehiclePageCount}
|
||||
pageSize={VEHICLE_PAGE_SIZE}
|
||||
total={perVehicleFlags.length}
|
||||
onPageChange={setVehiclePage}
|
||||
label="Vehicles"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Anomaly log</CardTitle>
|
||||
<CardDescription>
|
||||
Expected burn = hoursWorked × expected L/h · tolerance {12}% · flagged when over
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Input
|
||||
placeholder="Search registration or type…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="sm:max-w-xs"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{filteredAnomalies.length} entries</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Vehicle</TableHead>
|
||||
<TableHead className="text-right">Expected L</TableHead>
|
||||
<TableHead className="text-right">Filled L</TableHead>
|
||||
<TableHead className="text-right">Δ</TableHead>
|
||||
<TableHead>Severity</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{anomalySlice.map((a) => (
|
||||
<AnomalyRow key={a.readingId} anomaly={a} />
|
||||
))}
|
||||
{anomalySlice.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-10 text-center text-sm text-muted-foreground">
|
||||
No anomalies match your filters.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<Pagination
|
||||
page={anomalyPage}
|
||||
pageCount={anomalyPageCount}
|
||||
pageSize={ANOMALY_PAGE_SIZE}
|
||||
total={filteredAnomalies.length}
|
||||
onPageChange={setAnomalyPage}
|
||||
showPageSize
|
||||
pageSizeOptions={[5, 10, 25, 50]}
|
||||
label="Anomalies"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AnomalyRow({ anomaly }: { anomaly: FuelAnomaly }) {
|
||||
const v = VEHICLES.find((x) => x.id === anomaly.vehicleId)!;
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">{format(parseISO(anomaly.date), "d MMM")}</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-medium">{v.registration}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{v.type}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{anomaly.expectedLitres}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{anomaly.actualLitres}</TableCell>
|
||||
<TableCell
|
||||
className={cn(
|
||||
"text-right tabular-nums font-semibold",
|
||||
anomaly.deltaLitres > 0 ? "text-destructive" : "text-emerald-600"
|
||||
)}
|
||||
>
|
||||
{anomaly.deltaLitres > 0 ? "+" : ""}{anomaly.deltaLitres} L
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
anomaly.severity === "critical"
|
||||
? "danger"
|
||||
: anomaly.severity === "alert"
|
||||
? "warning"
|
||||
: anomaly.severity === "watch"
|
||||
? "info"
|
||||
: "success"
|
||||
}
|
||||
>
|
||||
{anomaly.severity}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,605 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type SortingState,
|
||||
type ColumnFiltersState,
|
||||
} from "@tanstack/react-table";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import {
|
||||
ChevronDown,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
Search,
|
||||
X,
|
||||
Upload,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Truck,
|
||||
AlertCircle,
|
||||
PackageX,
|
||||
} from "lucide-react";
|
||||
import { Topbar } from "@/components/layout/sidebar";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { KpiCard } from "@/components/kpi-card";
|
||||
import { OrdersStatusChart } from "@/components/charts/orders-status-chart";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
import { ORDERS } from "@/lib/mock-data";
|
||||
import type { Order, OrderStatus, OrderPriority } from "@/lib/types";
|
||||
import { cn, formatCurrency } from "@/lib/utils";
|
||||
|
||||
const STATUS_OPTIONS: OrderStatus[] = ["Awaiting", "Partial", "Delivered", "Discrepancy", "Cancelled"];
|
||||
const PRIORITY_OPTIONS: OrderPriority[] = ["Low", "Normal", "High", "Urgent"];
|
||||
|
||||
const statusVariant: Record<OrderStatus, "info" | "warning" | "success" | "danger" | "secondary"> = {
|
||||
Awaiting: "info",
|
||||
Partial: "warning",
|
||||
Delivered: "success",
|
||||
Discrepancy: "danger",
|
||||
Cancelled: "secondary",
|
||||
};
|
||||
|
||||
const statusIcon = (s: OrderStatus) => {
|
||||
switch (s) {
|
||||
case "Delivered": return <CheckCircle2 className="h-3 w-3" />;
|
||||
case "Awaiting": return <Clock className="h-3 w-3" />;
|
||||
case "Partial": return <Truck className="h-3 w-3" />;
|
||||
case "Discrepancy": return <AlertCircle className="h-3 w-3" />;
|
||||
case "Cancelled": return <PackageX className="h-3 w-3" />;
|
||||
}
|
||||
};
|
||||
|
||||
export default function OrdersPage() {
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [statusFilter, setStatusFilter] = React.useState<OrderStatus | "all">("all");
|
||||
const [priorityFilter, setPriorityFilter] = React.useState<OrderPriority | "all">("all");
|
||||
const [siteFilter, setSiteFilter] = React.useState<string>("all");
|
||||
const [open, setOpen] = React.useState<Order | null>(null);
|
||||
const [sorting, setSorting] = React.useState<SortingState>([{ id: "placedAt", desc: true }]);
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
|
||||
|
||||
const sites = React.useMemo(
|
||||
() => Array.from(new Set(ORDERS.map((o) => o.site))).sort(),
|
||||
[]
|
||||
);
|
||||
|
||||
const filtered = React.useMemo(
|
||||
() =>
|
||||
ORDERS.filter((o) => {
|
||||
if (statusFilter !== "all" && o.status !== statusFilter) return false;
|
||||
if (priorityFilter !== "all" && o.priority !== priorityFilter) return false;
|
||||
if (siteFilter !== "all" && o.site !== siteFilter) return false;
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
if (
|
||||
!`${o.reference} ${o.supplier} ${o.site}`.toLowerCase().includes(q)
|
||||
)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
[search, statusFilter, priorityFilter, siteFilter]
|
||||
);
|
||||
|
||||
const columns = React.useMemo<ColumnDef<Order>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "reference",
|
||||
header: "Reference",
|
||||
cell: ({ row }) => (
|
||||
<button
|
||||
onClick={() => setOpen(row.original)}
|
||||
className="text-left font-semibold text-foreground hover:text-primary"
|
||||
>
|
||||
{row.original.reference}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{ accessorKey: "supplier", header: "Supplier" },
|
||||
{ accessorKey: "site", header: "Site" },
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={statusVariant[row.original.status]} className="gap-1">
|
||||
{statusIcon(row.original.status)}
|
||||
{row.original.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "priority",
|
||||
header: "Priority",
|
||||
meta: { hideOnMobile: true },
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={
|
||||
row.original.priority === "Urgent"
|
||||
? "danger"
|
||||
: row.original.priority === "High"
|
||||
? "warning"
|
||||
: row.original.priority === "Normal"
|
||||
? "info"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{row.original.priority}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "placedAt",
|
||||
header: "Placed",
|
||||
cell: ({ row }) => format(parseISO(row.original.placedAt), "d MMM"),
|
||||
},
|
||||
{
|
||||
accessorKey: "expectedAt",
|
||||
header: "Expected",
|
||||
meta: { hideOnMobile: true },
|
||||
cell: ({ row }) => {
|
||||
const overdue =
|
||||
new Date(row.original.expectedAt) < new Date() &&
|
||||
row.original.status !== "Delivered" &&
|
||||
row.original.status !== "Cancelled";
|
||||
return (
|
||||
<span className={cn(overdue && "font-semibold text-destructive")}>
|
||||
{format(parseISO(row.original.expectedAt), "d MMM")}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "outstanding",
|
||||
header: "Outstanding",
|
||||
cell: ({ row }) => {
|
||||
const outstanding = row.original.lines.filter((l) => l.quantityDelivered < l.quantityOrdered);
|
||||
if (outstanding.length === 0)
|
||||
return <span className="text-xs text-muted-foreground">—</span>;
|
||||
return (
|
||||
<span className="text-xs font-semibold text-amber-600">
|
||||
{outstanding.length} of {row.original.lines.length}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "totalValue",
|
||||
header: () => <div className="text-right">Value</div>,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right font-medium tabular-nums">{formatCurrency(row.original.totalValue)}</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: filtered,
|
||||
columns,
|
||||
state: { sorting, columnFilters },
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
initialState: { pagination: { pageSize: 8 } },
|
||||
});
|
||||
|
||||
const stats = React.useMemo(() => {
|
||||
const total = ORDERS.length;
|
||||
const open = ORDERS.filter((o) => o.status !== "Delivered" && o.status !== "Cancelled").length;
|
||||
const overdue = ORDERS.filter(
|
||||
(o) => o.status !== "Delivered" && o.status !== "Cancelled" && new Date(o.expectedAt) < new Date()
|
||||
).length;
|
||||
const totalValue = ORDERS.reduce((s, o) => s + o.totalValue, 0);
|
||||
const outstandingLines = ORDERS.reduce(
|
||||
(s, o) => s + o.lines.filter((l) => l.quantityDelivered < l.quantityOrdered).length,
|
||||
0
|
||||
);
|
||||
return { total, open, overdue, totalValue, outstandingLines };
|
||||
}, []);
|
||||
|
||||
const statusBreakdown = React.useMemo(() => {
|
||||
const palette: Record<OrderStatus, string> = {
|
||||
Delivered: "#4f9c5b",
|
||||
Partial: "#fdb13c",
|
||||
Awaiting: "#006672",
|
||||
Discrepancy: "#ef4444",
|
||||
Cancelled: "#a3a3a3",
|
||||
};
|
||||
return STATUS_OPTIONS.map((s) => ({
|
||||
name: s,
|
||||
value: ORDERS.filter((o) => o.status === s).length,
|
||||
color: palette[s],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Topbar
|
||||
title="Orders & Delivery"
|
||||
subtitle="Track purchase orders, scan delivery notes, reconcile outstanding items"
|
||||
right={
|
||||
<Button size="sm">
|
||||
<Upload className="h-4 w-4" />
|
||||
New order
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-6 p-4 sm:p-6">
|
||||
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<KpiCard label="Total orders" value={`${stats.total}`} hint={`${formatCurrency(stats.totalValue)} value`} />
|
||||
<KpiCard
|
||||
label="Open"
|
||||
value={`${stats.open}`}
|
||||
delta={stats.overdue > 0 ? `${stats.overdue} overdue` : undefined}
|
||||
trend={stats.overdue > 0 ? "down" : "flat"}
|
||||
hint="Awaiting delivery or reconciliation"
|
||||
tone="info"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Outstanding lines"
|
||||
value={`${stats.outstandingLines}`}
|
||||
hint="Across all partial orders"
|
||||
tone="warning"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Status mix"
|
||||
value={`${statusBreakdown.filter((s) => s.value > 0).length} states`}
|
||||
hint="See donut for distribution"
|
||||
tone="default"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between">
|
||||
<div>
|
||||
<CardTitle>Status mix</CardTitle>
|
||||
<CardDescription>By count</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<div className="md:col-span-1">
|
||||
<OrdersStatusChart data={statusBreakdown} />
|
||||
</div>
|
||||
<ul className="md:col-span-2 space-y-2 self-center">
|
||||
{statusBreakdown.map((s) => (
|
||||
<li key={s.name} className="flex items-center justify-between rounded-md border border-border p-3">
|
||||
<span className="flex items-center gap-2 text-sm">
|
||||
<span className="h-3 w-3 rounded-sm" style={{ background: s.color }} />
|
||||
{s.name}
|
||||
</span>
|
||||
<span className="text-sm font-semibold">{s.value}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<CardTitle>Purchase orders</CardTitle>
|
||||
<CardDescription>
|
||||
{filtered.length} of {ORDERS.length} orders match the current filters
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search ref, supplier, site…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-56 pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as OrderStatus | "all")}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<SelectItem key={s} value={s}>{s}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={priorityFilter} onValueChange={(v) => setPriorityFilter(v as OrderPriority | "all")}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All priorities</SelectItem>
|
||||
{PRIORITY_OPTIONS.map((p) => (
|
||||
<SelectItem key={p} value={p}>{p}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={siteFilter} onValueChange={setSiteFilter}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All sites</SelectItem>
|
||||
{sites.map((s) => (
|
||||
<SelectItem key={s} value={s}>{s}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(statusFilter !== "all" || priorityFilter !== "all" || siteFilter !== "all" || search) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setStatusFilter("all");
|
||||
setPriorityFilter("all");
|
||||
setSiteFilter("all");
|
||||
setSearch("");
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-lg border border-border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((hg) => (
|
||||
<TableRow key={hg.id}>
|
||||
{hg.headers.map((header) => {
|
||||
const hideOnMobile = (header.column.columnDef.meta as { hideOnMobile?: boolean } | undefined)?.hideOnMobile;
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={cn(hideOnMobile && "hidden md:table-cell")}
|
||||
>
|
||||
{header.isPlaceholder ? null : (
|
||||
<button
|
||||
className="flex items-center gap-1"
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{{
|
||||
asc: <ChevronDown className="h-3 w-3" />,
|
||||
desc: <ChevronDown className="h-3 w-3 rotate-180" />,
|
||||
}[header.column.getIsSorted() as string] ?? null}
|
||||
</button>
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="py-10 text-center text-sm text-muted-foreground">
|
||||
No orders match the current filters.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const hideOnMobile = (cell.column.columnDef.meta as { hideOnMobile?: boolean } | undefined)?.hideOnMobile;
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={cn(hideOnMobile && "hidden md:table-cell")}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<Pagination
|
||||
page={table.getState().pagination.pageIndex + 1}
|
||||
pageCount={table.getPageCount()}
|
||||
pageSize={table.getState().pagination.pageSize}
|
||||
total={filtered.length}
|
||||
onPageChange={(p) => table.setPageIndex(p - 1)}
|
||||
onPageSizeChange={(s) => table.setPageSize(s)}
|
||||
showPageSize
|
||||
pageSizeOptions={[5, 8, 12, 25]}
|
||||
label="Orders"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<OrderDetailDialog order={open} onClose={() => setOpen(null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function OrderDetailDialog({ order, onClose }: { order: Order | null; onClose: () => void }) {
|
||||
if (!order) return null;
|
||||
const outstanding = order.lines.filter((l) => l.quantityDelivered < l.quantityOrdered);
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={statusVariant[order.status]} className="gap-1">
|
||||
{statusIcon(order.status)}
|
||||
{order.status}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={
|
||||
order.priority === "Urgent"
|
||||
? "danger"
|
||||
: order.priority === "High"
|
||||
? "warning"
|
||||
: order.priority === "Normal"
|
||||
? "info"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{order.priority}
|
||||
</Badge>
|
||||
</div>
|
||||
<DialogTitle>{order.reference}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{order.supplier} · {order.site}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Placed</p>
|
||||
<p className="font-medium">{format(parseISO(order.placedAt), "d MMM yyyy, HH:mm")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Expected</p>
|
||||
<p className="font-medium">{format(parseISO(order.expectedAt), "d MMM yyyy")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Value</p>
|
||||
<p className="font-medium">{formatCurrency(order.totalValue)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Notes</p>
|
||||
<p className="font-medium">{order.notes ?? "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Line items</p>
|
||||
<div className="rounded-lg border border-border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead className="text-right">Ordered</TableHead>
|
||||
<TableHead className="text-right">Delivered</TableHead>
|
||||
<TableHead className="text-right">Outstanding</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{order.lines.map((l) => {
|
||||
const oos = l.quantityOrdered - l.quantityDelivered;
|
||||
return (
|
||||
<TableRow key={l.id}>
|
||||
<TableCell>
|
||||
<span className="font-medium">{l.description}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
@ {formatCurrency(l.unitPrice)}/{l.unit}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{l.quantityOrdered} {l.unit}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{l.quantityDelivered} {l.unit}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn(
|
||||
"text-right tabular-nums font-semibold",
|
||||
oos > 0 ? "text-amber-600" : "text-emerald-600"
|
||||
)}
|
||||
>
|
||||
{oos > 0 ? `${oos} ${l.unit}` : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Delivery notes ({order.deliveryNotes.length})
|
||||
</p>
|
||||
{order.deliveryNotes.length === 0 ? (
|
||||
<p className="rounded-md border border-dashed border-border p-4 text-center text-xs text-muted-foreground">
|
||||
No delivery notes captured yet.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{order.deliveryNotes.map((n) => (
|
||||
<li key={n.id} className="flex items-center justify-between rounded-md border border-border p-3 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex h-9 w-9 items-center justify-center rounded-md bg-accent text-accent-foreground">
|
||||
{n.mimeType.startsWith("image/") ? <ImageIcon className="h-4 w-4" /> : <FileText className="h-4 w-4" />}
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium">{n.fileName}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Scanned by {n.scannedBy} · {format(parseISO(n.receivedAt), "d MMM, HH:mm")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm">View</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{outstanding.length > 0 && (
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-300">
|
||||
<strong>{outstanding.length} line(s)</strong> still outstanding at end of day.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>Close</Button>
|
||||
<Button>
|
||||
<Upload className="h-4 w-4" />
|
||||
Upload delivery note
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,468 +1,219 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { format, parseISO, subDays } from "date-fns";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Banknote,
|
||||
Clock,
|
||||
Flame,
|
||||
Fuel,
|
||||
PackageCheck,
|
||||
TrendingUp,
|
||||
Building2,
|
||||
ClipboardCheck,
|
||||
Truck,
|
||||
Users,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { Topbar } from "@/components/layout/sidebar";
|
||||
import { KpiCard } from "@/components/kpi-card";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { FuelTrendChart } from "@/components/charts/fuel-trend-chart";
|
||||
import { WorkforceTrendBars } from "@/components/charts/workforce-trend-bars";
|
||||
import { OrdersStatusChart } from "@/components/charts/orders-status-chart";
|
||||
import { OrderValueTrendChart } from "@/components/charts/order-value-trend-chart";
|
||||
import { EMPLOYEES, FUEL_READINGS, ORDERS, PG_EXPENSES, PLANT_HIRE_RATES, VEHICLES } from "@/lib/mock-data";
|
||||
import {
|
||||
analyseFuel,
|
||||
consumptionPerHour,
|
||||
litresByPeriod,
|
||||
vehiclesWithAnomalies,
|
||||
} from "@/lib/fuel";
|
||||
import type { Order } from "@/lib/types";
|
||||
import { pbAdmin } from "@/lib/pb";
|
||||
import { formatCurrency, formatNumber } from "@/lib/utils";
|
||||
import { burnBreakdown } from "@/lib/burn";
|
||||
import { Topbar } from "@/components/layout/sidebar";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type {
|
||||
EmployeeRecord,
|
||||
FixedCostRecord,
|
||||
PlantRecord,
|
||||
SiteRecord,
|
||||
VehicleRecord,
|
||||
} from "@/lib/pb-types";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const today = React.useMemo(() => new Date(), []);
|
||||
const last7 = React.useMemo(
|
||||
() => format(subDays(today, 6), "yyyy-MM-dd"),
|
||||
[today]
|
||||
);
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const last7Readings = React.useMemo(
|
||||
() => FUEL_READINGS.filter((r) => r.date >= last7),
|
||||
[last7]
|
||||
);
|
||||
const prior7Readings = React.useMemo(
|
||||
() => FUEL_READINGS.filter((r) => r.date < last7 && r.date >= format(subDays(today, 13), "yyyy-MM-dd")),
|
||||
[today]
|
||||
);
|
||||
async function loadOverview() {
|
||||
const pb = await pbAdmin();
|
||||
|
||||
const totalLitres7 = last7Readings.reduce((s, r) => s + r.litresFilled, 0);
|
||||
const priorLitres7 = prior7Readings.reduce((s, r) => s + r.litresFilled, 0);
|
||||
const totalCost7 = last7Readings.reduce((s, r) => s + r.litresFilled * r.unitPrice, 0);
|
||||
const [sites, employees, vehicles, plant, fixedCosts, poUnverified, dnUnverified] =
|
||||
await Promise.all([
|
||||
pb.collection("sites").getList<SiteRecord>(1, 50, { sort: "name" }),
|
||||
pb.collection("employees").getList<EmployeeRecord>(1, 1),
|
||||
pb.collection("vehicles").getList<VehicleRecord>(1, 1),
|
||||
pb.collection("plant").getList<PlantRecord>(1, 1),
|
||||
pb.collection("fixed_costs").getList<FixedCostRecord>(1, 500),
|
||||
pb.collection("purchase_orders").getList(1, 1, {
|
||||
filter: "is_verified=false",
|
||||
}),
|
||||
pb.collection("delivery_notes").getList(1, 1, {
|
||||
filter: "is_verified=false",
|
||||
}),
|
||||
]);
|
||||
|
||||
const anomalies = React.useMemo(() => analyseFuel(FUEL_READINGS, VEHICLES), []);
|
||||
const flagged = anomalies.filter((a) => a.severity === "alert" || a.severity === "critical");
|
||||
const watch = anomalies.filter((a) => a.severity === "watch");
|
||||
const permanent = employees.totalItems; // refined below
|
||||
const casuals = await pb
|
||||
.collection("employees")
|
||||
.getList<EmployeeRecord>(1, 1, { filter: "worker_type='casual'" });
|
||||
const permanents = await pb
|
||||
.collection("employees")
|
||||
.getList<EmployeeRecord>(1, 1, { filter: "worker_type='permanent'" });
|
||||
|
||||
const fuelTrend = React.useMemo(() => litresByPeriod(last7Readings, "day"), [last7Readings]);
|
||||
const monthlyFixedCost = fixedCosts.items
|
||||
.filter((c) => c.date_logged.startsWith(formatYearMonth(new Date())))
|
||||
.reduce((sum, c) => sum + (c.cost || 0), 0);
|
||||
|
||||
const ordersByStatus = React.useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const o of ORDERS) map.set(o.status, (map.get(o.status) ?? 0) + 1);
|
||||
const palette: Record<string, string> = {
|
||||
Delivered: "#4f9c5b",
|
||||
Partial: "#fdb13c",
|
||||
Awaiting: "#006672",
|
||||
Discrepancy: "#ef4444",
|
||||
Cancelled: "#a3a3a3",
|
||||
};
|
||||
return Array.from(map.entries()).map(([name, value]) => ({ name, value, color: palette[name] ?? "#fdb13c" }));
|
||||
}, []);
|
||||
const costBySite = new Map<string, number>();
|
||||
for (const c of fixedCosts.items) {
|
||||
if (!c.date_logged.startsWith(formatYearMonth(new Date()))) continue;
|
||||
costBySite.set(c.site, (costBySite.get(c.site) ?? 0) + (c.cost || 0));
|
||||
}
|
||||
|
||||
const overdueOrders = ORDERS.filter(
|
||||
(o) => o.status !== "Delivered" && o.status !== "Cancelled" && new Date(o.expectedAt) < today
|
||||
);
|
||||
return {
|
||||
sites: sites.items,
|
||||
employees: {
|
||||
total: employees.totalItems,
|
||||
permanent: permanents.totalItems,
|
||||
casual: casuals.totalItems,
|
||||
},
|
||||
vehicles: vehicles.totalItems,
|
||||
plant: plant.totalItems,
|
||||
fixedCosts: {
|
||||
total: fixedCosts.totalItems,
|
||||
monthly: monthlyFixedCost,
|
||||
bySite: costBySite,
|
||||
},
|
||||
pendingVerification: {
|
||||
purchaseOrders: poUnverified.totalItems,
|
||||
deliveryNotes: dnUnverified.totalItems,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- Order financial metrics ----------
|
||||
const orderFinancials = React.useMemo(() => {
|
||||
const open = (o: Order) => o.status !== "Delivered" && o.status !== "Cancelled";
|
||||
const overdue = (o: Order) =>
|
||||
open(o) && new Date(o.expectedAt) < today;
|
||||
function formatYearMonth(d: Date) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
const totalValue = ORDERS.reduce((s, o) => s + o.totalValue, 0);
|
||||
const openValue = ORDERS.filter(open).reduce((s, o) => s + o.totalValue, 0);
|
||||
const overdueValue = ORDERS.filter(overdue).reduce((s, o) => s + o.totalValue, 0);
|
||||
const deliveredValue = ORDERS.filter((o) => o.status === "Delivered").reduce(
|
||||
(s, o) => s + o.totalValue,
|
||||
0
|
||||
);
|
||||
export default async function DashboardPage() {
|
||||
const data = await loadOverview();
|
||||
|
||||
// Line-level delivery rate across the whole order book
|
||||
const lines = ORDERS.flatMap((o) => o.lines);
|
||||
const totalOrdered = lines.reduce((s, l) => s + l.quantityOrdered, 0);
|
||||
const totalDelivered = lines.reduce((s, l) => s + l.quantityDelivered, 0);
|
||||
const completionPct = totalOrdered === 0 ? 0 : (totalDelivered / totalOrdered) * 100;
|
||||
|
||||
// Outstanding line value (only lines still missing delivery, by unit price)
|
||||
const outstandingLinesValue = lines.reduce(
|
||||
(s, l) => s + Math.max(0, l.quantityOrdered - l.quantityDelivered) * l.unitPrice,
|
||||
0
|
||||
);
|
||||
|
||||
return {
|
||||
totalValue,
|
||||
openValue,
|
||||
overdueValue,
|
||||
deliveredValue,
|
||||
completionPct,
|
||||
outstandingLinesValue,
|
||||
totalOrdered,
|
||||
totalDelivered,
|
||||
};
|
||||
}, [today]);
|
||||
|
||||
// Daily order value trend (last 14 days) split by current status
|
||||
const orderValueTrend = React.useMemo(() => {
|
||||
const windowStart = format(subDays(today, 13), "yyyy-MM-dd");
|
||||
const map = new Map<string, { date: string; delivered: number; outstanding: number; overdue: number }>();
|
||||
for (let i = 13; i >= 0; i--) {
|
||||
const d = format(subDays(today, i), "yyyy-MM-dd");
|
||||
map.set(d, { date: d, delivered: 0, outstanding: 0, overdue: 0 });
|
||||
}
|
||||
for (const o of ORDERS) {
|
||||
const placed = format(parseISO(o.placedAt), "yyyy-MM-dd");
|
||||
if (placed < windowStart) continue;
|
||||
const bucket = map.get(placed);
|
||||
if (!bucket) continue;
|
||||
if (o.status === "Delivered") bucket.delivered += o.totalValue;
|
||||
else if (new Date(o.expectedAt) < today) bucket.overdue += o.totalValue;
|
||||
else bucket.outstanding += o.totalValue;
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}, [today]);
|
||||
|
||||
const employeesThisWeek = EMPLOYEES;
|
||||
const totalWeeklyHours = employeesThisWeek.reduce((s, e) => s + e.weeklyHours, 0);
|
||||
const overworked = employeesThisWeek.filter((e) => e.weeklyOvertimeHours > 6).length;
|
||||
const absent = employeesThisWeek.filter((e) => e.dailyHours.slice(1).every((d) => d.hours === 0)).length;
|
||||
|
||||
const avgConsumption = consumptionPerHour(last7Readings);
|
||||
const priorConsumption = consumptionPerHour(prior7Readings);
|
||||
const consumptionDelta = priorConsumption === 0 ? 0 : ((avgConsumption - priorConsumption) / priorConsumption) * 100;
|
||||
|
||||
// Pick the top-1 critical anomaly for the "needs attention" rail
|
||||
const topFlag = flagged[0] ?? watch[0];
|
||||
const tiles = [
|
||||
{
|
||||
label: "Sites",
|
||||
value: formatNumber(data.sites.length),
|
||||
sub: data.sites[0]?.name ?? "No sites yet",
|
||||
icon: Building2,
|
||||
},
|
||||
{
|
||||
label: "Employees",
|
||||
value: formatNumber(data.employees.total),
|
||||
sub: `${data.employees.permanent} permanent · ${data.employees.casual} casual`,
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
label: "Vehicles",
|
||||
value: formatNumber(data.vehicles),
|
||||
sub: "On site",
|
||||
icon: Truck,
|
||||
},
|
||||
{
|
||||
label: "Plant",
|
||||
value: formatNumber(data.plant),
|
||||
sub: "Items on site",
|
||||
icon: Wrench,
|
||||
},
|
||||
{
|
||||
label: "Fixed costs (this month)",
|
||||
value: formatCurrency(data.fixedCosts.monthly),
|
||||
sub: `${data.fixedCosts.total} entries total`,
|
||||
icon: Banknote,
|
||||
},
|
||||
{
|
||||
label: "Pending verification",
|
||||
value: formatNumber(
|
||||
data.pendingVerification.purchaseOrders + data.pendingVerification.deliveryNotes
|
||||
),
|
||||
sub: `${data.pendingVerification.purchaseOrders} PO · ${data.pendingVerification.deliveryNotes} DN`,
|
||||
icon: ClipboardCheck,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Topbar
|
||||
title="Operations Overview"
|
||||
subtitle={`Snapshot for ${format(today, "EEEE, d MMMM yyyy")}`}
|
||||
right={
|
||||
<div className="hidden items-center gap-2 text-xs text-muted-foreground sm:flex">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
Last CSV ingest: 2h ago
|
||||
</div>
|
||||
}
|
||||
title="Overview"
|
||||
subtitle="Live data from the MEGA INFRASTRUCTURE PocketBase."
|
||||
/>
|
||||
|
||||
<div className="space-y-6 p-4 sm:p-6">
|
||||
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<KpiCard
|
||||
label="Fuel (7d)"
|
||||
value={`${formatNumber(totalLitres7)} L`}
|
||||
delta={
|
||||
priorLitres7 === 0
|
||||
? undefined
|
||||
: `${(((totalLitres7 - priorLitres7) / priorLitres7) * 100).toFixed(1)}%`
|
||||
}
|
||||
trend={totalLitres7 >= priorLitres7 ? "up" : "down"}
|
||||
hint={
|
||||
<span className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5">
|
||||
<span className="inline-flex items-center gap-1 font-semibold text-destructive">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{flagged.length + watch.length} anomalies
|
||||
</span>
|
||||
<span>· {flagged.length} critical</span>
|
||||
<span>· R{formatNumber(totalCost7)} spent at R24.50/L</span>
|
||||
</span>
|
||||
}
|
||||
icon={Fuel}
|
||||
tone="warning"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Open Orders"
|
||||
value={`${ORDERS.filter((o) => o.status !== "Delivered" && o.status !== "Cancelled").length}`}
|
||||
delta={`${overdueOrders.length} overdue`}
|
||||
trend={overdueOrders.length > 0 ? "down" : "flat"}
|
||||
hint={`${ORDERS.length} total this period`}
|
||||
icon={PackageCheck}
|
||||
tone="info"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Order value (period)"
|
||||
value={formatCurrency(orderFinancials.totalValue)}
|
||||
hint={
|
||||
<span>
|
||||
{ORDERS.length} purchase orders · {formatCurrency(orderFinancials.openValue)} outstanding
|
||||
</span>
|
||||
}
|
||||
icon={Banknote}
|
||||
tone="default"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Workforce Hours"
|
||||
value={`${formatNumber(totalWeeklyHours)} h`}
|
||||
hint={`${employeesThisWeek.length} employees this week`}
|
||||
icon={Users}
|
||||
tone="default"
|
||||
/>
|
||||
<section
|
||||
aria-label="Key metrics"
|
||||
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
{tiles.map((t) => {
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<Card key={t.label}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t.label}
|
||||
</CardTitle>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold tracking-tight">{t.value}</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t.sub}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader className="flex flex-row items-start justify-between">
|
||||
<div>
|
||||
<CardTitle>Order value flow — last 14 days</CardTitle>
|
||||
<CardDescription>
|
||||
Daily value of purchase orders placed, stacked by current status.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Link href="/dashboard/orders" className="text-xs font-medium text-primary hover:underline">
|
||||
Open orders →
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<OrderValueTrendChart data={orderValueTrend} formatValue={formatCurrency} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section aria-label="Sites">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Banknote className="h-4 w-4 text-primary" />
|
||||
Money in flight
|
||||
</CardTitle>
|
||||
<CardDescription>Where the open spend is sitting</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="rounded-lg border border-border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Total open</span>
|
||||
<Badge variant="warning">{ORDERS.filter((o) => o.status !== "Delivered" && o.status !== "Cancelled").length}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">{formatCurrency(orderFinancials.openValue)}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Value of orders not yet fully delivered</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Past expected</span>
|
||||
<Badge variant={orderFinancials.overdueValue > 0 ? "danger" : "success"}>{overdueOrders.length}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">{formatCurrency(orderFinancials.overdueValue)}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Already past the supplier's expected delivery date</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Missing units</span>
|
||||
<Badge variant="info">{orderFinancials.totalOrdered - orderFinancials.totalDelivered} units</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">{formatCurrency(orderFinancials.outstandingLinesValue)}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Cost of line items still outstanding</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader className="flex flex-row items-start justify-between">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Fuel consumption — last 7 days</CardTitle>
|
||||
<CardDescription>
|
||||
Litres filled across all vehicles · Avg {avgConsumption.toFixed(1)} L/h
|
||||
{priorConsumption > 0 && (
|
||||
<span className={consumptionDelta > 5 ? "ml-1 text-destructive" : "ml-1 text-emerald-600"}>
|
||||
({consumptionDelta > 0 ? "+" : ""}{consumptionDelta.toFixed(1)}% vs prior week)
|
||||
</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Link href="/dashboard/fuel" className="text-xs font-medium text-primary hover:underline">
|
||||
Open fuel module →
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FuelTrendChart data={fuelTrend} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Orders by status</CardTitle>
|
||||
<CardDescription>{ORDERS.length} orders in the current period</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<OrdersStatusChart data={ordersByStatus} />
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{ordersByStatus.map((s) => (
|
||||
<li key={s.name} className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full" style={{ background: s.color }} />
|
||||
{s.name}
|
||||
</span>
|
||||
<span className="font-medium">{s.value}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<BurnPanel />
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader className="flex flex-row items-start justify-between">
|
||||
<div>
|
||||
<CardTitle>Workforce this week</CardTitle>
|
||||
<CardDescription>
|
||||
Hours logged across the team · {overworked} overworked · {absent} on leave
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Link href="/dashboard/employees" className="text-xs font-medium text-primary hover:underline">
|
||||
Open workforce →
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{employeesThisWeek.slice(0, 3).map((e) => (
|
||||
<div key={e.id} className="rounded-lg border border-border p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold">{e.fullName}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{e.role}</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
e.weeklyOvertimeHours > 6
|
||||
? "warning"
|
||||
: e.dailyHours.slice(1).every((d) => d.hours === 0)
|
||||
? "danger"
|
||||
: "success"
|
||||
}
|
||||
>
|
||||
{e.weeklyHours.toFixed(0)}h
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<WorkforceTrendBars data={e.dailyHours.slice(-7)} height={56} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Needs attention</CardTitle>
|
||||
<CardDescription>Live risk signals from across the dashboard</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{flagged.slice(0, 3).map((a) => {
|
||||
const v = VEHICLES.find((x) => x.id === a.vehicleId)!;
|
||||
return (
|
||||
<div key={a.readingId} className="flex items-start gap-3 rounded-lg border border-border p-3">
|
||||
<span className="mt-0.5 inline-flex h-8 w-8 items-center justify-center rounded-md bg-destructive/15 text-destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold">{v.registration} · {v.type}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{format(parseISO(a.date), "d MMM")} · {a.actualLitres}L filled vs {a.expectedLitres}L expected
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-medium text-destructive">
|
||||
+{a.expectedDeltaPct}% over tolerance — possible missing fuel
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{overdueOrders.slice(0, 2).map((o) => (
|
||||
<div key={o.id} className="flex items-start gap-3 rounded-lg border border-border p-3">
|
||||
<span className="mt-0.5 inline-flex h-8 w-8 items-center justify-center rounded-md bg-amber-500/15 text-amber-600">
|
||||
<PackageCheck className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold">{o.reference} · {o.supplier}</p>
|
||||
<p className="text-xs text-muted-foreground">Expected {format(parseISO(o.expectedAt), "d MMM")} · {o.site}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{topFlag && (
|
||||
<Link
|
||||
href="/dashboard/fuel"
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
Investigate fuel anomalies <ArrowRight className="h-3 w-3" />
|
||||
</Link>
|
||||
)}
|
||||
{!topFlag && overdueOrders.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed border-border p-4 text-center text-xs text-muted-foreground">
|
||||
All clear — no critical signals right now.
|
||||
<CardTitle>Sites</CardTitle>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Every site in the system. Click a row to drill in once
|
||||
detail pages are built.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Top suppliers (this period)</CardTitle>
|
||||
<CardDescription>By value of orders placed</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">{data.sites.length}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
{Array.from(
|
||||
ORDERS.reduce((m, o) => {
|
||||
const cur = m.get(o.supplier) ?? { count: 0, value: 0 };
|
||||
cur.count++;
|
||||
cur.value += o.totalValue;
|
||||
m.set(o.supplier, cur);
|
||||
return m;
|
||||
}, new Map<string, { count: number; value: number }>())
|
||||
)
|
||||
.sort((a, b) => b[1].value - a[1].value)
|
||||
.slice(0, 5)
|
||||
.map(([supplier, agg]) => (
|
||||
<li key={supplier} className="flex items-center justify-between rounded-lg border border-border p-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{supplier}</p>
|
||||
<p className="text-xs text-muted-foreground">{agg.count} orders</p>
|
||||
{data.sites.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No sites yet.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{data.sites.map((s) => (
|
||||
<li
|
||||
key={s.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 py-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{s.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{s.project_code} · {s.duration || "—"} ·{" "}
|
||||
{s.hours_per_week
|
||||
? `${s.hours_per_week} hrs/week`
|
||||
: "hours not set"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold">
|
||||
{s.contract_value
|
||||
? formatCurrency(s.contract_value)
|
||||
: "—"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Contract value
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold">
|
||||
{formatCurrency(
|
||||
data.fixedCosts.bySite.get(s.id) ?? 0
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Fixed costs / mo
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-bold">{formatCurrency(agg.value)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4 text-primary" />
|
||||
Quick wins
|
||||
</CardTitle>
|
||||
<CardDescription>Actions to clear the dashboard</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<Link href="/dashboard/fuel" className="flex items-center justify-between rounded-lg border border-border p-3 hover:bg-muted/50">
|
||||
<span>Review {flagged.length} fuel anomalies</span>
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
<Link href="/dashboard/orders" className="flex items-center justify-between rounded-lg border border-border p-3 hover:bg-muted/50">
|
||||
<span>Reconcile {overdueOrders.length} overdue orders</span>
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
<Link href="/dashboard/employees" className="flex items-center justify-between rounded-lg border border-border p-3 hover:bg-muted/50">
|
||||
<span>Check {absent + overworked} attendance flags</span>
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
|
@ -470,90 +221,3 @@ export default function DashboardPage() {
|
|||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BurnPanel() {
|
||||
const burn = React.useMemo(
|
||||
() => burnBreakdown(PLANT_HIRE_RATES, VEHICLES, PG_EXPENSES),
|
||||
[]
|
||||
);
|
||||
const contractValue = ORDERS.reduce((s, o) => s + o.totalValue, 0); // proxy for project size
|
||||
return (
|
||||
<section className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Flame className="h-4 w-4 text-primary" />
|
||||
Burn rate — weekly vs monthly
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Plant hire at 45h/week per unit + P&G monthly overheads, applied to every active unit on the project.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">This week</span>
|
||||
<Badge variant="warning">{formatNumber((burn.weekly / Math.max(1, contractValue)) * 100)}% of PO value</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-3xl font-bold tabular-nums">{formatCurrency(burn.weekly)}</p>
|
||||
<ul className="mt-3 space-y-1 text-xs text-muted-foreground">
|
||||
<li className="flex items-center justify-between">
|
||||
<span>Plant hire</span>
|
||||
<span className="tabular-nums text-foreground">{formatCurrency(burn.weeklyHire)}</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span>P&G (weekly share)</span>
|
||||
<span className="tabular-nums text-foreground">{formatCurrency(burn.weeklyPg)}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">This month</span>
|
||||
<Badge variant="warning">{formatNumber((burn.monthly / Math.max(1, contractValue)) * 100)}% of PO value</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-3xl font-bold tabular-nums">{formatCurrency(burn.monthly)}</p>
|
||||
<ul className="mt-3 space-y-1 text-xs text-muted-foreground">
|
||||
<li className="flex items-center justify-between">
|
||||
<span>Plant hire</span>
|
||||
<span className="tabular-nums text-foreground">{formatCurrency(burn.monthlyHire)}</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span>P&G (monthly)</span>
|
||||
<span className="tabular-nums text-foreground">{formatCurrency(burn.monthlyPg)}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Cost breakdown</CardTitle>
|
||||
<CardDescription>Where the burn comes from</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="flex items-center justify-between">
|
||||
<span>Plant hire (weekly × {VEHICLES.filter((v) => PLANT_HIRE_RATES.some((r) => r.vehicleId === v.id)).length} units)</span>
|
||||
<span className="tabular-nums font-medium">{formatCurrency(burn.weeklyHire)}</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span>P&G (monthly)</span>
|
||||
<span className="tabular-nums font-medium">{formatCurrency(burn.monthlyPg)}</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between border-t border-border pt-2">
|
||||
<span className="font-semibold">Total / month</span>
|
||||
<span className="tabular-nums font-semibold">{formatCurrency(burn.monthly)}</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span>Fuel (per L)</span>
|
||||
<span className="tabular-nums text-muted-foreground">R25.65</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,440 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { notFound, useRouter } from "next/navigation";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Banknote,
|
||||
Briefcase,
|
||||
Building2,
|
||||
Calendar,
|
||||
Clock,
|
||||
Flame,
|
||||
Fuel,
|
||||
Hammer,
|
||||
Hourglass,
|
||||
MapPin,
|
||||
Package,
|
||||
Truck,
|
||||
TrendingUp,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { Topbar } from "@/components/layout/sidebar";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { KpiCard } from "@/components/kpi-card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { FuelByVehicleChart } from "@/components/charts/fuel-by-vehicle-chart";
|
||||
import { WorkforceTrendBars } from "@/components/charts/workforce-trend-bars";
|
||||
import {
|
||||
EMPLOYEES,
|
||||
FUEL_READINGS,
|
||||
ORDERS,
|
||||
PG_EXPENSES,
|
||||
PLANT_HIRE_RATES,
|
||||
SITES_DATA,
|
||||
VEHICLES,
|
||||
} from "@/lib/mock-data";
|
||||
import { summarizeSite } from "@/lib/sites";
|
||||
import type { OrderStatus, OrderPriority } from "@/lib/types";
|
||||
import { cn, formatCurrency, formatNumber } from "@/lib/utils";
|
||||
|
||||
const STATUS_BADGE: Record<OrderStatus, "info" | "warning" | "success" | "danger" | "secondary"> = {
|
||||
Awaiting: "info",
|
||||
Partial: "warning",
|
||||
Delivered: "success",
|
||||
Discrepancy: "danger",
|
||||
Cancelled: "secondary",
|
||||
};
|
||||
|
||||
const PRIORITY_BADGE: Record<OrderPriority, "danger" | "warning" | "info" | "secondary"> = {
|
||||
Urgent: "danger",
|
||||
High: "warning",
|
||||
Normal: "info",
|
||||
Low: "secondary",
|
||||
};
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default function SiteDetailPage({ params }: PageProps) {
|
||||
const router = useRouter();
|
||||
const { id } = React.use(params);
|
||||
const site = SITES_DATA.find((s) => s.id === id);
|
||||
if (!site) notFound();
|
||||
|
||||
const summary = React.useMemo(
|
||||
() => summarizeSite(site, ORDERS, EMPLOYEES, VEHICLES, FUEL_READINGS, PLANT_HIRE_RATES, PG_EXPENSES),
|
||||
[site]
|
||||
);
|
||||
|
||||
const siteOrders = React.useMemo(
|
||||
() => ORDERS.filter((o) => o.site === site.name),
|
||||
[site.name]
|
||||
);
|
||||
const siteEmployees = React.useMemo(
|
||||
() => EMPLOYEES.filter((e) => e.site === site.name),
|
||||
[site.name]
|
||||
);
|
||||
const siteVehicles = React.useMemo(
|
||||
() => VEHICLES.filter((v) => v.assignedSite === site.name),
|
||||
[site.name]
|
||||
);
|
||||
const siteFuelData = React.useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const v of siteVehicles) map.set(v.id, 0);
|
||||
for (const r of FUEL_READINGS) {
|
||||
if (map.has(r.vehicleId)) {
|
||||
map.set(r.vehicleId, (map.get(r.vehicleId) ?? 0) + r.litresFilled);
|
||||
}
|
||||
}
|
||||
return siteVehicles.map((v) => ({ vehicle: v, litres: map.get(v.id) ?? 0 }));
|
||||
}, [siteVehicles]);
|
||||
|
||||
const burnTone =
|
||||
summary.burnPct >= 75 ? "danger" : summary.burnPct >= 40 ? "warning" : "default";
|
||||
const progressTone =
|
||||
site.progress >= 75 ? "success" : site.progress >= 30 ? "default" : "warning";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Topbar
|
||||
title={site.name}
|
||||
subtitle={`${site.client} · ${site.type} project`}
|
||||
right={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => router.push("/dashboard/sites")}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
All sites
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-6 p-4 sm:p-6">
|
||||
{/* Header card */}
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="inline-flex h-14 w-14 shrink-0 items-center justify-center rounded-xl bg-primary/15 text-primary">
|
||||
<Building2 className="h-7 w-7" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="success">{site.status}</Badge>
|
||||
<Badge variant="outline">{site.type}</Badge>
|
||||
</div>
|
||||
<h2 className="mt-2 text-2xl font-bold tracking-tight">{site.name}</h2>
|
||||
<p className="mt-1 flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<MapPin className="h-3.5 w-3.5" />
|
||||
{site.address}
|
||||
</p>
|
||||
<p className="mt-3 max-w-2xl text-sm text-muted-foreground">
|
||||
{site.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm lg:max-w-sm">
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wider text-muted-foreground">Client</dt>
|
||||
<dd className="font-medium">{site.client}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wider text-muted-foreground">Project manager</dt>
|
||||
<dd className="font-medium">{site.projectManager}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wider text-muted-foreground">Start date</dt>
|
||||
<dd className="font-medium tabular-nums">{format(parseISO(site.startDate), "d MMM yyyy")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wider text-muted-foreground">Expected end</dt>
|
||||
<dd className="font-medium tabular-nums">{format(parseISO(site.expectedEndDate), "d MMM yyyy")}</dd>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<dt className="text-xs uppercase tracking-wider text-muted-foreground">Headcount target</dt>
|
||||
<dd className="font-medium">{site.headcount} planned · {summary.workersActive} active</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Progress
|
||||
</span>
|
||||
<span className="text-sm font-bold tabular-nums">{site.progress}%</span>
|
||||
</div>
|
||||
<Progress value={site.progress} tone={progressTone} className="mt-2" />
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Started {format(parseISO(site.startDate), "MMM yyyy")} · expected completion {format(parseISO(site.expectedEndDate), "MMM yyyy")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Contract burn
|
||||
</span>
|
||||
<span className="text-sm font-bold tabular-nums">{summary.burnPct.toFixed(1)}%</span>
|
||||
</div>
|
||||
<Progress value={summary.burnPct} tone={burnTone} className="mt-2" />
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{formatCurrency(summary.spentToDate)} of {formatCurrency(site.contractValue)} committed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* KPIs */}
|
||||
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<KpiCard
|
||||
label="Contract value"
|
||||
value={formatCurrency(summary.contractValue)}
|
||||
hint={`Client: ${site.client}`}
|
||||
icon={Banknote}
|
||||
tone="default"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Spent to date"
|
||||
value={formatCurrency(summary.spentToDate)}
|
||||
delta={`${summary.burnPct.toFixed(1)}%`}
|
||||
trend={summary.burnPct >= 50 ? "up" : "flat"}
|
||||
hint={`${formatCurrency(summary.budgetRemaining)} budget remaining`}
|
||||
icon={Hourglass}
|
||||
tone={summary.burnPct >= 75 ? "danger" : "warning"}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Active orders"
|
||||
value={`${summary.activeOrdersCount}`}
|
||||
hint={`${formatCurrency(summary.activeOrdersValue)} open`}
|
||||
icon={Package}
|
||||
tone="info"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Delivered value"
|
||||
value={formatCurrency(summary.deliveredValue)}
|
||||
hint={`${formatCurrency(summary.totalOrdersValue)} total orders placed`}
|
||||
icon={TrendingUp}
|
||||
tone="success"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Hours this week"
|
||||
value={`${formatNumber(summary.hoursThisWeek)} h`}
|
||||
hint={`${summary.workersActive} active workers`}
|
||||
icon={Clock}
|
||||
tone="default"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Fuel this month"
|
||||
value={`${formatNumber(summary.fuelLitresThisMonth)} L`}
|
||||
hint={`${formatCurrency(summary.fuelCostThisMonth)} spend`}
|
||||
icon={Fuel}
|
||||
tone="warning"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Active orders */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-primary" />
|
||||
Orders at this site
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{siteOrders.length} orders · {summary.activeOrdersCount} still open
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Link href="/dashboard/orders" className="text-xs font-medium text-primary hover:underline">
|
||||
Open orders →
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{siteOrders.length === 0 ? (
|
||||
<p className="rounded-md border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
|
||||
No orders placed against this site yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Reference</TableHead>
|
||||
<TableHead>Supplier</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Priority</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Expected</TableHead>
|
||||
<TableHead className="text-right">Value</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{siteOrders.slice(0, 8).map((o) => {
|
||||
const overdue =
|
||||
new Date(o.expectedAt) < new Date() &&
|
||||
o.status !== "Delivered" &&
|
||||
o.status !== "Cancelled";
|
||||
return (
|
||||
<TableRow key={o.id}>
|
||||
<TableCell>
|
||||
<Link href="/dashboard/orders" className="font-semibold hover:text-primary">
|
||||
{o.reference}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{o.supplier}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={STATUS_BADGE[o.status]}>{o.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<Badge variant={PRIORITY_BADGE[o.priority]}>{o.priority}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell tabular-nums">
|
||||
<span className={cn(overdue && "font-semibold text-destructive")}>
|
||||
{format(parseISO(o.expectedAt), "d MMM")}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium tabular-nums">
|
||||
{formatCurrency(o.totalValue)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{siteOrders.length > 8 && (
|
||||
<div className="border-t border-border px-3 py-2 text-xs text-muted-foreground">
|
||||
Showing first 8 of {siteOrders.length} orders
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
{/* Fuel by vehicle */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Flame className="h-4 w-4 text-amber-500" />
|
||||
Fuel filled per vehicle on site
|
||||
</CardTitle>
|
||||
<CardDescription>{siteVehicles.length} vehicles assigned</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{siteFuelData.length === 0 ? (
|
||||
<p className="rounded-md border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
|
||||
No vehicles on site.
|
||||
</p>
|
||||
) : (
|
||||
<FuelByVehicleChart data={siteFuelData} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Workforce weekly hours */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-primary" />
|
||||
Workforce this week
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{summary.workersActive} active · {formatNumber(summary.hoursThisWeek)} h logged
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{siteEmployees.length === 0 ? (
|
||||
<p className="rounded-md border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
|
||||
No employees assigned to this site yet.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{siteEmployees.slice(0, 6).map((e) => (
|
||||
<li key={e.id} className="flex items-center gap-3 rounded-md border border-border p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold">{e.fullName}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{e.role}</p>
|
||||
</div>
|
||||
<div className="w-32 shrink-0">
|
||||
<WorkforceTrendBars
|
||||
data={e.dailyHours.slice(-7)}
|
||||
height={56}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-bold tabular-nums">{e.weeklyHours.toFixed(0)} h</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
of {e.weeklyTargetHours}h
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Vehicles */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Truck className="h-4 w-4 text-primary" />
|
||||
Vehicles on site
|
||||
</CardTitle>
|
||||
<CardDescription>{siteVehicles.length} units</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{siteVehicles.length === 0 ? (
|
||||
<p className="rounded-md border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
|
||||
No vehicles on site.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{siteVehicles.map((v) => {
|
||||
const fuel = siteFuelData.find((d) => d.vehicle.id === v.id)?.litres ?? 0;
|
||||
return (
|
||||
<div key={v.id} className="rounded-lg border border-border p-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="font-semibold">{v.registration}</p>
|
||||
<p className="text-xs text-muted-foreground">{v.type}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[10px]">{v.model}</Badge>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Tank</p>
|
||||
<p className="font-medium tabular-nums">{v.tankCapacityL} L</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Filled</p>
|
||||
<p className="font-medium tabular-nums">{formatNumber(fuel)} L</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,321 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import {
|
||||
Building2,
|
||||
ChevronRight,
|
||||
MapPin,
|
||||
Plus,
|
||||
Search,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { Topbar } from "@/components/layout/sidebar";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
EMPLOYEES,
|
||||
FUEL_READINGS,
|
||||
ORDERS,
|
||||
PG_EXPENSES,
|
||||
PLANT_HIRE_RATES,
|
||||
SITES_DATA,
|
||||
VEHICLES,
|
||||
} from "@/lib/mock-data";
|
||||
import { summarizeAllSites } from "@/lib/sites";
|
||||
import type { SiteStatus, SiteType } from "@/lib/types";
|
||||
import { cn, formatCurrency, formatNumber } from "@/lib/utils";
|
||||
|
||||
const STATUS_OPTIONS: SiteStatus[] = ["Active", "Mobilising", "On Hold", "Completed", "Warranty"];
|
||||
const TYPE_OPTIONS: SiteType[] = ["Road", "Port", "Reclamation", "Logistics", "Terminal"];
|
||||
|
||||
const statusVariant: Record<SiteStatus, "success" | "info" | "warning" | "secondary" | "default"> = {
|
||||
Active: "success",
|
||||
Mobilising: "info",
|
||||
"On Hold": "warning",
|
||||
Completed: "secondary",
|
||||
Warranty: "default",
|
||||
};
|
||||
|
||||
export default function SitesPage() {
|
||||
const router = useRouter();
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [statusFilter, setStatusFilter] = React.useState<SiteStatus | "all">("all");
|
||||
const [typeFilter, setTypeFilter] = React.useState<SiteType | "all">("all");
|
||||
|
||||
const summaries = React.useMemo(
|
||||
() => summarizeAllSites(SITES_DATA, ORDERS, EMPLOYEES, VEHICLES, FUEL_READINGS, PLANT_HIRE_RATES, PG_EXPENSES),
|
||||
[]
|
||||
);
|
||||
|
||||
const filtered = summaries.filter(({ site }) => {
|
||||
if (statusFilter !== "all" && site.status !== statusFilter) return false;
|
||||
if (typeFilter !== "all" && site.type !== typeFilter) return false;
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
if (
|
||||
!`${site.name} ${site.client} ${site.address}`.toLowerCase().includes(q)
|
||||
) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const totals = React.useMemo(() => {
|
||||
return summaries.reduce(
|
||||
(acc, s) => ({
|
||||
contract: acc.contract + s.contractValue,
|
||||
spent: acc.spent + s.spentToDate,
|
||||
activeValue: acc.activeValue + s.activeOrdersValue,
|
||||
hours: acc.hours + s.hoursThisWeek,
|
||||
workers: acc.workers + s.workersActive,
|
||||
}),
|
||||
{ contract: 0, spent: 0, activeValue: 0, hours: 0, workers: 0 }
|
||||
);
|
||||
}, [summaries]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Topbar
|
||||
title="Sites"
|
||||
subtitle={`${SITES_DATA.length} active and pipeline projects`}
|
||||
right={
|
||||
<Button size="sm">
|
||||
<Plus className="h-4 w-4" />
|
||||
New site
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-6 p-4 sm:p-6">
|
||||
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Contract value</p>
|
||||
<p className="mt-2 text-2xl font-bold tracking-tight">{formatCurrency(totals.contract)}</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">Total committed across {SITES_DATA.length} sites</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Spent to date</p>
|
||||
<p className="mt-2 text-2xl font-bold tracking-tight">{formatCurrency(totals.spent)}</p>
|
||||
<Progress
|
||||
value={(totals.spent / totals.contract) * 100}
|
||||
tone="warning"
|
||||
className="mt-3"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{totals.contract === 0 ? "0%" : `${((totals.spent / totals.contract) * 100).toFixed(1)}%`} of total contract value
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Active orders</p>
|
||||
<p className="mt-2 text-2xl font-bold tracking-tight">{formatCurrency(totals.activeValue)}</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">Value of open POs across all sites</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Workforce this week</p>
|
||||
<p className="mt-2 text-2xl font-bold tracking-tight">{formatNumber(totals.hours)} h</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{formatNumber(totals.workers)} active workers</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<CardTitle>All sites</CardTitle>
|
||||
<CardDescription>
|
||||
{filtered.length} of {SITES_DATA.length} sites match the current filters
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search site, client, address…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-56 pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as SiteStatus | "all")}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<SelectItem key={s} value={s}>{s}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={typeFilter} onValueChange={(v) => setTypeFilter(v as SiteType | "all")}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All types</SelectItem>
|
||||
{TYPE_OPTIONS.map((t) => (
|
||||
<SelectItem key={t} value={t}>{t}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-lg border border-border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Site</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Type</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Progress</TableHead>
|
||||
<TableHead className="text-right">Contract</TableHead>
|
||||
<TableHead className="text-right">Spent</TableHead>
|
||||
<TableHead className="hidden md:table-cell text-right">Active orders</TableHead>
|
||||
<TableHead className="hidden lg:table-cell text-right">Workers</TableHead>
|
||||
<TableHead className="w-8" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="py-10 text-center text-sm text-muted-foreground">
|
||||
No sites match the current filters.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filtered.map(({ site, activeOrdersCount, activeOrdersValue, workersActive, spentToDate, contractValue }) => {
|
||||
const burnPct = contractValue === 0 ? 0 : (spentToDate / contractValue) * 100;
|
||||
return (
|
||||
<TableRow
|
||||
key={site.id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => router.push(`/dashboard/sites/${site.id}`)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 inline-flex h-9 w-9 items-center justify-center rounded-lg bg-primary/15 text-primary">
|
||||
<Building2 className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/dashboard/sites/${site.id}`}
|
||||
className="font-semibold hover:text-primary"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{site.name}
|
||||
</Link>
|
||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{site.address}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant[site.status]}>{site.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<span className="text-sm">{site.type}</span>
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell min-w-[140px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress
|
||||
value={site.progress}
|
||||
tone={site.progress >= 75 ? "success" : site.progress >= 30 ? "default" : "warning"}
|
||||
className="w-24"
|
||||
/>
|
||||
<span className="text-xs font-medium tabular-nums">{site.progress}%</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium tabular-nums">
|
||||
{formatCurrency(contractValue)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
<span className={cn(
|
||||
"font-medium",
|
||||
burnPct >= 50 && "text-destructive"
|
||||
)}>
|
||||
{formatCurrency(spentToDate)}
|
||||
</span>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
{burnPct.toFixed(0)}% burned
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell text-right tabular-nums">
|
||||
<div className="font-medium">{activeOrdersCount}</div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
{formatCurrency(activeOrdersValue)}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell text-right tabular-nums">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<Users className="h-3 w-3 text-muted-foreground" />
|
||||
{workersActive}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Site type breakdown</CardTitle>
|
||||
<CardDescription>Total contract value by project type</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{TYPE_OPTIONS.map((t) => {
|
||||
const list = SITES_DATA.filter((s) => s.type === t);
|
||||
const total = list.reduce((s, x) => s + x.contractValue, 0);
|
||||
return (
|
||||
<li key={t} className="rounded-lg border border-border p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{t}</p>
|
||||
<p className="mt-2 text-lg font-bold tabular-nums">{formatCurrency(total)}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{list.length} project{list.length === 1 ? "" : "s"}</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,369 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
CloudUpload,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
Fuel,
|
||||
Image as ImageIcon,
|
||||
Truck,
|
||||
Upload,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { Topbar } from "@/components/layout/sidebar";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { UPLOAD_ACCEPTANCE, UPLOAD_HISTORY } from "@/lib/uploads";
|
||||
import type { UploadEntry, UploadKind, UploadStatus } from "@/lib/types";
|
||||
|
||||
interface BlockConfig {
|
||||
kind: UploadKind;
|
||||
label: string;
|
||||
description: string;
|
||||
longDescription: string;
|
||||
icon: LucideIcon;
|
||||
/** Tint colour — used for the icon background, accent stripe, and badge. */
|
||||
hex: string;
|
||||
/** Tailwind classes for the icon disc, kept in sync with the hex. */
|
||||
iconBg: string;
|
||||
stripe: string;
|
||||
badge: string;
|
||||
badgeText: string;
|
||||
/** Pill background for the accepted-format chips. */
|
||||
chip: string;
|
||||
}
|
||||
|
||||
const BLOCKS: BlockConfig[] = [
|
||||
{
|
||||
kind: "orders",
|
||||
label: "Orders",
|
||||
description: "Bulk purchase order lines & supplier invoices",
|
||||
longDescription: "Import PO lines, supplier invoices, or stock-sheet exports. CSV with vehicle registration, date, hours worked, odometer, litres, and unit price is the fastest format.",
|
||||
icon: Truck,
|
||||
hex: "#fdb13c",
|
||||
iconBg: "bg-[#fdb13c]/15 text-[#9c6f1e] dark:text-[#fdb13c]",
|
||||
stripe: "before:bg-[#fdb13c]",
|
||||
badge: "bg-[#fdb13c]/15",
|
||||
badgeText: "text-[#9c6f1e] dark:text-[#fdb13c]",
|
||||
chip: "bg-[#fdb13c]/10 text-[#9c6f1e] dark:text-[#fdb13c]",
|
||||
},
|
||||
{
|
||||
kind: "delivery-notes",
|
||||
label: "Delivery Notes",
|
||||
description: "Scanned GRNs, supplier dockets & photos",
|
||||
longDescription: "Capture scanned delivery notes, supplier GRNs, or photos of the docket. PDFs and high-resolution JPEGs are matched to the order automatically.",
|
||||
icon: FileText,
|
||||
hex: "#008372",
|
||||
iconBg: "bg-[#008372]/15 text-[#008372] dark:text-[#3fb8a8]",
|
||||
stripe: "before:bg-[#008372]",
|
||||
badge: "bg-[#008372]/15",
|
||||
badgeText: "text-[#008372] dark:text-[#3fb8a8]",
|
||||
chip: "bg-[#008372]/10 text-[#008372] dark:text-[#3fb8a8]",
|
||||
},
|
||||
{
|
||||
kind: "fuel-logs",
|
||||
label: "Fuel Logs",
|
||||
description: "Per-vehicle daily fuel-issue exports",
|
||||
longDescription: "Drop per-vehicle daily fuel-issue exports from the depot system. The CSV needs vehicle registration, date, hours worked, odometer, litres, unit price, and expected consumption.",
|
||||
icon: Fuel,
|
||||
hex: "#a1ae3b",
|
||||
iconBg: "bg-[#a1ae3b]/15 text-[#5e6920] dark:text-[#c0cd5a]",
|
||||
stripe: "before:bg-[#a1ae3b]",
|
||||
badge: "bg-[#a1ae3b]/15",
|
||||
badgeText: "text-[#5e6920] dark:text-[#c0cd5a]",
|
||||
chip: "bg-[#a1ae3b]/10 text-[#5e6920] dark:text-[#c0cd5a]",
|
||||
},
|
||||
{
|
||||
kind: "time-sheets",
|
||||
label: "Time Sheets",
|
||||
description: "Weekly or daily payroll timesheet exports",
|
||||
longDescription: "Weekly or daily timesheet exports from the payroll system. CSV/XLSX with employee id, date, and hours worked will update the workforce dashboard automatically.",
|
||||
icon: FileSpreadsheet,
|
||||
hex: "#4f9c5b",
|
||||
iconBg: "bg-[#4f9c5b]/15 text-[#2f6940] dark:text-[#6ec082]",
|
||||
stripe: "before:bg-[#4f9c5b]",
|
||||
badge: "bg-[#4f9c5b]/15",
|
||||
badgeText: "text-[#2f6940] dark:text-[#6ec082]",
|
||||
chip: "bg-[#4f9c5b]/10 text-[#2f6940] dark:text-[#6ec082]",
|
||||
},
|
||||
];
|
||||
|
||||
const STATUS_META: Record<UploadStatus, { label: string; variant: "success" | "warning" | "danger" | "secondary"; icon: LucideIcon }> = {
|
||||
Processed: { label: "Processed", variant: "success", icon: CheckCircle2 },
|
||||
Pending: { label: "Pending", variant: "warning", icon: Clock },
|
||||
Failed: { label: "Failed", variant: "danger", icon: AlertCircle },
|
||||
};
|
||||
|
||||
function formatBytes(n: number) {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
export default function UploadsPage() {
|
||||
const [recent, setRecent] = React.useState(UPLOAD_HISTORY);
|
||||
const [busy, setBusy] = React.useState<UploadKind | null>(null);
|
||||
const [toast, setToast] = React.useState<{ kind: UploadKind; ok: number; fail: number } | null>(null);
|
||||
const fileInputRefs = React.useRef<Record<UploadKind, HTMLInputElement | null>>({
|
||||
orders: null,
|
||||
"delivery-notes": null,
|
||||
"fuel-logs": null,
|
||||
"time-sheets": null,
|
||||
});
|
||||
|
||||
const handlePick = (kind: UploadKind) => {
|
||||
fileInputRefs.current[kind]?.click();
|
||||
};
|
||||
|
||||
const handleUpload = async (kind: UploadKind, fileList: FileList | null) => {
|
||||
if (!fileList || fileList.length === 0) return;
|
||||
setBusy(kind);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.set("type", kind);
|
||||
for (const f of Array.from(fileList)) form.append("file", f);
|
||||
const res = await fetch("/api/upload", { method: "POST", body: form });
|
||||
const json = await res.json();
|
||||
const ok = (json.accepted as Array<unknown>).length;
|
||||
const fail = (json.rejected as Array<unknown>).length;
|
||||
setToast({ kind, ok, fail });
|
||||
|
||||
// Append a synthetic entry to the local history so the UI updates immediately.
|
||||
const newEntries: UploadEntry[] = Array.from(fileList).map((f, i) => ({
|
||||
id: `up-new-${Date.now()}-${i}`,
|
||||
kind,
|
||||
fileName: f.name,
|
||||
size: f.size,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
uploadedBy: "You",
|
||||
status: i < ok ? "Processed" : "Failed",
|
||||
note: i < ok ? "Just uploaded" : (json.rejected?.[0]?.reason ?? "Rejected"),
|
||||
}));
|
||||
setRecent((prev) => [...newEntries, ...prev]);
|
||||
} catch (err) {
|
||||
setToast({ kind, ok: 0, fail: fileList.length });
|
||||
} finally {
|
||||
setBusy(null);
|
||||
if (fileInputRefs.current[kind]) fileInputRefs.current[kind]!.value = "";
|
||||
setTimeout(() => setToast(null), 4000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (kind: UploadKind) => (e: React.DragEvent<HTMLLabelElement>) => {
|
||||
e.preventDefault();
|
||||
handleUpload(kind, e.dataTransfer.files);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Topbar
|
||||
title="Uploads"
|
||||
subtitle="Drop or pick files for each module — the dashboard updates automatically"
|
||||
/>
|
||||
|
||||
<div className="space-y-6 p-4 sm:p-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Each card is wired to a different upload endpoint. Files are validated
|
||||
against the format and size limits shown, then forwarded to the right
|
||||
place in the dashboard.
|
||||
</p>
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{BLOCKS.map((block) => {
|
||||
const acc = UPLOAD_ACCEPTANCE[block.kind];
|
||||
const history = recent.filter((u) => u.kind === block.kind).slice(0, 4);
|
||||
return (
|
||||
<Card
|
||||
key={block.kind}
|
||||
className={cn(
|
||||
"relative overflow-hidden before:absolute before:inset-y-0 before:left-0 before:w-1.5",
|
||||
block.stripe
|
||||
)}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-lg",
|
||||
block.iconBg
|
||||
)}
|
||||
style={{ color: block.hex }}
|
||||
>
|
||||
<block.icon className="h-5 w-5" style={{ color: block.hex }} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle>{block.label}</CardTitle>
|
||||
<Badge className={cn(block.badge, block.badgeText)} variant="secondary">
|
||||
{acc.extensions.length} formats
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription className="mt-0.5">{block.description}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{block.longDescription}</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{acc.extensions.map((ext) => (
|
||||
<span
|
||||
key={ext}
|
||||
className={cn(
|
||||
"rounded-md px-2 py-0.5 text-[11px] font-medium uppercase tracking-wider",
|
||||
block.chip
|
||||
)}
|
||||
>
|
||||
{ext}
|
||||
</span>
|
||||
))}
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
Max {Math.round(acc.maxBytes / 1024 / 1024)} MB
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Drop zone */}
|
||||
<label
|
||||
htmlFor={`upload-${block.kind}`}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleDrop(block.kind)}
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-border bg-muted/30 px-4 py-6 text-center transition-colors",
|
||||
"hover:border-primary/50 hover:bg-muted/50",
|
||||
busy === block.kind && "pointer-events-none opacity-60"
|
||||
)}
|
||||
>
|
||||
{busy === block.kind ? (
|
||||
<>
|
||||
<CloudUpload className="h-6 w-6 animate-pulse text-muted-foreground" />
|
||||
<p className="text-sm font-medium">Uploading…</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-6 w-6 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">
|
||||
Drop {block.label.toLowerCase()} files here
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
or click to browse
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
ref={(el) => {
|
||||
fileInputRefs.current[block.kind] = el;
|
||||
}}
|
||||
id={`upload-${block.kind}`}
|
||||
type="file"
|
||||
className="sr-only"
|
||||
multiple
|
||||
accept={acc.extensions.join(",")}
|
||||
onChange={(e) => handleUpload(block.kind, e.target.files)}
|
||||
/>
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={busy === block.kind}
|
||||
onClick={() => handlePick(block.kind)}
|
||||
style={{ backgroundColor: block.hex, color: "white" }}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Upload {block.label.toLowerCase()}
|
||||
</Button>
|
||||
|
||||
{/* Recent uploads for this kind */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Recent
|
||||
</p>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{history.length} of {recent.filter((u) => u.kind === block.kind).length}
|
||||
</span>
|
||||
</div>
|
||||
{history.length === 0 ? (
|
||||
<p className="rounded-md border border-dashed border-border p-4 text-center text-xs text-muted-foreground">
|
||||
No uploads yet.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{history.map((u) => {
|
||||
const meta = STATUS_META[u.status];
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<li
|
||||
key={u.id}
|
||||
className="flex items-center gap-3 rounded-md border border-border p-2.5 text-sm"
|
||||
>
|
||||
{u.kind === "delivery-notes" ? (
|
||||
<ImageIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<FileSpreadsheet className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{u.fileName}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatBytes(u.size)} · {u.uploadedBy} ·{" "}
|
||||
{format(parseISO(u.uploadedAt), "d MMM, HH:mm")}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant={meta.variant}
|
||||
className="gap-1"
|
||||
title={u.note}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
{meta.label}
|
||||
</Badge>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
{toast && (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-lg border px-4 py-3 shadow-lg",
|
||||
"bg-card text-card-foreground",
|
||||
toast.fail > 0 ? "border-destructive" : "border-emerald-500"
|
||||
)}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{toast.fail > 0 ? (
|
||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
|
||||
)}
|
||||
<p className="text-sm">
|
||||
{toast.ok} accepted · {toast.fail} rejected
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setToast(null)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
interface DayHours {
|
||||
date: string;
|
||||
hours: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: DayHours[];
|
||||
/** Chart height in pixels. Defaults to 200. */
|
||||
height?: number;
|
||||
/** When `true` (default) renders grid, axes and tooltip. Set `false`
|
||||
* for a sparkline-style inline chart where the container's height
|
||||
* is too small to fit axis labels. */
|
||||
showAxis?: boolean;
|
||||
/** When provided, renders with a fixed pixel width instead of filling
|
||||
* the parent container. Useful for compact inline sparklines. */
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export function EmployeeHoursChart({ data, height = 200, showAxis = true, width }: Props) {
|
||||
const enriched = data.map((d) => {
|
||||
const tone =
|
||||
d.hours === 0 ? "danger" : d.hours > 10 ? "warning" : d.hours < 6 ? "info" : "success";
|
||||
return { ...d, tone };
|
||||
});
|
||||
const margin = showAxis
|
||||
? { top: 10, right: 8, left: -16, bottom: 0 }
|
||||
: { top: 4, right: 2, left: 2, bottom: 4 };
|
||||
const radius: [number, number, number, number] = showAxis ? [6, 6, 0, 0] : [3, 3, 0, 0];
|
||||
const barCell = (entry: { tone: string }, i: number) => {
|
||||
const fill =
|
||||
entry.tone === "danger"
|
||||
? "#ef4444"
|
||||
: entry.tone === "warning"
|
||||
? "#fdb13c"
|
||||
: entry.tone === "info"
|
||||
? "#006672"
|
||||
: "#4f9c5b";
|
||||
return <Cell key={i} fill={fill} />;
|
||||
};
|
||||
|
||||
const chartBody = (
|
||||
<>
|
||||
{showAxis && <CartesianGrid strokeDasharray="3 3" stroke="#dcdfe2" vertical={false} />}
|
||||
{showAxis && (
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="#6b7280"
|
||||
fontSize={10}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(v: string) => v.slice(5)}
|
||||
/>
|
||||
)}
|
||||
{showAxis && (
|
||||
<YAxis
|
||||
stroke="#6b7280"
|
||||
fontSize={11}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={28}
|
||||
domain={[0, 14]}
|
||||
/>
|
||||
)}
|
||||
{showAxis && (
|
||||
<Tooltip
|
||||
cursor={{ fill: "#f3f4f6" }}
|
||||
contentStyle={{
|
||||
background: "#ffffff",
|
||||
border: "1px solid #dcdfe2",
|
||||
borderRadius: 8,
|
||||
color: "#333333",
|
||||
fontSize: 12,
|
||||
}}
|
||||
labelStyle={{ color: "#6b7280" }}
|
||||
formatter={(v: number) => [`${v.toFixed(1)} hrs`, "Worked"]}
|
||||
/>
|
||||
)}
|
||||
<Bar dataKey="hours" radius={radius} isAnimationActive={false}>
|
||||
{enriched.map(barCell)}
|
||||
</Bar>
|
||||
</>
|
||||
);
|
||||
|
||||
if (width !== undefined) {
|
||||
// Fixed-size rendering — works reliably in headless / SSR / narrow
|
||||
// containers without depending on ResizeObserver.
|
||||
return (
|
||||
<BarChart width={width} height={height} data={enriched} margin={margin}>
|
||||
{chartBody}
|
||||
</BarChart>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<BarChart data={enriched} margin={margin}>
|
||||
{chartBody}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import type { Vehicle } from "@/lib/types";
|
||||
|
||||
interface Props {
|
||||
data: Array<{ vehicle: Vehicle; litres: number }>;
|
||||
}
|
||||
|
||||
export function FuelByVehicleChart({ data }: Props) {
|
||||
const chartData = data.map((d) => ({ name: d.vehicle.registration, litres: d.litres }));
|
||||
const palette = ["#fdb13c", "#a1ae3b", "#4f9c5b", "#008372", "#006672", "#2f4858"];
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={chartData} margin={{ top: 10, right: 8, left: -8, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#dcdfe2" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="#6b7280"
|
||||
fontSize={11}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis stroke="#6b7280" fontSize={11} tickLine={false} axisLine={false} width={36} />
|
||||
<Tooltip
|
||||
cursor={{ fill: "#f3f4f6" }}
|
||||
contentStyle={{
|
||||
background: "#ffffff",
|
||||
border: "1px solid #dcdfe2",
|
||||
borderRadius: 8,
|
||||
color: "#333333",
|
||||
fontSize: 12,
|
||||
}}
|
||||
labelStyle={{ color: "#6b7280" }}
|
||||
/>
|
||||
<Bar dataKey="litres" radius={[6, 6, 0, 0]}>
|
||||
{chartData.map((_, i) => (
|
||||
<Cell key={i} fill={palette[i % palette.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
interface Point {
|
||||
bucket: string;
|
||||
litres: number;
|
||||
cost: number;
|
||||
hours: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: Point[];
|
||||
}
|
||||
|
||||
export function FuelTrendChart({ data }: Props) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<AreaChart data={data} margin={{ top: 10, right: 8, left: -8, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="fuelLitres" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#fdb13c" stopOpacity={0.5} />
|
||||
<stop offset="100%" stopColor="#fdb13c" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#dcdfe2" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="bucket"
|
||||
stroke="#6b7280"
|
||||
fontSize={11}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#6b7280"
|
||||
fontSize={11}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={36}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ stroke: "#fdb13c", strokeWidth: 1, strokeDasharray: "3 3" }}
|
||||
contentStyle={{
|
||||
background: "#ffffff",
|
||||
border: "1px solid #dcdfe2",
|
||||
borderRadius: 8,
|
||||
color: "#333333",
|
||||
fontSize: 12,
|
||||
}}
|
||||
labelStyle={{ color: "#6b7280" }}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="litres"
|
||||
stroke="#fdb13c"
|
||||
strokeWidth={2}
|
||||
fill="url(#fuelLitres)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
interface DayValue {
|
||||
date: string; // yyyy-MM-dd
|
||||
delivered: number;
|
||||
outstanding: number;
|
||||
overdue: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: DayValue[];
|
||||
formatValue: (n: number) => string;
|
||||
}
|
||||
|
||||
export function OrderValueTrendChart({ data, formatValue }: Props) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={data} margin={{ top: 10, right: 8, left: -8, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#dcdfe2" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="#6b7280"
|
||||
fontSize={11}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(v: string) => v.slice(5)}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#6b7280"
|
||||
fontSize={11}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={56}
|
||||
tickFormatter={(v: number) => (v >= 1000 ? `${Math.round(v / 1000)}k` : `${v}`)}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "#f3f4f6" }}
|
||||
contentStyle={{
|
||||
background: "#ffffff",
|
||||
border: "1px solid #dcdfe2",
|
||||
borderRadius: 8,
|
||||
color: "#333333",
|
||||
fontSize: 12,
|
||||
}}
|
||||
labelStyle={{ color: "#6b7280" }}
|
||||
formatter={(value: number, name: string) => [formatValue(value), name]}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ paddingTop: 8, fontSize: 12 }}
|
||||
iconType="circle"
|
||||
iconSize={8}
|
||||
/>
|
||||
<Bar dataKey="delivered" name="Delivered" stackId="v" fill="#4f9c5b" radius={[0, 0, 0, 0]} />
|
||||
<Bar dataKey="outstanding" name="Outstanding" stackId="v" fill="#fdb13c" />
|
||||
<Bar dataKey="overdue" name="Overdue" stackId="v" fill="#ef4444" radius={[6, 6, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
|
||||
|
||||
interface Props {
|
||||
data: Array<{ name: string; value: number; color: string }>;
|
||||
}
|
||||
|
||||
export function OrdersStatusChart({ data }: Props) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: "#ffffff",
|
||||
border: "1px solid #dcdfe2",
|
||||
borderRadius: 8,
|
||||
color: "#333333",
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<Pie
|
||||
data={data}
|
||||
innerRadius={50}
|
||||
outerRadius={80}
|
||||
paddingAngle={3}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
strokeWidth={0}
|
||||
>
|
||||
{data.map((d, i) => (
|
||||
<Cell key={i} fill={d.color} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DayHours {
|
||||
date: string;
|
||||
hours: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: DayHours[];
|
||||
/** Chart height in pixels. Defaults to 56. */
|
||||
height?: number;
|
||||
/** Maximum hours for the Y-axis. Bars are scaled relative to this. */
|
||||
maxHours?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pure-CSS bar chart for inline hourly trend visualisation. Renders
|
||||
* each day's hours as a coloured bar without depending on Recharts or
|
||||
* ResizeObserver, so it works reliably in any environment (including
|
||||
* strict headless renderers and tight inline containers).
|
||||
*/
|
||||
export function WorkforceTrendBars({ data, height = 56, maxHours = 14, className }: Props) {
|
||||
const safeMax = Math.max(maxHours, ...data.map((d) => d.hours), 1);
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-end gap-1", className)}
|
||||
style={{ height, width: "100%" }}
|
||||
role="img"
|
||||
aria-label={`Daily hours chart, ${data.length} days`}
|
||||
>
|
||||
{data.map((d) => {
|
||||
const h = Math.max(2, (d.hours / safeMax) * height);
|
||||
const tone =
|
||||
d.hours === 0
|
||||
? "bg-destructive"
|
||||
: d.hours > 10
|
||||
? "bg-amber-500"
|
||||
: d.hours < 6
|
||||
? "bg-[#006672]"
|
||||
: "bg-[#4f9c5b]";
|
||||
return (
|
||||
<div
|
||||
key={d.date}
|
||||
className={cn("flex-1 rounded-t-sm transition-all", tone)}
|
||||
style={{ height: `${h}px`, minWidth: 2 }}
|
||||
title={`${d.date} · ${d.hours.toFixed(1)} h`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ArrowDownRight, ArrowUpRight, type LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
interface KpiCardProps {
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: React.ReactNode;
|
||||
delta?: string;
|
||||
trend?: "up" | "down" | "flat";
|
||||
icon?: LucideIcon;
|
||||
tone?: "default" | "success" | "warning" | "danger" | "info";
|
||||
}
|
||||
|
||||
const toneClass: Record<NonNullable<KpiCardProps["tone"]>, string> = {
|
||||
default: "bg-primary/15 text-primary",
|
||||
success: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400",
|
||||
warning: "bg-amber-500/15 text-amber-600 dark:text-amber-400",
|
||||
danger: "bg-destructive/15 text-destructive",
|
||||
info: "bg-sky-500/15 text-sky-600 dark:text-sky-400",
|
||||
};
|
||||
|
||||
export function KpiCard({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
delta,
|
||||
trend,
|
||||
icon: Icon,
|
||||
tone = "default",
|
||||
}: KpiCardProps) {
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">{label}</p>
|
||||
{Icon && (
|
||||
<span className={cn("inline-flex h-9 w-9 items-center justify-center rounded-lg", toneClass[tone])}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex items-baseline gap-2">
|
||||
<p className="text-2xl font-bold tracking-tight">{value}</p>
|
||||
{delta && (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs font-semibold",
|
||||
trend === "up" && "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400",
|
||||
trend === "down" && "bg-destructive/15 text-destructive",
|
||||
trend === "flat" && "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{trend === "up" && <ArrowUpRight className="h-3 w-3" />}
|
||||
{trend === "down" && <ArrowDownRight className="h-3 w-3" />}
|
||||
{delta}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hint && <div className="mt-2 text-xs text-muted-foreground">{hint}</div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,15 +4,10 @@ import * as React from "react";
|
|||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
Building2,
|
||||
Fuel,
|
||||
LayoutDashboard,
|
||||
Package,
|
||||
LogOut,
|
||||
Menu,
|
||||
Upload,
|
||||
X,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { Logo } from "@/components/logo";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
|
|
@ -28,13 +23,10 @@ interface NavItem {
|
|||
description: string;
|
||||
}
|
||||
|
||||
// Navigation entries are added page-by-page as each PocketBase-backed
|
||||
// route is built. The Overview link below is the only one wired today.
|
||||
const NAV: NavItem[] = [
|
||||
{ href: "/dashboard", label: "Overview", icon: LayoutDashboard, description: "Site-at-a-glance KPIs" },
|
||||
{ href: "/dashboard/fuel", label: "Fuel", icon: Fuel, description: "Consumption, theft detection" },
|
||||
{ href: "/dashboard/orders", label: "Orders", icon: Package, description: "Purchase orders & delivery notes" },
|
||||
{ href: "/dashboard/employees", label: "Workforce", icon: Users, description: "Weekly hours & attendance" },
|
||||
{ href: "/dashboard/sites", label: "Sites", icon: Building2, description: "Per-site financials & progress" },
|
||||
{ href: "/dashboard/uploads", label: "Uploads", icon: Upload, description: "Drop files into the right module" },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
|
|
@ -101,12 +93,6 @@ export function Sidebar() {
|
|||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{item.label}</span>
|
||||
{item.label === "Fuel" && (
|
||||
<Badge variant="warning" className="ml-1">3 alerts</Badge>
|
||||
)}
|
||||
{item.label === "Orders" && (
|
||||
<Badge variant="info" className="ml-1">4 due</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">{item.description}</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
import type {
|
||||
PgExpense,
|
||||
PlantHireRate,
|
||||
Vehicle,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Constants used by the burn-rate computations on the dashboard.
|
||||
* Tweak these if the project shifts from a 45-hour work week.
|
||||
*/
|
||||
export const STANDARD_WEEKLY_HOURS = 45;
|
||||
|
||||
/**
|
||||
* Given a list of plant hire rates and the vehicles on this site,
|
||||
* compute the weekly hire cost. Vehicles without a hire rate
|
||||
* contribute zero — they're typically owned or supplied by the
|
||||
* client and only their fuel is tracked.
|
||||
*/
|
||||
export function weeklyPlantHireCost(
|
||||
hireRates: PlantHireRate[],
|
||||
siteVehicles: Vehicle[]
|
||||
): number {
|
||||
const rateByVehicle = new Map(hireRates.map((r) => [r.vehicleId, r.ratePerHour]));
|
||||
let total = 0;
|
||||
for (const v of siteVehicles) {
|
||||
const rate = rateByVehicle.get(v.id);
|
||||
if (typeof rate === "number") total += rate * STANDARD_WEEKLY_HOURS;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum of every P&G line on this site for a calendar month.
|
||||
*/
|
||||
export function monthlyPgCost(expenses: PgExpense[]): number {
|
||||
return expenses.reduce((s, e) => s + e.monthlyAmount, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a monthly ZAR figure into a per-week equivalent using the
|
||||
* standard 4.345 weeks-per-month convention.
|
||||
*/
|
||||
export function weeklyFromMonthly(monthlyZar: number): number {
|
||||
return monthlyZar / 4.345;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper — weekly burn = weekly plant hire + weekly P&G
|
||||
* share. Returns both component sums so the dashboard can display them
|
||||
* separately.
|
||||
*/
|
||||
export function burnBreakdown(
|
||||
hireRates: PlantHireRate[],
|
||||
siteVehicles: Vehicle[],
|
||||
pgExpenses: PgExpense[]
|
||||
): { weeklyHire: number; weeklyPg: number; monthlyHire: number; monthlyPg: number; weekly: number; monthly: number } {
|
||||
const weeklyHire = weeklyPlantHireCost(hireRates, siteVehicles);
|
||||
const monthlyHire = (weeklyHire / 7) * 30;
|
||||
const monthlyPg = monthlyPgCost(pgExpenses);
|
||||
const weeklyPg = weeklyFromMonthly(monthlyPg);
|
||||
return {
|
||||
weeklyHire,
|
||||
weeklyPg,
|
||||
monthlyHire,
|
||||
monthlyPg,
|
||||
weekly: weeklyHire + weeklyPg,
|
||||
monthly: monthlyHire + monthlyPg,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
import Papa from "papaparse";
|
||||
import type { FuelReading, Vehicle } from "./types";
|
||||
|
||||
/**
|
||||
* Expected CSV columns (header row required):
|
||||
* vehicleRegistration,date,hoursWorked,odometer,litresFilled,unitPrice,expectedConsumptionPerHour
|
||||
*
|
||||
* `date` is yyyy-mm-dd. Trailing rows with invalid data are skipped with a warning.
|
||||
*/
|
||||
export interface ParsedFuelRow {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
data?: Omit<FuelReading, "id"> & { vehicleRegistration: string };
|
||||
}
|
||||
|
||||
export function parseFuelCsv(
|
||||
fileContent: string,
|
||||
vehicles: Vehicle[]
|
||||
): { rows: ParsedFuelRow[]; errors: string[] } {
|
||||
const parsed = Papa.parse<Record<string, string>>(fileContent, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
transformHeader: (h) => h.trim(),
|
||||
});
|
||||
const errors: string[] = parsed.errors.map((e) => `row ${e.row}: ${e.message}`);
|
||||
const byReg = new Map(vehicles.map((v) => [v.registration.toUpperCase(), v]));
|
||||
const rows: ParsedFuelRow[] = [];
|
||||
for (const raw of parsed.data) {
|
||||
const reg = (raw.vehicleRegistration || "").trim().toUpperCase();
|
||||
const v = byReg.get(reg);
|
||||
if (!v) {
|
||||
rows.push({ ok: false, reason: `Unknown registration "${reg}"` });
|
||||
continue;
|
||||
}
|
||||
const date = (raw.date || "").trim();
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
rows.push({ ok: false, reason: `Invalid date "${date}" (expected yyyy-mm-dd)` });
|
||||
continue;
|
||||
}
|
||||
const hoursWorked = Number(raw.hoursWorked);
|
||||
const odometer = Number(raw.odometer);
|
||||
const litresFilled = Number(raw.litresFilled);
|
||||
const unitPrice = Number(raw.unitPrice);
|
||||
const expectedConsumptionPerHour = Number(raw.expectedConsumptionPerHour || 0) || v.type === "Generator" ? 6 : 9;
|
||||
if (![hoursWorked, odometer, litresFilled, unitPrice].every(Number.isFinite)) {
|
||||
rows.push({ ok: false, reason: "Numeric fields are not all valid numbers" });
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
ok: true,
|
||||
data: {
|
||||
vehicleId: v.id,
|
||||
vehicleRegistration: v.registration,
|
||||
date,
|
||||
hoursWorked,
|
||||
odometer,
|
||||
litresFilled,
|
||||
unitPrice,
|
||||
expectedConsumptionPerHour,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { rows, errors };
|
||||
}
|
||||
|
||||
export function fuelCsvTemplate(): string {
|
||||
return [
|
||||
"vehicleRegistration,date,hoursWorked,odometer,litresFilled,unitPrice,expectedConsumptionPerHour",
|
||||
"MI-EX-01,2026-08-01,9.5,12480,90,24.50,9",
|
||||
"MI-TT-07,2026-08-01,7.0,88412,55,24.50,7",
|
||||
"MI-LD-03,2026-08-01,8.0,32104,80,24.50,10",
|
||||
].join("\n");
|
||||
}
|
||||
131
src/lib/fuel.ts
131
src/lib/fuel.ts
|
|
@ -1,131 +0,0 @@
|
|||
import type { FuelReading, FuelAnomaly, Vehicle } from "./types";
|
||||
|
||||
/**
|
||||
* Compare today's reading against the previous reading on the same vehicle
|
||||
* to compute how many litres *should* have been used, based on hours worked.
|
||||
*
|
||||
* expectedLitres = hoursWorkedToday * expectedConsumptionPerHour
|
||||
*
|
||||
* If actualLitresFilled > expectedLitres by more than the tolerance, we flag
|
||||
* it as missing-fuel / potential theft.
|
||||
*
|
||||
* NOTE: This is the production formula we'll use at site — change
|
||||
* EXPECTED_LITRES_TOLERANCE_PCT if we want a different threshold.
|
||||
*/
|
||||
export const EXPECTED_LITRES_TOLERANCE_PCT = 12; // % above expected before we alert
|
||||
|
||||
export function severityFor(expected: number, actual: number): {
|
||||
severity: FuelAnomaly["severity"];
|
||||
deltaLitres: number;
|
||||
deltaPct: number;
|
||||
} {
|
||||
if (expected <= 0) {
|
||||
return { severity: "ok", deltaLitres: 0, deltaPct: 0 };
|
||||
}
|
||||
const deltaLitres = actual - expected;
|
||||
const deltaPct = (deltaLitres / expected) * 100;
|
||||
const over = Math.max(0, deltaPct);
|
||||
let severity: FuelAnomaly["severity"] = "ok";
|
||||
if (over > EXPECTED_LITRES_TOLERANCE_PCT) severity = "watch";
|
||||
if (over > EXPECTED_LITRES_TOLERANCE_PCT * 2) severity = "alert";
|
||||
if (over > EXPECTED_LITRES_TOLERANCE_PCT * 4) severity = "critical";
|
||||
return { severity, deltaLitres, deltaPct };
|
||||
}
|
||||
|
||||
export function analyseFuel(
|
||||
readings: FuelReading[],
|
||||
vehicles: Vehicle[]
|
||||
): FuelAnomaly[] {
|
||||
const byVehicle = new Map<string, FuelReading[]>();
|
||||
for (const r of readings) {
|
||||
const arr = byVehicle.get(r.vehicleId) ?? [];
|
||||
arr.push(r);
|
||||
byVehicle.set(r.vehicleId, arr);
|
||||
}
|
||||
const anomalies: FuelAnomaly[] = [];
|
||||
for (const [, list] of byVehicle) {
|
||||
const sorted = [...list].sort((a, b) => a.date.localeCompare(b.date));
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const r = sorted[i];
|
||||
const expected = r.hoursWorked * r.expectedConsumptionPerHour;
|
||||
const { severity, deltaLitres, deltaPct } = severityFor(expected, r.litresFilled);
|
||||
anomalies.push({
|
||||
readingId: r.id,
|
||||
vehicleId: r.vehicleId,
|
||||
date: r.date,
|
||||
expectedLitres: Number(expected.toFixed(2)),
|
||||
actualLitres: r.litresFilled,
|
||||
deltaLitres: Number(deltaLitres.toFixed(2)),
|
||||
expectedDeltaPct: Number(deltaPct.toFixed(1)),
|
||||
severity,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Sort by date desc, then by severity desc (critical → alert → watch → ok)
|
||||
// so the most actionable entries surface first in the log.
|
||||
const severityWeight: Record<FuelAnomaly["severity"], number> = {
|
||||
critical: 4,
|
||||
alert: 3,
|
||||
watch: 2,
|
||||
ok: 1,
|
||||
};
|
||||
anomalies.sort((a, b) => {
|
||||
const dateDiff = b.date.localeCompare(a.date);
|
||||
if (dateDiff !== 0) return dateDiff;
|
||||
return severityWeight[b.severity] - severityWeight[a.severity];
|
||||
});
|
||||
return anomalies;
|
||||
}
|
||||
|
||||
export function litresByPeriod(
|
||||
readings: FuelReading[],
|
||||
granularity: "hour" | "day" | "week"
|
||||
): Array<{ bucket: string; litres: number; cost: number; hours: number }> {
|
||||
const buckets = new Map<string, { litres: number; cost: number; hours: number }>();
|
||||
for (const r of readings) {
|
||||
let key = r.date;
|
||||
if (granularity === "week") {
|
||||
const d = new Date(r.date);
|
||||
const tmp = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
||||
const dayNum = tmp.getUTCDay() || 7;
|
||||
tmp.setUTCDate(tmp.getUTCDate() + 4 - dayNum);
|
||||
const yearStart = new Date(Date.UTC(tmp.getUTCFullYear(), 0, 1));
|
||||
const weekNo = Math.ceil(((tmp.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
||||
key = `${tmp.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`;
|
||||
}
|
||||
const cur = buckets.get(key) ?? { litres: 0, cost: 0, hours: 0 };
|
||||
cur.litres += r.litresFilled;
|
||||
cur.cost += r.litresFilled * r.unitPrice;
|
||||
cur.hours += r.hoursWorked;
|
||||
buckets.set(key, cur);
|
||||
}
|
||||
return Array.from(buckets.entries())
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([bucket, v]) => ({ bucket, ...v }));
|
||||
}
|
||||
|
||||
export function consumptionPerHour(readings: FuelReading[]): number {
|
||||
const totalLitres = readings.reduce((s, r) => s + r.litresFilled, 0);
|
||||
const totalHours = readings.reduce((s, r) => s + r.hoursWorked, 0);
|
||||
if (totalHours === 0) return 0;
|
||||
return totalLitres / totalHours;
|
||||
}
|
||||
|
||||
export function vehiclesWithAnomalies(
|
||||
vehicles: Vehicle[],
|
||||
anomalies: FuelAnomaly[]
|
||||
): Array<{ vehicle: Vehicle; critical: number; alert: number; watch: number }> {
|
||||
const map = new Map<string, { critical: number; alert: number; watch: number }>();
|
||||
for (const v of vehicles) map.set(v.id, { critical: 0, alert: 0, watch: 0 });
|
||||
for (const a of anomalies) {
|
||||
const cur = map.get(a.vehicleId);
|
||||
if (!cur) continue;
|
||||
if (a.severity === "critical") cur.critical++;
|
||||
if (a.severity === "alert") cur.alert++;
|
||||
if (a.severity === "watch") cur.watch++;
|
||||
}
|
||||
return vehicles.map((vehicle) => ({
|
||||
vehicle,
|
||||
...(map.get(vehicle.id) ?? { critical: 0, alert: 0, watch: 0 }),
|
||||
}));
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
import {
|
||||
addDays,
|
||||
format,
|
||||
startOfDay,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
subDays,
|
||||
} from "date-fns";
|
||||
import type {
|
||||
DeliveryNote,
|
||||
Employee,
|
||||
FuelReading,
|
||||
Order,
|
||||
OrderLine,
|
||||
PgExpense,
|
||||
PlantHireRate,
|
||||
Site,
|
||||
Vehicle,
|
||||
} from "./types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PLACEHOLDER DATA LAYER
|
||||
// ---------------------------------------------------------------------------
|
||||
// Every array below is empty by design. The real source of truth will
|
||||
// be PocketBase (https://pb.mega.infra.apphub.co.za) — see lib/pb.ts for
|
||||
// the wiring that swaps these exports for live PB queries.
|
||||
//
|
||||
// Until PB is connected, the dashboard renders in its empty-state UI:
|
||||
// • Dashboard KPIs all show 0
|
||||
// • Charts draw empty axes
|
||||
// • Tables say "No X match the current filters"
|
||||
// • Upload history is blank
|
||||
// • The Sites list shows the empty-state card
|
||||
// • The site detail route at /dashboard/sites/[id] returns a 404
|
||||
//
|
||||
// The site helpers (SITES, SUPPLIERS) and the type definitions stay in
|
||||
// place so that all pages continue to render — they just show zeros
|
||||
// and empty lists until live data is wired in.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const SITES_DATA: Site[] = [];
|
||||
|
||||
export const SITES: string[] = [];
|
||||
|
||||
export const SUPPLIERS: string[] = [];
|
||||
|
||||
export const VEHICLES: Vehicle[] = [];
|
||||
|
||||
export const EMPLOYEES: Employee[] = [];
|
||||
|
||||
export const ORDERS: Order[] = [];
|
||||
|
||||
export const FUEL_READINGS: FuelReading[] = [];
|
||||
|
||||
export const PLANT_HIRE_RATES: PlantHireRate[] = [];
|
||||
|
||||
export const PG_EXPENSES: PgExpense[] = [];
|
||||
|
||||
export {
|
||||
addDays,
|
||||
format,
|
||||
startOfDay,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
subDays,
|
||||
};
|
||||
|
||||
export type {
|
||||
DeliveryNote,
|
||||
Employee,
|
||||
FuelReading,
|
||||
Order,
|
||||
OrderLine,
|
||||
PgExpense,
|
||||
PlantHireRate,
|
||||
Site,
|
||||
Vehicle,
|
||||
};
|
||||
157
src/lib/pb-types.ts
Normal file
157
src/lib/pb-types.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
/**
|
||||
* Typed shapes matching the 12 MEGA INFRASTRUCTURE collections on
|
||||
* `https://pb.mega.infra.apphub.co.za`. Keep in sync with the live
|
||||
* schema — re-run `scripts/pb-bootstrap-schema.py` after changes.
|
||||
*/
|
||||
import type { RecordModel } from "./pb";
|
||||
|
||||
export interface SiteRecord extends RecordModel {
|
||||
collectionName: "sites";
|
||||
project_code: string;
|
||||
name: string;
|
||||
duration: string;
|
||||
contract_value: number;
|
||||
work_hours: number;
|
||||
hours_per_week: number;
|
||||
wages_hours_per_day: number;
|
||||
expand?: Record<string, never>;
|
||||
}
|
||||
|
||||
export type WorkerType = "permanent" | "casual";
|
||||
|
||||
export interface EmployeeRecord extends RecordModel {
|
||||
collectionName: "employees";
|
||||
project_code: string;
|
||||
site: string; // relation id
|
||||
name: string;
|
||||
employee_no: string;
|
||||
position: string;
|
||||
rate_per_hour: number;
|
||||
hours_per_day_standard: number;
|
||||
worker_type: WorkerType;
|
||||
expand?: { site?: SiteRecord };
|
||||
}
|
||||
|
||||
export interface VehicleRecord extends RecordModel {
|
||||
collectionName: "vehicles";
|
||||
project_code: string;
|
||||
site: string;
|
||||
description: string;
|
||||
standard_work_hours: string | number;
|
||||
fuel_limits: string;
|
||||
expand?: { site?: SiteRecord };
|
||||
}
|
||||
|
||||
export interface PlantRecord extends RecordModel {
|
||||
collectionName: "plant";
|
||||
project_code: string;
|
||||
site: string;
|
||||
description: string;
|
||||
standard_work_hours: string | number;
|
||||
cost_per_hour: number;
|
||||
expand?: { site?: SiteRecord };
|
||||
}
|
||||
|
||||
export interface FixedCostRecord extends RecordModel {
|
||||
collectionName: "fixed_costs";
|
||||
project_code: string;
|
||||
site: string;
|
||||
date_logged: string;
|
||||
description: string;
|
||||
cost: number;
|
||||
notes: string;
|
||||
expand?: { site?: SiteRecord };
|
||||
}
|
||||
|
||||
export type BatchType = "timesheets" | "fuel_logs";
|
||||
|
||||
export interface DataImportBatchRecord extends RecordModel {
|
||||
collectionName: "data_import_batches";
|
||||
batch_type: BatchType;
|
||||
original_file: string;
|
||||
date_uploaded: string;
|
||||
uploaded_by: string;
|
||||
}
|
||||
|
||||
export interface PurchaseOrderRecord extends RecordModel {
|
||||
collectionName: "purchase_orders";
|
||||
purchase_order_number: string;
|
||||
project_code: string;
|
||||
site: string;
|
||||
supplier: string;
|
||||
required_by_date: string;
|
||||
ordered_by: string;
|
||||
items_count: number;
|
||||
total_value: number;
|
||||
purchase_order_file: string;
|
||||
raw_ocr_data: unknown;
|
||||
is_verified: boolean;
|
||||
expand?: { site?: SiteRecord };
|
||||
}
|
||||
|
||||
export interface PurchaseOrderItemRecord extends RecordModel {
|
||||
collectionName: "purchase_order_items";
|
||||
purchase_order: string;
|
||||
material_code: string;
|
||||
cost_code: string;
|
||||
description: string;
|
||||
qty: number;
|
||||
qty_outstanding: number;
|
||||
unit: string;
|
||||
rate: number;
|
||||
disc_percent: number;
|
||||
vat_percent: number;
|
||||
amount: number;
|
||||
expand?: { purchase_order?: PurchaseOrderRecord };
|
||||
}
|
||||
|
||||
export interface TimesheetRecord extends RecordModel {
|
||||
collectionName: "timesheets";
|
||||
employee: string;
|
||||
batch_reference: string;
|
||||
date_logged: string;
|
||||
hours_worked: number;
|
||||
expand?: { employee?: EmployeeRecord; batch_reference?: DataImportBatchRecord };
|
||||
}
|
||||
|
||||
export interface FuelLogRecord extends RecordModel {
|
||||
collectionName: "fuel_logs";
|
||||
project_code: string;
|
||||
site: string;
|
||||
vehicle: string;
|
||||
batch_reference: string;
|
||||
date_logged: string;
|
||||
litres: number;
|
||||
odo_reading: number;
|
||||
expand?: {
|
||||
site?: SiteRecord;
|
||||
vehicle?: VehicleRecord;
|
||||
batch_reference?: DataImportBatchRecord;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DeliveryNoteRecord extends RecordModel {
|
||||
collectionName: "delivery_notes";
|
||||
delivery_note_number: string;
|
||||
project_code: string;
|
||||
site: string;
|
||||
purchase_order: string;
|
||||
date_received: string;
|
||||
received_by: string;
|
||||
slip_image: string;
|
||||
raw_ocr_data: unknown;
|
||||
is_verified: boolean;
|
||||
expand?: { site?: SiteRecord; purchase_order?: PurchaseOrderRecord };
|
||||
}
|
||||
|
||||
export interface ReceivedItemRecord extends RecordModel {
|
||||
collectionName: "received_items";
|
||||
delivery_note: string;
|
||||
purchase_order_item: string;
|
||||
qty_received: number;
|
||||
condition_notes: string;
|
||||
expand?: {
|
||||
delivery_note?: DeliveryNoteRecord;
|
||||
purchase_order_item?: PurchaseOrderItemRecord;
|
||||
};
|
||||
}
|
||||
92
src/lib/pb.ts
Normal file
92
src/lib/pb.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* PocketBase client for MEGA INFRASTRUCTURE.
|
||||
*
|
||||
* Two clients are exported:
|
||||
* - `pb` — browser/client-safe client (uses NEXT_PUBLIC_POCKETBASE_URL).
|
||||
* Reads its auth token from localStorage so the user
|
||||
* stays signed in across reloads.
|
||||
* - `pbAdmin` — server-only client authenticated as the superuser.
|
||||
* Uses POCKETBASE_ADMIN_EMAIL + POCKETBASE_ADMIN_PASSWORD
|
||||
* from the server environment and caches the token in
|
||||
* module scope.
|
||||
*
|
||||
* Always import from this file. Do not call `new Pocketbase(...)` elsewhere.
|
||||
*/
|
||||
import PocketBase from "pocketbase";
|
||||
|
||||
export const PB_URL =
|
||||
process.env.NEXT_PUBLIC_POCKETBASE_URL ?? "http://127.0.0.1:8090";
|
||||
|
||||
const AUTH_STORAGE_KEY = "mega-pb-auth";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Browser client */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const pb = new PocketBase(PB_URL);
|
||||
|
||||
// Restore persisted auth on first access (browser only).
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(AUTH_STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as { token?: string; model?: unknown };
|
||||
if (parsed.token) {
|
||||
pb.authStore.save(parsed.token, parsed.model as never);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* localStorage may be blocked — ignore */
|
||||
}
|
||||
pb.authStore.onChange(() => {
|
||||
try {
|
||||
if (pb.authStore.isValid) {
|
||||
window.localStorage.setItem(
|
||||
AUTH_STORAGE_KEY,
|
||||
JSON.stringify({ token: pb.authStore.token, model: pb.authStore.model })
|
||||
);
|
||||
} else {
|
||||
window.localStorage.removeItem(AUTH_STORAGE_KEY);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Admin client (server-only) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
let _adminClient: PocketBase | null = null;
|
||||
let _adminToken: string | null = null;
|
||||
|
||||
export async function pbAdmin(): Promise<PocketBase> {
|
||||
if (_adminClient && _adminToken) return _adminClient;
|
||||
|
||||
const email = process.env.POCKETBASE_ADMIN_EMAIL;
|
||||
const password = process.env.POCKETBASE_ADMIN_PASSWORD;
|
||||
const presetToken = process.env.POCKETBASE_ADMIN_TOKEN;
|
||||
|
||||
if (!email || !password) {
|
||||
throw new Error(
|
||||
"pbAdmin(): POCKETBASE_ADMIN_EMAIL / POCKETBASE_ADMIN_PASSWORD are not set in the server environment."
|
||||
);
|
||||
}
|
||||
|
||||
const client = new PocketBase(PB_URL);
|
||||
if (presetToken) {
|
||||
client.authStore.save(presetToken, null);
|
||||
} else {
|
||||
await client.collection("_superusers").authWithPassword(email, password);
|
||||
}
|
||||
_adminClient = client;
|
||||
_adminToken = client.authStore.token;
|
||||
return client;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type { RecordModel } from "pocketbase";
|
||||
107
src/lib/sites.ts
107
src/lib/sites.ts
|
|
@ -1,107 +0,0 @@
|
|||
import type {
|
||||
Employee,
|
||||
FuelReading,
|
||||
Order,
|
||||
PgExpense,
|
||||
PlantHireRate,
|
||||
Site,
|
||||
SiteFinancialSummary,
|
||||
Vehicle,
|
||||
} from "./types";
|
||||
import { format, startOfMonth } from "date-fns";
|
||||
import { burnBreakdown } from "./burn";
|
||||
|
||||
/**
|
||||
* Compute a financial + operational rollup for a single site by joining
|
||||
* the site record with the orders, employees and vehicles currently
|
||||
* assigned to it. Fuel cost is summed across the current calendar month
|
||||
* for vehicles assigned to this site.
|
||||
*
|
||||
* Returned values are best-effort estimates derived from mock data —
|
||||
* swap them out with proper PocketBase queries once the backend is live.
|
||||
*/
|
||||
export function summarizeSite(
|
||||
site: Site,
|
||||
orders: Order[],
|
||||
employees: Employee[],
|
||||
vehicles: Vehicle[],
|
||||
fuelReadings: FuelReading[],
|
||||
hireRates: PlantHireRate[],
|
||||
pgExpenses: PgExpense[],
|
||||
now: Date = new Date()
|
||||
): SiteFinancialSummary {
|
||||
const monthStart = format(startOfMonth(now), "yyyy-MM-dd");
|
||||
|
||||
const siteName = site.name;
|
||||
const siteOrders = orders.filter((o) => o.site === siteName);
|
||||
const openOrders = siteOrders.filter(
|
||||
(o) => o.status !== "Delivered" && o.status !== "Cancelled"
|
||||
);
|
||||
const deliveredOrders = siteOrders.filter((o) => o.status === "Delivered");
|
||||
|
||||
const totalOrdersValue = siteOrders.reduce((s, o) => s + o.totalValue, 0);
|
||||
const activeOrdersValue = openOrders.reduce((s, o) => s + o.totalValue, 0);
|
||||
const deliveredValue = deliveredOrders.reduce((s, o) => s + o.totalValue, 0);
|
||||
|
||||
const siteVehicles = vehicles.filter((v) => v.assignedSite === siteName);
|
||||
const siteVehicleIds = new Set(siteVehicles.map((v) => v.id));
|
||||
|
||||
const monthFuel = fuelReadings.filter(
|
||||
(r) => r.date >= monthStart && siteVehicleIds.has(r.vehicleId)
|
||||
);
|
||||
const fuelCostThisMonth = monthFuel.reduce(
|
||||
(s, r) => s + r.litresFilled * r.unitPrice,
|
||||
0
|
||||
);
|
||||
const fuelLitresThisMonth = monthFuel.reduce((s, r) => s + r.litresFilled, 0);
|
||||
|
||||
const siteEmployees = employees.filter((e) => e.site === siteName);
|
||||
const activeEmployees = siteEmployees.filter((e) => e.status === "Active");
|
||||
const hoursThisWeek = siteEmployees.reduce((s, e) => s + e.weeklyHours, 0);
|
||||
|
||||
// Spent to date = open orders value + delivered order value + month fuel
|
||||
// (fuel for previous months is rolled into the contract too).
|
||||
const spentToDate = totalOrdersValue + fuelCostThisMonth;
|
||||
const budgetRemaining = Math.max(0, site.contractValue - spentToDate);
|
||||
const burnPct = site.contractValue === 0
|
||||
? 0
|
||||
: (spentToDate / site.contractValue) * 100;
|
||||
|
||||
const burn = burnBreakdown(hireRates, siteVehicles, pgExpenses);
|
||||
|
||||
return {
|
||||
site,
|
||||
contractValue: site.contractValue,
|
||||
spentToDate,
|
||||
budgetRemaining,
|
||||
burnPct,
|
||||
activeOrdersCount: openOrders.length,
|
||||
activeOrdersValue,
|
||||
deliveredValue,
|
||||
totalOrdersValue,
|
||||
hoursThisWeek,
|
||||
workersActive: activeEmployees.length,
|
||||
vehiclesOnSite: siteVehicles.length,
|
||||
fuelCostThisMonth,
|
||||
fuelLitresThisMonth,
|
||||
weeklyBurn: burn.weekly,
|
||||
monthlyBurn: burn.monthly,
|
||||
weeklyPlantHire: burn.weeklyHire,
|
||||
monthlyPlantHire: burn.monthlyHire,
|
||||
weeklyPg: burn.weeklyPg,
|
||||
monthlyPg: burn.monthlyPg,
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeAllSites(
|
||||
sites: Site[],
|
||||
orders: Order[],
|
||||
employees: Employee[],
|
||||
vehicles: Vehicle[],
|
||||
fuelReadings: FuelReading[],
|
||||
hireRates: PlantHireRate[],
|
||||
pgExpenses: PgExpense[],
|
||||
now: Date = new Date()
|
||||
): SiteFinancialSummary[] {
|
||||
return sites.map((s) => summarizeSite(s, orders, employees, vehicles, fuelReadings, hireRates, pgExpenses, now));
|
||||
}
|
||||
227
src/lib/types.ts
227
src/lib/types.ts
|
|
@ -1,227 +0,0 @@
|
|||
// Shared domain types for the MEGA INFRASTRUCTURE dashboard.
|
||||
|
||||
export type VehicleType =
|
||||
| "Excavator"
|
||||
| "Tipper Truck"
|
||||
| "Loader"
|
||||
| "Generator"
|
||||
| "Grader"
|
||||
| "Bulldozer"
|
||||
| "Roller"
|
||||
| "TLB"
|
||||
| "Pickup"
|
||||
| "Water Truck"
|
||||
| "Compressor"
|
||||
| "Mixer";
|
||||
export type FuelUnit = "L" | "gal";
|
||||
|
||||
export interface Vehicle {
|
||||
id: string;
|
||||
registration: string;
|
||||
type: VehicleType;
|
||||
model: string;
|
||||
tankCapacityL: number;
|
||||
assignedSite: string;
|
||||
/**
|
||||
* Optional km/L allowance for vehicles where fuel consumption is
|
||||
* tracked by distance rather than hours (e.g. pickups, water trucks).
|
||||
* If omitted, the fuel-anomaly check falls back to `hoursWorked * expectedLPerHour`.
|
||||
*/
|
||||
kmPerL?: number;
|
||||
/**
|
||||
* Optional fallback L/h allowance for plant where fuel is tracked
|
||||
* against engine hours (excavators, rollers, TLBs, etc.).
|
||||
* If omitted, vehicles without `kmPerL` are excluded from the anomaly
|
||||
* detection rather than treated as zero-consumption.
|
||||
*/
|
||||
expectedLPerHour?: number;
|
||||
}
|
||||
|
||||
export interface FuelReading {
|
||||
id: string;
|
||||
vehicleId: string;
|
||||
date: string; // ISO yyyy-mm-dd
|
||||
hoursWorked: number;
|
||||
odometer: number;
|
||||
litresFilled: number;
|
||||
unitPrice: number;
|
||||
expectedConsumptionPerHour: number; // L/h
|
||||
notes?: string;
|
||||
attachmentUrl?: string;
|
||||
}
|
||||
|
||||
export interface FuelAnomaly {
|
||||
readingId: string;
|
||||
vehicleId: string;
|
||||
date: string;
|
||||
expectedLitres: number;
|
||||
actualLitres: number;
|
||||
deltaLitres: number; // positive = missing fuel, negative = over-recorded
|
||||
expectedDeltaPct: number;
|
||||
severity: "ok" | "watch" | "alert" | "critical";
|
||||
}
|
||||
|
||||
export type OrderStatus = "Awaiting" | "Partial" | "Delivered" | "Discrepancy" | "Cancelled";
|
||||
export type OrderPriority = "Low" | "Normal" | "High" | "Urgent";
|
||||
|
||||
export interface OrderLine {
|
||||
id: string;
|
||||
description: string;
|
||||
quantityOrdered: number;
|
||||
quantityDelivered: number;
|
||||
unit: string;
|
||||
unitPrice: number;
|
||||
}
|
||||
|
||||
export interface DeliveryNote {
|
||||
id: string;
|
||||
orderId: string;
|
||||
receivedAt: string; // ISO timestamp
|
||||
scannedBy: string;
|
||||
fileName: string;
|
||||
fileUrl: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
id: string;
|
||||
reference: string;
|
||||
supplier: string;
|
||||
site: string;
|
||||
status: OrderStatus;
|
||||
priority: OrderPriority;
|
||||
placedAt: string; // ISO timestamp
|
||||
expectedAt: string; // ISO timestamp
|
||||
totalValue: number;
|
||||
notes?: string;
|
||||
lines: OrderLine[];
|
||||
deliveryNotes: DeliveryNote[];
|
||||
}
|
||||
|
||||
export type EmployeeRole =
|
||||
| "Site Foreman"
|
||||
| "Operator"
|
||||
| "Driver"
|
||||
| "Mechanic"
|
||||
| "Electrician"
|
||||
| "Welder"
|
||||
| "Labourer"
|
||||
| "Surveyor"
|
||||
| "Safety Officer";
|
||||
|
||||
export type EmployeeStatus = "Active" | "On Leave" | "Suspended";
|
||||
|
||||
export interface DailyHours {
|
||||
date: string; // ISO yyyy-mm-dd
|
||||
hours: number;
|
||||
}
|
||||
|
||||
export interface Employee {
|
||||
id: string;
|
||||
fullName: string;
|
||||
role: EmployeeRole;
|
||||
site: string;
|
||||
status: EmployeeStatus;
|
||||
phone: string;
|
||||
hourlyRate: number;
|
||||
weeklyTargetHours: number;
|
||||
weeklyHours: number; // current ISO week hours
|
||||
weeklyOvertimeHours: number;
|
||||
dailyHours: DailyHours[]; // last 7 days for chart
|
||||
}
|
||||
|
||||
export interface DashboardKpi {
|
||||
label: string;
|
||||
value: string;
|
||||
delta?: string;
|
||||
trend?: "up" | "down" | "flat";
|
||||
hint?: string;
|
||||
iconName: string;
|
||||
tone?: "default" | "success" | "warning" | "danger" | "info";
|
||||
}
|
||||
|
||||
export type SiteStatus = "Active" | "Mobilising" | "On Hold" | "Completed" | "Warranty";
|
||||
export type SiteType = "Road" | "Port" | "Reclamation" | "Logistics" | "Terminal";
|
||||
|
||||
export interface Site {
|
||||
id: string;
|
||||
name: string;
|
||||
client: string;
|
||||
status: SiteStatus;
|
||||
type: SiteType;
|
||||
contractValue: number;
|
||||
progress: number; // 0-100
|
||||
startDate: string; // ISO yyyy-mm-dd
|
||||
expectedEndDate: string; // ISO yyyy-mm-dd
|
||||
projectManager: string;
|
||||
address: string;
|
||||
headcount: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface SiteFinancialSummary {
|
||||
site: Site;
|
||||
contractValue: number;
|
||||
spentToDate: number;
|
||||
budgetRemaining: number;
|
||||
burnPct: number; // spent / contract * 100
|
||||
activeOrdersCount: number;
|
||||
activeOrdersValue: number;
|
||||
deliveredValue: number;
|
||||
totalOrdersValue: number;
|
||||
hoursThisWeek: number;
|
||||
workersActive: number;
|
||||
vehiclesOnSite: number;
|
||||
fuelCostThisMonth: number;
|
||||
fuelLitresThisMonth: number;
|
||||
weeklyBurn: number;
|
||||
monthlyBurn: number;
|
||||
weeklyPlantHire: number;
|
||||
monthlyPlantHire: number;
|
||||
weeklyPg: number;
|
||||
monthlyPg: number;
|
||||
}
|
||||
|
||||
/** Plant hire rate — what it costs per operational hour, in ZAR. */
|
||||
export interface PlantHireRate {
|
||||
vehicleId: string;
|
||||
ratePerHour: number;
|
||||
}
|
||||
|
||||
export type PgCategory =
|
||||
| "container-hire"
|
||||
| "security"
|
||||
| "salaries"
|
||||
| "overheads"
|
||||
| "insurance"
|
||||
| "site-facilities"
|
||||
| "fuel";
|
||||
|
||||
export interface PgExpense {
|
||||
id: string;
|
||||
category: PgCategory;
|
||||
label: string;
|
||||
/** ZAR per calendar month. */
|
||||
monthlyAmount: number;
|
||||
}
|
||||
|
||||
export type UploadKind = "orders" | "delivery-notes" | "fuel-logs" | "time-sheets";
|
||||
export type UploadStatus = "Processed" | "Pending" | "Failed";
|
||||
|
||||
export interface UploadEntry {
|
||||
id: string;
|
||||
kind: UploadKind;
|
||||
fileName: string;
|
||||
size: number; // bytes
|
||||
uploadedAt: string; // ISO timestamp
|
||||
uploadedBy: string;
|
||||
status: UploadStatus;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface UploadAcceptance {
|
||||
mimes: string[]; // e.g. ["text/csv", "application/vnd.ms-excel"]
|
||||
extensions: string[]; // e.g. [".csv", ".xlsx"]
|
||||
maxBytes: number;
|
||||
description: string;
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import type { UploadEntry, UploadKind, UploadAcceptance } from "./types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Upload acceptance rules — kept populated so the UI on /dashboard/uploads
|
||||
// can still render its format / size hints and the /api/upload endpoint
|
||||
// can still validate incoming files. Once PocketBase is wired, the
|
||||
// history below will be replaced with live queries to the appropriate
|
||||
// PB collection per kind.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const UPLOAD_ACCEPTANCE: Record<UploadKind, UploadAcceptance> = {
|
||||
orders: {
|
||||
mimes: ["text/csv", "application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
|
||||
extensions: [".csv", ".xls", ".xlsx"],
|
||||
maxBytes: 10 * 1024 * 1024, // 10 MB
|
||||
description: "Bulk purchase order lines, supplier invoices, or stock-sheet exports.",
|
||||
},
|
||||
"delivery-notes": {
|
||||
mimes: ["application/pdf", "image/png", "image/jpeg", "image/webp"],
|
||||
extensions: [".pdf", ".png", ".jpg", ".jpeg", ".webp"],
|
||||
maxBytes: 20 * 1024 * 1024, // 20 MB
|
||||
description: "Scanned delivery notes, supplier GRNs, or photos of the docket.",
|
||||
},
|
||||
"fuel-logs": {
|
||||
mimes: ["text/csv", "application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
|
||||
extensions: [".csv", ".xls", ".xlsx"],
|
||||
maxBytes: 5 * 1024 * 1024, // 5 MB
|
||||
description: "Per-vehicle daily fuel-issue exports from the depot system.",
|
||||
},
|
||||
"time-sheets": {
|
||||
mimes: ["text/csv", "application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
|
||||
extensions: [".csv", ".xls", ".xlsx"],
|
||||
maxBytes: 5 * 1024 * 1024,
|
||||
description: "Weekly or daily timesheet exports from the payroll system.",
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Upload history — empty until PocketBase is wired. The shape of each
|
||||
// entry matches `UploadEntry`; once your ingest pipeline lands in
|
||||
// lib/pb.ts, append live records here and the Uploads page will render
|
||||
// them (or replace this export entirely with a PB query).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const UPLOAD_HISTORY: UploadEntry[] = [];
|
||||
|
||||
// Helper for callers that want to filter by kind. Returns an empty array
|
||||
// when `UPLOAD_HISTORY` is empty.
|
||||
export function uploadsByKind(kind: UploadKind): UploadEntry[] {
|
||||
return UPLOAD_HISTORY.filter((u) => u.kind === kind);
|
||||
}
|
||||
Loading…
Reference in a new issue