Add timesheet sync: pull actual hours, daily cron, per-cell source indicators

- fetchTimesheetShifts: hits /api/v2/shifts, subtracts breaks, carries PENDING/APPROVED status
- syncJobs.js: shared runRotaSync/runTimesheetSync logic used by routes and cron
- Rota sync now skips dates already marked source='timesheet'
- POST /api/workforce/sync-timesheets: on-demand pull of last 7 days actual hours
- Daily 6am cron: rota sync then timesheet sync, each logged independently
- upsertWorkforceShifts now writes source per row (workforce or timesheet)
- Frontend: Pull timesheets button alongside Sync rota button
- Clock icon on timesheet cells — amber for PENDING, green for APPROVED
- Both buttons disabled while either sync is in progress

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-22 18:25:02 +00:00
parent 109a4ea194
commit 9bfd14a082
16 changed files with 1129 additions and 212 deletions

723
backend/package-lock.json generated Normal file
View file

@ -0,0 +1,723 @@
{
"name": "hnf-hk-planner-backend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hnf-hk-planner-backend",
"version": "1.0.0",
"dependencies": {
"@fastify/cookie": "^9.4.0",
"@fastify/cors": "^9.0.1",
"fastify": "^4.28.1",
"jose": "^5.9.6",
"node-cron": "^4.6.0",
"pg": "^8.13.1"
}
},
"node_modules/@fastify/ajv-compiler": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz",
"integrity": "sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==",
"license": "MIT",
"dependencies": {
"ajv": "^8.11.0",
"ajv-formats": "^2.1.1",
"fast-uri": "^2.0.0"
}
},
"node_modules/@fastify/cookie": {
"version": "9.4.0",
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-9.4.0.tgz",
"integrity": "sha512-Th+pt3kEkh4MQD/Q2q1bMuJIB5NX/D5SwSpOKu3G/tjoGbwfpurIMJsWSPS0SJJ4eyjtmQ8OipDQspf8RbUOlg==",
"license": "MIT",
"dependencies": {
"cookie-signature": "^1.1.0",
"fastify-plugin": "^4.0.0"
}
},
"node_modules/@fastify/cors": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-9.0.1.tgz",
"integrity": "sha512-YY9Ho3ovI+QHIL2hW+9X4XqQjXLjJqsU+sMV/xFsxZkE8p3GNnYVFpoOxF7SsP5ZL76gwvbo3V9L+FIekBGU4Q==",
"license": "MIT",
"dependencies": {
"fastify-plugin": "^4.0.0",
"mnemonist": "0.39.6"
}
},
"node_modules/@fastify/error": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz",
"integrity": "sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==",
"license": "MIT"
},
"node_modules/@fastify/fast-json-stringify-compiler": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-4.3.0.tgz",
"integrity": "sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==",
"license": "MIT",
"dependencies": {
"fast-json-stringify": "^5.7.0"
}
},
"node_modules/@fastify/merge-json-schemas": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.1.1.tgz",
"integrity": "sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/abstract-logging": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
"license": "MIT"
},
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/ajv/node_modules/fast-uri": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/avvio": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/avvio/-/avvio-8.4.0.tgz",
"integrity": "sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==",
"license": "MIT",
"dependencies": {
"@fastify/error": "^3.3.0",
"fastq": "^1.17.1"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/fast-content-type-parse": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz",
"integrity": "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==",
"license": "MIT"
},
"node_modules/fast-decode-uri-component": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
"integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-json-stringify": {
"version": "5.16.1",
"resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-5.16.1.tgz",
"integrity": "sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==",
"license": "MIT",
"dependencies": {
"@fastify/merge-json-schemas": "^0.1.0",
"ajv": "^8.10.0",
"ajv-formats": "^3.0.1",
"fast-deep-equal": "^3.1.3",
"fast-uri": "^2.1.0",
"json-schema-ref-resolver": "^1.0.1",
"rfdc": "^1.2.0"
}
},
"node_modules/fast-json-stringify/node_modules/ajv-formats": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/fast-querystring": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz",
"integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==",
"license": "MIT",
"dependencies": {
"fast-decode-uri-component": "^1.0.1"
}
},
"node_modules/fast-uri": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.3.tgz",
"integrity": "sha512-8V8UrSDUkYpi4AXM7Na0G6hctXSaRHBGMuANOotuFdHEFtTdqDTRNfcDczA9WkKODI17o7o10iQvzdMIxXb8eA==",
"license": "MIT"
},
"node_modules/fastify": {
"version": "4.29.1",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.1.tgz",
"integrity": "sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/ajv-compiler": "^3.5.0",
"@fastify/error": "^3.4.0",
"@fastify/fast-json-stringify-compiler": "^4.3.0",
"abstract-logging": "^2.0.1",
"avvio": "^8.3.0",
"fast-content-type-parse": "^1.1.0",
"fast-json-stringify": "^5.8.0",
"find-my-way": "^8.0.0",
"light-my-request": "^5.11.0",
"pino": "^9.0.0",
"process-warning": "^3.0.0",
"proxy-addr": "^2.0.7",
"rfdc": "^1.3.0",
"secure-json-parse": "^2.7.0",
"semver": "^7.5.4",
"toad-cache": "^3.3.0"
}
},
"node_modules/fastify-plugin": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.1.tgz",
"integrity": "sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==",
"license": "MIT"
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
},
"node_modules/find-my-way": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz",
"integrity": "sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-querystring": "^1.0.0",
"safe-regex2": "^3.1.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/jose": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/json-schema-ref-resolver": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz",
"integrity": "sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3"
}
},
"node_modules/json-schema-traverse": {
"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"
},
"node_modules/light-my-request": {
"version": "5.14.0",
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz",
"integrity": "sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==",
"license": "BSD-3-Clause",
"dependencies": {
"cookie": "^0.7.0",
"process-warning": "^3.0.0",
"set-cookie-parser": "^2.4.1"
}
},
"node_modules/mnemonist": {
"version": "0.39.6",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.6.tgz",
"integrity": "sha512-A/0v5Z59y63US00cRSLiloEIw3t5G+MiKz4BhX21FI+YBJXBOGW0ohFxTxO08dsOYlzxo87T7vGfZKYp2bcAWA==",
"license": "MIT",
"dependencies": {
"obliterator": "^2.0.1"
}
},
"node_modules/node-cron": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.6.0.tgz",
"integrity": "sha512-Si/bzYiKRHOB8/a99T2+SDGN582ONDMSTlJr5oCkT6GtnqPjZ2s10eoQRYkW9ZHwjVxONL+W8Fb+qR0AHMQsdg==",
"license": "ISC",
"engines": {
"node": ">=20"
}
},
"node_modules/obliterator": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
"integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==",
"license": "MIT"
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/pg": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.15.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/pino": {
"version": "9.14.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
"integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^2.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^3.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
"integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/pino/node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/process-warning": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz",
"integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ret": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/ret/-/ret-0.4.3.tgz",
"integrity": "sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/safe-regex2": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz",
"integrity": "sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==",
"license": "MIT",
"dependencies": {
"ret": "~0.4.0"
}
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/secure-json-parse": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz",
"integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==",
"license": "BSD-3-Clause"
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/thread-stream": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz",
"integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==",
"license": "MIT",
"dependencies": {
"real-require": "^0.2.0"
}
},
"node_modules/toad-cache": {
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz",
"integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
}
}
}

View file

@ -11,6 +11,7 @@
"@fastify/cors": "^9.0.1", "@fastify/cors": "^9.0.1",
"fastify": "^4.28.1", "fastify": "^4.28.1",
"jose": "^5.9.6", "jose": "^5.9.6",
"node-cron": "^4.6.0",
"pg": "^8.13.1" "pg": "^8.13.1"
} }
} }

View file

@ -54,9 +54,9 @@ export async function upsertWorkforceShifts(dailyRows) {
for (const row of dailyRows) { for (const row of dailyRows) {
await client.query( await client.query(
`INSERT INTO workforce_daily_shifts (date, synced_at, source, staff) `INSERT INTO workforce_daily_shifts (date, synced_at, source, staff)
VALUES ($1, now(), 'workforce', $2::jsonb) VALUES ($1, now(), $2, $3::jsonb)
ON CONFLICT (date) DO UPDATE SET synced_at = now(), staff = EXCLUDED.staff`, ON CONFLICT (date) DO UPDATE SET synced_at = now(), source = EXCLUDED.source, staff = EXCLUDED.staff`,
[row.date, JSON.stringify(row.staff)] [row.date, row.source || 'workforce', JSON.stringify(row.staff)]
) )
} }
await client.query('COMMIT') await client.query('COMMIT')

View file

@ -1,10 +1,12 @@
import Fastify from 'fastify' import Fastify from 'fastify'
import cookie from '@fastify/cookie' import cookie from '@fastify/cookie'
import cors from '@fastify/cors' import cors from '@fastify/cors'
import cron from 'node-cron'
import { initDb } from './db.js' import { initDb } from './db.js'
import { bookingRoutes } from './routes/bookings.js' import { bookingRoutes } from './routes/bookings.js'
import { configRoutes } from './routes/config.js' import { configRoutes } from './routes/config.js'
import { workforceRoutes } from './routes/workforce.js' import { workforceRoutes } from './routes/workforce.js'
import { runRotaSync, runTimesheetSync } from './lib/syncJobs.js'
const app = Fastify({ logger: true, trustProxy: true }) const app = Fastify({ logger: true, trustProxy: true })
const startedAt = Date.now() const startedAt = Date.now()
@ -25,3 +27,20 @@ try {
app.log.error(err) app.log.error(err)
process.exit(1) process.exit(1)
} }
// Daily 6am: rota sync (today-7 → today+28) then timesheet sync (last 7 days)
cron.schedule('0 6 * * *', async () => {
app.log.info('cron: starting daily workforce sync')
try {
const r = await runRotaSync()
app.log.info({ dates_synced: r.dates_synced }, 'cron: rota sync complete')
} catch (err) {
app.log.error({ err: err.message }, 'cron: rota sync failed')
}
try {
const r = await runTimesheetSync()
app.log.info({ dates_synced: r.dates_synced }, 'cron: timesheet sync complete')
} catch (err) {
app.log.error({ err: err.message }, 'cron: timesheet sync failed')
}
})

View file

@ -0,0 +1,94 @@
import { getConfig, getWorkforceShifts, upsertWorkforceShifts } from '../db.js'
import { fetchShifts, fetchTimesheetShifts } from './workforce.js'
function fmtDate(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
export async function runRotaSync() {
const deptIds = (await getConfig('workforce_departments', [])) || []
if (!deptIds.length) throw new Error('No HK departments selected — configure in Category Settings')
const today = new Date()
const fromDate = new Date(today); fromDate.setDate(fromDate.getDate() - 7)
const toDate = new Date(today); toDate.setDate(toDate.getDate() + 28)
const from = fmtDate(fromDate)
const to = fmtDate(toDate)
// API limit: max 7 days per request — split into weekly chunks
const chunks = []
const cur = new Date(fromDate)
while (cur <= toDate) {
const chunkFrom = fmtDate(cur)
const chunkToDate = new Date(cur)
chunkToDate.setDate(chunkToDate.getDate() + 6)
if (chunkToDate > toDate) chunkToDate.setTime(toDate.getTime())
chunks.push({ from: chunkFrom, to: fmtDate(chunkToDate) })
cur.setDate(cur.getDate() + 7)
}
const staffMap = {}
for (const chunk of chunks) {
const chunkStaff = await fetchShifts(chunk.from, chunk.to, deptIds)
for (const member of chunkStaff) {
if (!staffMap[member.id]) staffMap[member.id] = { id: member.id, name: member.name, days: {} }
Object.assign(staffMap[member.id].days, member.days)
}
}
const byDate = {}
for (const member of Object.values(staffMap)) {
for (const [date, shift] of Object.entries(member.days)) {
if (!byDate[date]) byDate[date] = []
byDate[date].push({ id: member.id, name: member.name, hours: shift.hours, times: shift.times })
}
}
// Skip dates already replaced with timesheet actuals
const existing = await getWorkforceShifts(from, to)
const timesheetDates = new Set(
Object.entries(existing).filter(([, d]) => d.source === 'timesheet').map(([date]) => date)
)
const dailyRows = []
const day = new Date(fromDate)
while (day <= toDate) {
const d = fmtDate(day)
if (!timesheetDates.has(d)) dailyRows.push({ date: d, staff: byDate[d] || [], source: 'workforce' })
day.setDate(day.getDate() + 1)
}
await upsertWorkforceShifts(dailyRows)
return { ok: true, from, to, dates_synced: dailyRows.length }
}
export async function runTimesheetSync(days = 7) {
const deptIds = (await getConfig('workforce_departments', [])) || []
if (!deptIds.length) throw new Error('No HK departments selected — configure in Category Settings')
const today = new Date()
const fromDate = new Date(today); fromDate.setDate(fromDate.getDate() - days)
const from = fmtDate(fromDate)
const to = fmtDate(today)
const staffList = await fetchTimesheetShifts(from, to, deptIds)
const byDate = {}
for (const member of staffList) {
for (const [date, shift] of Object.entries(member.days)) {
if (!byDate[date]) byDate[date] = []
byDate[date].push({ id: member.id, name: member.name, hours: shift.hours, times: shift.times, status: shift.status })
}
}
const dailyRows = []
const day = new Date(fromDate)
while (day <= today) {
const d = fmtDate(day)
dailyRows.push({ date: d, staff: byDate[d] || [], source: 'timesheet' })
day.setDate(day.getDate() + 1)
}
await upsertWorkforceShifts(dailyRows)
return { ok: true, from, to, dates_synced: dailyRows.length }
}

View file

@ -72,6 +72,54 @@ export async function fetchStaff(deptIds) {
.map(u => ({ id: String(u.id), name: u.name })) .map(u => ({ id: String(u.id), name: u.name }))
} }
export async function fetchTimesheetShifts(from, to, deptIds) {
const creds = await getWorkforceCreds()
const locationId = creds.location_id
const staffList = await fetchStaff(deptIds)
const nameMap = Object.fromEntries(staffList.map(s => [s.id, s.name]))
let path = `/api/v2/shifts?from=${from}&to=${to}`
if (locationId) path += `&report_location_id=${locationId}`
const shifts = await wfFetchPaged(path)
const filtered = shifts.filter(s => deptIds.includes(String(s.department_id)))
const byUser = {}
for (const s of filtered) {
const uid = String(s.user_id)
if (!nameMap[uid]) continue
const date = s.date // already 'YYYY-MM-DD'
const hrs = (s.finish && s.start)
? Math.max(0, (s.finish - s.start) / 3600 - (s.break_length || 0) / 60)
: 0
if (!byUser[uid]) byUser[uid] = {}
if (!byUser[uid][date]) byUser[uid][date] = { hours: 0, shifts: [], status: s.status }
byUser[uid][date].hours += hrs
byUser[uid][date].shifts.push({ start: s.start, finish: s.finish })
if (s.status === 'APPROVED') byUser[uid][date].status = 'APPROVED'
}
return Object.entries(byUser).map(([uid, days]) => ({
id: uid,
name: nameMap[uid],
days: Object.fromEntries(
Object.entries(days).map(([date, data]) => {
const sorted = data.shifts.sort((a, b) => a.start - b.start)
const firstStart = sorted[0]?.start
const lastFinish = sorted[sorted.length - 1]?.finish
const times = firstStart && lastFinish
? sorted.length > 1
? `${fmtTime(firstStart)}${fmtTime(lastFinish)} (${sorted.length} shifts)`
: `${fmtTime(firstStart)}${fmtTime(lastFinish)}`
: '?'
return [date, { hours: parseFloat(data.hours.toFixed(2)), times, status: data.status }]
})
),
}))
}
export async function fetchShifts(from, to, deptIds) { export async function fetchShifts(from, to, deptIds) {
const creds = await getWorkforceCreds() const creds = await getWorkforceCreds()
const locationId = creds.location_id const locationId = creds.location_id

View file

@ -1,9 +1,10 @@
import { requireAuth, requireCap } from '../auth.js' import { requireAuth, requireCap } from '../auth.js'
import { getConfig, setConfig, getWorkforceShifts, upsertWorkforceShifts } from '../db.js' import { getConfig, setConfig, getWorkforceShifts } from '../db.js'
import { fetchDepartments, fetchStaff, fetchShifts } from '../lib/workforce.js' import { fetchDepartments, fetchStaff } from '../lib/workforce.js'
import { runRotaSync, runTimesheetSync } from '../lib/syncJobs.js'
function fmtDate(d) { function errStatus(msg) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` return msg.includes('not configured') ? 503 : 502
} }
export async function workforceRoutes(app) { export async function workforceRoutes(app) {
@ -15,72 +16,29 @@ export async function workforceRoutes(app) {
try { try {
return await fetchDepartments() return await fetchDepartments()
} catch (err) { } catch (err) {
const status = err.message.includes('not configured') ? 503 : 502 return reply.status(errStatus(err.message)).send({ error: err.message })
return reply.status(status).send({ error: err.message })
} }
}) })
// ── POST /api/workforce/sync — rolling window: today-7 → today+28 ─────────── // ── POST /api/workforce/sync — rolling rota window: today-7 → today+28 ──────
app.post('/api/workforce/sync', { preHandler: requireCap('planner') }, async (req, reply) => { app.post('/api/workforce/sync', { preHandler: requireCap('planner') }, async (req, reply) => {
const deptIds = (await getConfig('workforce_departments', [])) || []
if (!deptIds.length) {
return reply.status(400).send({ error: 'No HK departments selected — configure in Category Settings' })
}
const today = new Date()
const fromDate = new Date(today); fromDate.setDate(fromDate.getDate() - 7)
const toDate = new Date(today); toDate.setDate(toDate.getDate() + 28)
const from = fmtDate(fromDate)
const to = fmtDate(toDate)
try { try {
// API limit: max 7 days per request — split window into weekly chunks return await runRotaSync()
const chunks = []
const cur = new Date(fromDate)
while (cur <= toDate) {
const chunkFrom = fmtDate(cur)
const chunkToDate = new Date(cur)
chunkToDate.setDate(chunkToDate.getDate() + 6)
if (chunkToDate > toDate) chunkToDate.setTime(toDate.getTime())
chunks.push({ from: chunkFrom, to: fmtDate(chunkToDate) })
cur.setDate(cur.getDate() + 7)
}
const staffMap = {}
for (const chunk of chunks) {
const chunkStaff = await fetchShifts(chunk.from, chunk.to, deptIds)
for (const member of chunkStaff) {
if (!staffMap[member.id]) staffMap[member.id] = { id: member.id, name: member.name, days: {} }
Object.assign(staffMap[member.id].days, member.days)
}
}
const staffList = Object.values(staffMap)
// Pivot per-member → per-date
const byDate = {}
for (const member of staffList) {
for (const [date, shift] of Object.entries(member.days)) {
if (!byDate[date]) byDate[date] = []
byDate[date].push({ id: member.id, name: member.name, hours: shift.hours, times: shift.times })
}
}
// Build a row for every date in the window (empty staff = no one scheduled)
const dailyRows = []
const day = new Date(fromDate)
while (day <= toDate) {
const d = fmtDate(day)
dailyRows.push({ date: d, staff: byDate[d] || [] })
day.setDate(day.getDate() + 1)
}
await upsertWorkforceShifts(dailyRows)
return { ok: true, from, to, dates_synced: dailyRows.length }
} catch (err) { } catch (err) {
req.log.error({ err: err.message }, 'workforce sync failed') req.log.error({ err: err.message }, 'rota sync failed')
const status = err.message.includes('not configured') ? 503 : 502 return reply.status(errStatus(err.message)).send({ error: err.message })
return reply.status(status).send({ error: err.message }) }
})
// ── POST /api/workforce/sync-timesheets — last 7 days of actual hours ────────
app.post('/api/workforce/sync-timesheets', { preHandler: requireCap('planner') }, async (req, reply) => {
try {
return await runTimesheetSync()
} catch (err) {
req.log.error({ err: err.message }, 'timesheet sync failed')
return reply.status(errStatus(err.message)).send({ error: err.message })
} }
}) })
@ -108,8 +66,7 @@ export async function workforceRoutes(app) {
await setConfig('workforce_staff_cache', { fetched_at: new Date().toISOString(), staff }) await setConfig('workforce_staff_cache', { fetched_at: new Date().toISOString(), staff })
return staff return staff
} catch (err) { } catch (err) {
const status = err.message.includes('not configured') ? 503 : 502 return reply.status(errStatus(err.message)).send({ error: err.message })
return reply.status(status).send({ error: err.message })
} }
}) })
} }

File diff suppressed because one or more lines are too long

132
frontend/dist/assets/index-C9TVzZ7i.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -5,7 +5,7 @@
<meta name="viewport" content="width=1280" /> <meta name="viewport" content="width=1280" />
<meta name="theme-color" content="#2d6a4f" /> <meta name="theme-color" content="#2d6a4f" />
<title>HK Planner</title> <title>HK Planner</title>
<script type="module" crossorigin src="/hk-planner/assets/index-C1fxqzme.js"></script> <script type="module" crossorigin src="/hk-planner/assets/index-C9TVzZ7i.js"></script>
<link rel="stylesheet" crossorigin href="/hk-planner/assets/index-B7_UXJgZ.css"> <link rel="stylesheet" crossorigin href="/hk-planner/assets/index-B7_UXJgZ.css">
<link rel="manifest" href="/hk-planner/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/hk-planner/registerSW.js"></script></head> <link rel="manifest" href="/hk-planner/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/hk-planner/registerSW.js"></script></head>
<body> <body>

2
frontend/dist/sw.js vendored
View file

@ -1 +1 @@
if(!self.define){let e,i={};const n=(n,s)=>(n=new URL(n+".js",s).href,i[n]||new Promise(i=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=i,document.head.appendChild(e)}else e=n,importScripts(n),i()}).then(()=>{let e=i[n];if(!e)throw new Error(`Module ${n} didnt register its module`);return e}));self.define=(s,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(i[o])return;let t={};const c=e=>n(e,o),d={module:{uri:o},exports:t,require:c};i[o]=Promise.all(s.map(e=>d[e]||c(e))).then(e=>(r(...e),t))}}define(["./workbox-9c191d2f"],function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"registerSW.js",revision:"a2c395d8c225f1b3ea12388f15189bce"},{url:"index.html",revision:"69c09c935ebcd7adcba85b212773b6f6"},{url:"icons/icon-512.png",revision:"c32202b9deed67ef38331f63dec9d1c8"},{url:"icons/icon-192.png",revision:"46ece317d50d10b8f5e225e073b3221d"},{url:"assets/index-C1fxqzme.js",revision:null},{url:"assets/index-B7_UXJgZ.css",revision:null},{url:"manifest.webmanifest",revision:"c2510de876adb84db0c4b300b71216fa"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/hk-planner/index.html"),{denylist:[/\/api\//]}))}); if(!self.define){let e,i={};const n=(n,s)=>(n=new URL(n+".js",s).href,i[n]||new Promise(i=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=i,document.head.appendChild(e)}else e=n,importScripts(n),i()}).then(()=>{let e=i[n];if(!e)throw new Error(`Module ${n} didnt register its module`);return e}));self.define=(s,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(i[o])return;let t={};const c=e=>n(e,o),d={module:{uri:o},exports:t,require:c};i[o]=Promise.all(s.map(e=>d[e]||c(e))).then(e=>(r(...e),t))}}define(["./workbox-9c191d2f"],function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"registerSW.js",revision:"a2c395d8c225f1b3ea12388f15189bce"},{url:"index.html",revision:"3b1c49874f107c5a3d6697cd6a89320e"},{url:"icons/icon-512.png",revision:"c32202b9deed67ef38331f63dec9d1c8"},{url:"icons/icon-192.png",revision:"46ece317d50d10b8f5e225e073b3221d"},{url:"assets/index-C9TVzZ7i.js",revision:null},{url:"assets/index-B7_UXJgZ.css",revision:null},{url:"manifest.webmanifest",revision:"c2510de876adb84db0c4b300b71216fa"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/hk-planner/index.html"),{denylist:[/\/api\//]}))});

View file

@ -86,6 +86,9 @@ export function putWorkforceDepartments(dept_ids) {
export function syncWorkforce() { export function syncWorkforce() {
return request('/workforce/sync', { method: 'POST' }); return request('/workforce/sync', { method: 'POST' });
} }
export function syncTimesheets() {
return request('/workforce/sync-timesheets', { method: 'POST' });
}
export function getWorkforceShifts(start, end) { export function getWorkforceShifts(start, end) {
return request(`/workforce/shifts?start=${start}&end=${end}`); return request(`/workforce/shifts?start=${start}&end=${end}`);
} }

View file

@ -120,6 +120,10 @@ export function syncWorkforce(): Promise<{ ok: boolean; from: string; to: string
return request('/workforce/sync', { method: 'POST' }) return request('/workforce/sync', { method: 'POST' })
} }
export function syncTimesheets(): Promise<{ ok: boolean; from: string; to: string; dates_synced: number }> {
return request('/workforce/sync-timesheets', { method: 'POST' })
}
export function getWorkforceShifts(start: string, end: string): Promise<Record<string, WfDayData>> { export function getWorkforceShifts(start: string, end: string): Promise<Record<string, WfDayData>> {
return request(`/workforce/shifts?start=${start}&end=${end}`) return request(`/workforce/shifts?start=${start}&end=${end}`)
} }

View file

@ -1,7 +1,7 @@
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { RefreshCw } from 'lucide-react'; import { RefreshCw, Clock } from 'lucide-react';
import { getBookings, getConfig, putStaff, putPickup, putLastReviewed, putAdjustments, syncWorkforce, getWorkforceShifts, getWorkforceStaff, } from '../api'; import { getBookings, getConfig, putStaff, putPickup, putLastReviewed, putAdjustments, syncWorkforce, syncTimesheets, getWorkforceShifts, getWorkforceStaff, } from '../api';
// ── Date helpers ────────────────────────────────────────────────────────────── // ── Date helpers ──────────────────────────────────────────────────────────────
function todayStr() { function todayStr() {
const d = new Date(); const d = new Date();
@ -104,6 +104,7 @@ export function Planner() {
const [wfShifts, setWfShifts] = useState({}); const [wfShifts, setWfShifts] = useState({});
const [wfStaff, setWfStaff] = useState([]); const [wfStaff, setWfStaff] = useState([]);
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
const [timesheetSyncing, setTimesheetSyncing] = useState(false);
const [weekStart, setWeekStart] = useState(null); const [weekStart, setWeekStart] = useState(null);
const [lastViewed, setLastViewed] = useState(''); const [lastViewed, setLastViewed] = useState('');
const [savedLastReviewed, setSavedLastReviewed] = useState(''); const [savedLastReviewed, setSavedLastReviewed] = useState('');
@ -265,6 +266,23 @@ export function Planner() {
setSyncing(false); setSyncing(false);
} }
} }
async function syncTimesheetData() {
setTimesheetSyncing(true);
try {
await syncTimesheets();
if (bookings) {
const shifts = await getWorkforceShifts(bookings.dates[0], bookings.dates[bookings.dates.length - 1]);
setWfShifts(shifts);
}
flash('Timesheets pulled from Workforce');
}
catch (e) {
flash(e instanceof Error ? e.message : 'Timesheet sync failed', true);
}
finally {
setTimesheetSyncing(false);
}
}
function wfSyncLabel() { function wfSyncLabel() {
if (!bookings) if (!bookings)
return ''; return '';
@ -305,12 +323,17 @@ export function Planner() {
}, children: saveMsg.text })), error && (_jsxs("div", { style: { }, children: saveMsg.text })), error && (_jsxs("div", { style: {
marginBottom: '1rem', padding: '0.75rem 1rem', borderRadius: '8px', marginBottom: '1rem', padding: '0.75rem 1rem', borderRadius: '8px',
background: '#fee2e2', color: 'var(--danger)', fontSize: '0.875rem', background: '#fee2e2', color: 'var(--danger)', fontSize: '0.875rem',
}, children: ["Error: ", error] })), loading && !bookings && (_jsx("div", { style: { color: 'var(--text-mid)', padding: '2rem 0', textAlign: 'center' }, children: "Loading bookings\u2026" })), bookings && required && (_jsxs(_Fragment, { children: [_jsx(Section, { title: "7-Day Occupancy", children: _jsx("div", { className: "table-scroll", children: _jsx(SummaryTable, { bookings: bookings, pickup: pickup, onPickupChange: handlePickupChange }) }) }), _jsx(Section, { title: "Required Hours", children: _jsx("div", { className: "table-scroll", children: _jsx(RequiredTable, { bookings: bookings, required: required }) }) }), _jsx(Section, { title: "Adjustments", action: _jsx(CtrlBtn, { onClick: addAdjustmentRow, children: "+ Add adjustment" }), children: _jsx("div", { className: "table-scroll", children: _jsx(AdjustmentsTable, { bookings: bookings, adjustments: adjustments, onChange: handleAdjustmentsChange }) }) }), _jsx(Section, { title: "Staff Rota", action: _jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }, children: [_jsxs("button", { onClick: syncRota, disabled: syncing, style: { }, children: ["Error: ", error] })), loading && !bookings && (_jsx("div", { style: { color: 'var(--text-mid)', padding: '2rem 0', textAlign: 'center' }, children: "Loading bookings\u2026" })), bookings && required && (_jsxs(_Fragment, { children: [_jsx(Section, { title: "7-Day Occupancy", children: _jsx("div", { className: "table-scroll", children: _jsx(SummaryTable, { bookings: bookings, pickup: pickup, onPickupChange: handlePickupChange }) }) }), _jsx(Section, { title: "Required Hours", children: _jsx("div", { className: "table-scroll", children: _jsx(RequiredTable, { bookings: bookings, required: required }) }) }), _jsx(Section, { title: "Adjustments", action: _jsx(CtrlBtn, { onClick: addAdjustmentRow, children: "+ Add adjustment" }), children: _jsx("div", { className: "table-scroll", children: _jsx(AdjustmentsTable, { bookings: bookings, adjustments: adjustments, onChange: handleAdjustmentsChange }) }) }), _jsx(Section, { title: "Staff Rota", action: _jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }, children: [_jsxs("button", { onClick: syncRota, disabled: syncing || timesheetSyncing, style: {
display: 'flex', alignItems: 'center', gap: '0.3rem', display: 'flex', alignItems: 'center', gap: '0.3rem',
background: 'var(--card-bg)', color: 'var(--text-dark)', background: 'var(--card-bg)', color: 'var(--text-dark)',
border: '1px solid var(--card-border)', borderRadius: '6px', border: '1px solid var(--card-border)', borderRadius: '6px',
padding: '0.3rem 0.65rem', fontSize: '0.78rem', fontWeight: 600, padding: '0.3rem 0.65rem', fontSize: '0.78rem', fontWeight: 600,
}, children: [_jsx(RefreshCw, { size: 11, strokeWidth: 1.75, style: { animation: syncing ? 'spin 1s linear infinite' : 'none' } }), syncing ? 'Syncing…' : 'Sync from Workforce'] }), _jsx("span", { style: { fontSize: '0.72rem', color: 'var(--text-mid)' }, children: wfSyncLabel() }), _jsx(CtrlBtn, { onClick: addStaffRow, children: "+ Add staff" })] }), children: _jsx("div", { className: "table-scroll", children: _jsx(StaffTable, { bookings: bookings, staff: staff, required: required, warnOverRed: warnOverRed, warnOverAmber: warnOverAmber, warnUnderAmber: warnUnderAmber, warnUnderRed: warnUnderRed, onChange: handleStaffChange, wfShifts: wfShifts, wfStaff: wfStaff }) }) })] }))] })); }, children: [_jsx(RefreshCw, { size: 11, strokeWidth: 1.75, style: { animation: syncing ? 'spin 1s linear infinite' : 'none' } }), syncing ? 'Syncing…' : 'Sync rota'] }), _jsxs("button", { onClick: syncTimesheetData, disabled: syncing || timesheetSyncing, style: {
display: 'flex', alignItems: 'center', gap: '0.3rem',
background: 'var(--card-bg)', color: 'var(--text-dark)',
border: '1px solid var(--card-border)', borderRadius: '6px',
padding: '0.3rem 0.65rem', fontSize: '0.78rem', fontWeight: 600,
}, children: [_jsx(Clock, { size: 11, strokeWidth: 1.75, style: { animation: timesheetSyncing ? 'spin 1s linear infinite' : 'none' } }), timesheetSyncing ? 'Pulling…' : 'Pull timesheets'] }), _jsx("span", { style: { fontSize: '0.72rem', color: 'var(--text-mid)' }, children: wfSyncLabel() }), _jsx(CtrlBtn, { onClick: addStaffRow, children: "+ Add staff" })] }), children: _jsx("div", { className: "table-scroll", children: _jsx(StaffTable, { bookings: bookings, staff: staff, required: required, warnOverRed: warnOverRed, warnOverAmber: warnOverAmber, warnUnderAmber: warnUnderAmber, warnUnderRed: warnUnderRed, onChange: handleStaffChange, wfShifts: wfShifts, wfStaff: wfStaff }) }) })] }))] }));
} }
// ── Section wrapper ─────────────────────────────────────────────────────────── // ── Section wrapper ───────────────────────────────────────────────────────────
function Section({ title, children, action }) { function Section({ title, children, action }) {
@ -433,7 +456,7 @@ function pivotWfShifts(wfShifts, dates) {
for (const s of day.staff) { for (const s of day.staff) {
if (!members[s.id]) if (!members[s.id])
members[s.id] = { id: s.id, name: s.name, days: {} }; members[s.id] = { id: s.id, name: s.name, days: {} };
members[s.id].days[date] = { hours: s.hours, times: s.times }; members[s.id].days[date] = { hours: s.hours, times: s.times, status: s.status };
} }
} }
return Object.values(members).sort((a, b) => a.name.localeCompare(b.name)); return Object.values(members).sort((a, b) => a.name.localeCompare(b.name));
@ -468,7 +491,9 @@ function StaffTable({ bookings, staff, required, warnOverRed, warnOverAmber, war
borderRadius: '3px', padding: '0 4px', marginRight: '0.4rem', lineHeight: '1.5', borderRadius: '3px', padding: '0 4px', marginRight: '0.4rem', lineHeight: '1.5',
}, children: "WF" }), member.name] }), dates.map(date => { }, children: "WF" }), member.name] }), dates.map(date => {
const shift = member.days[date]; const shift = member.days[date];
return (_jsx("td", { style: { textAlign: 'center', padding: '0.3rem 0.4rem', verticalAlign: 'middle' }, children: shift ? (_jsxs(_Fragment, { children: [_jsx("div", { style: { fontSize: '0.68rem', color: 'var(--text-mid)', lineHeight: 1.25 }, children: shift.times }), _jsx("div", { style: { fontWeight: 600 }, children: fmtH(shift.hours) })] })) : _jsx("span", { style: { color: 'var(--text-mid)' }, children: "\u2014" }) }, date)); const isTimesheet = wfShifts[date]?.source === 'timesheet';
const isPending = isTimesheet && shift?.status !== 'APPROVED';
return (_jsx("td", { style: { textAlign: 'center', padding: '0.3rem 0.4rem', verticalAlign: 'middle' }, children: shift ? (_jsxs(_Fragment, { children: [_jsx("div", { style: { fontSize: '0.68rem', color: 'var(--text-mid)', lineHeight: 1.25 }, children: shift.times }), _jsxs("div", { style: { fontWeight: 600, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.2rem' }, children: [isTimesheet && (_jsx(Clock, { size: 10, strokeWidth: 1.75, style: { color: isPending ? '#d97706' : '#1a7a4a', flexShrink: 0 } })), fmtH(shift.hours)] })] })) : _jsx("span", { style: { color: 'var(--text-mid)' }, children: "\u2014" }) }, date));
}), _jsx("td", {})] }, 'wf-' + member.id))), wfStaff.length > 0 && (_jsx("datalist", { id: "wf-staff-datalist", children: wfStaff.map(s => _jsx("option", { value: s.name }, s.id)) })), staff.map((member, i) => (_jsxs("tr", { children: [_jsx("td", { className: "col-label", style: { padding: '0.3rem 0.5rem' }, children: _jsx("input", { className: "hk-text-input", value: member.name, placeholder: "Staff name", list: wfStaff.length > 0 ? 'wf-staff-datalist' : undefined, onChange: e => setMemberName(i, e.target.value) }) }), dates.map(date => (_jsx("td", { style: { padding: '0.3rem 0.4rem' }, children: _jsx("input", { type: "number", className: "hk-num-input", min: 0, max: 24, step: 0.5, value: member.hours[date] ?? '', placeholder: "0", onChange: e => setMemberHours(i, date, e.target.value) }) }, date))), _jsx("td", { children: _jsx("button", { onClick: () => removeRow(i), style: { }), _jsx("td", {})] }, 'wf-' + member.id))), wfStaff.length > 0 && (_jsx("datalist", { id: "wf-staff-datalist", children: wfStaff.map(s => _jsx("option", { value: s.name }, s.id)) })), staff.map((member, i) => (_jsxs("tr", { children: [_jsx("td", { className: "col-label", style: { padding: '0.3rem 0.5rem' }, children: _jsx("input", { className: "hk-text-input", value: member.name, placeholder: "Staff name", list: wfStaff.length > 0 ? 'wf-staff-datalist' : undefined, onChange: e => setMemberName(i, e.target.value) }) }), dates.map(date => (_jsx("td", { style: { padding: '0.3rem 0.4rem' }, children: _jsx("input", { type: "number", className: "hk-num-input", min: 0, max: 24, step: 0.5, value: member.hours[date] ?? '', placeholder: "0", onChange: e => setMemberHours(i, date, e.target.value) }) }, date))), _jsx("td", { children: _jsx("button", { onClick: () => removeRow(i), style: {
background: 'none', border: 'none', color: 'var(--text-mid)', background: 'none', border: 'none', color: 'var(--text-mid)',
fontSize: '1rem', padding: '0.2rem 0.4rem', fontSize: '1rem', padding: '0.2rem 0.4rem',

View file

@ -1,8 +1,8 @@
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import { RefreshCw } from 'lucide-react' import { RefreshCw, Clock } from 'lucide-react'
import { import {
getBookings, getConfig, putStaff, putPickup, getBookings, getConfig, putStaff, putPickup,
putLastReviewed, putAdjustments, syncWorkforce, getWorkforceShifts, getWorkforceStaff, putLastReviewed, putAdjustments, syncWorkforce, syncTimesheets, getWorkforceShifts, getWorkforceStaff,
} from '../api' } from '../api'
import type { import type {
BookingsData, TimeReqs, StaffMember, GeneralTask, BookingsData, TimeReqs, StaffMember, GeneralTask,
@ -136,6 +136,7 @@ export function Planner() {
const [wfShifts, setWfShifts] = useState<Record<string, WfDayData>>({}) const [wfShifts, setWfShifts] = useState<Record<string, WfDayData>>({})
const [wfStaff, setWfStaff] = useState<{ id: string; name: string }[]>([]) const [wfStaff, setWfStaff] = useState<{ id: string; name: string }[]>([])
const [syncing, setSyncing] = useState(false) const [syncing, setSyncing] = useState(false)
const [timesheetSyncing, setTimesheetSyncing] = useState(false)
const [weekStart, setWeekStart] = useState<string | null>(null) const [weekStart, setWeekStart] = useState<string | null>(null)
const [lastViewed, setLastViewed] = useState('') const [lastViewed, setLastViewed] = useState('')
const [savedLastReviewed, setSavedLastReviewed] = useState('') const [savedLastReviewed, setSavedLastReviewed] = useState('')
@ -312,6 +313,22 @@ export function Planner() {
} }
} }
async function syncTimesheetData() {
setTimesheetSyncing(true)
try {
await syncTimesheets()
if (bookings) {
const shifts = await getWorkforceShifts(bookings.dates[0], bookings.dates[bookings.dates.length - 1])
setWfShifts(shifts)
}
flash('Timesheets pulled from Workforce')
} catch (e) {
flash(e instanceof Error ? e.message : 'Timesheet sync failed', true)
} finally {
setTimesheetSyncing(false)
}
}
function wfSyncLabel(): string { function wfSyncLabel(): string {
if (!bookings) return '' if (!bookings) return ''
const viewDates = bookings.dates.filter(d => wfShifts[d]) const viewDates = bookings.dates.filter(d => wfShifts[d])
@ -447,7 +464,7 @@ export function Planner() {
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<button <button
onClick={syncRota} onClick={syncRota}
disabled={syncing} disabled={syncing || timesheetSyncing}
style={{ style={{
display: 'flex', alignItems: 'center', gap: '0.3rem', display: 'flex', alignItems: 'center', gap: '0.3rem',
background: 'var(--card-bg)', color: 'var(--text-dark)', background: 'var(--card-bg)', color: 'var(--text-dark)',
@ -456,7 +473,20 @@ export function Planner() {
}} }}
> >
<RefreshCw size={11} strokeWidth={1.75} style={{ animation: syncing ? 'spin 1s linear infinite' : 'none' }} /> <RefreshCw size={11} strokeWidth={1.75} style={{ animation: syncing ? 'spin 1s linear infinite' : 'none' }} />
{syncing ? 'Syncing…' : 'Sync from Workforce'} {syncing ? 'Syncing…' : 'Sync rota'}
</button>
<button
onClick={syncTimesheetData}
disabled={syncing || timesheetSyncing}
style={{
display: 'flex', alignItems: 'center', gap: '0.3rem',
background: 'var(--card-bg)', color: 'var(--text-dark)',
border: '1px solid var(--card-border)', borderRadius: '6px',
padding: '0.3rem 0.65rem', fontSize: '0.78rem', fontWeight: 600,
}}
>
<Clock size={11} strokeWidth={1.75} style={{ animation: timesheetSyncing ? 'spin 1s linear infinite' : 'none' }} />
{timesheetSyncing ? 'Pulling…' : 'Pull timesheets'}
</button> </button>
<span style={{ fontSize: '0.72rem', color: 'var(--text-mid)' }}>{wfSyncLabel()}</span> <span style={{ fontSize: '0.72rem', color: 'var(--text-mid)' }}>{wfSyncLabel()}</span>
<CtrlBtn onClick={addStaffRow}>+ Add staff</CtrlBtn> <CtrlBtn onClick={addStaffRow}>+ Add staff</CtrlBtn>
@ -804,7 +834,7 @@ function pivotWfShifts(wfShifts: Record<string, WfDayData>, dates: string[]): Wf
if (!day) continue if (!day) continue
for (const s of day.staff) { for (const s of day.staff) {
if (!members[s.id]) members[s.id] = { id: s.id, name: s.name, days: {} } if (!members[s.id]) members[s.id] = { id: s.id, name: s.name, days: {} }
members[s.id].days[date] = { hours: s.hours, times: s.times } members[s.id].days[date] = { hours: s.hours, times: s.times, status: s.status }
} }
} }
return Object.values(members).sort((a, b) => a.name.localeCompare(b.name)) return Object.values(members).sort((a, b) => a.name.localeCompare(b.name))
@ -870,12 +900,19 @@ function StaffTable({ bookings, staff, required, warnOverRed, warnOverAmber, war
</td> </td>
{dates.map(date => { {dates.map(date => {
const shift = member.days[date] const shift = member.days[date]
const isTimesheet = wfShifts[date]?.source === 'timesheet'
const isPending = isTimesheet && shift?.status !== 'APPROVED'
return ( return (
<td key={date} style={{ textAlign: 'center', padding: '0.3rem 0.4rem', verticalAlign: 'middle' }}> <td key={date} style={{ textAlign: 'center', padding: '0.3rem 0.4rem', verticalAlign: 'middle' }}>
{shift ? ( {shift ? (
<> <>
<div style={{ fontSize: '0.68rem', color: 'var(--text-mid)', lineHeight: 1.25 }}>{shift.times}</div> <div style={{ fontSize: '0.68rem', color: 'var(--text-mid)', lineHeight: 1.25 }}>{shift.times}</div>
<div style={{ fontWeight: 600 }}>{fmtH(shift.hours)}</div> <div style={{ fontWeight: 600, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.2rem' }}>
{isTimesheet && (
<Clock size={10} strokeWidth={1.75} style={{ color: isPending ? '#d97706' : '#1a7a4a', flexShrink: 0 }} />
)}
{fmtH(shift.hours)}
</div>
</> </>
) : <span style={{ color: 'var(--text-mid)' }}></span>} ) : <span style={{ color: 'var(--text-mid)' }}></span>}
</td> </td>

View file

@ -57,6 +57,7 @@ export interface Adjustment {
export interface WfShiftDay { export interface WfShiftDay {
hours: number hours: number
times: string times: string
status?: string
} }
export interface WfStaffMember { export interface WfStaffMember {
@ -68,7 +69,7 @@ export interface WfStaffMember {
export interface WfDayData { export interface WfDayData {
synced_at: string synced_at: string
source: string source: string
staff: { id: string; name: string; hours: number; times: string }[] staff: { id: string; name: string; hours: number; times: string; status?: string }[]
} }
export interface RequiredDay { export interface RequiredDay {