Merge pull request #387 from Oloodi/authentication-web

Invoices and Staff View related PR
This commit is contained in:
Achintha Isuru
2026-02-09 09:47:49 -05:00
committed by GitHub
23 changed files with 4746 additions and 23 deletions

View File

@@ -13,7 +13,10 @@
"@dataconnect/generated": "link:src/dataconnect-generated",
"@firebase/analytics": "^0.10.19",
"@firebase/data-connect": "^0.3.12",
"@hello-pangea/dnd": "^18.0.1",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-slot": "^1.2.4",
@@ -32,12 +35,16 @@
"date-fns": "^4.1.0",
"firebase": "^12.8.0",
"framer-motion": "^12.29.2",
"i18next": "^25.8.4",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"lucide-react": "^0.563.0",
"react": "^19.2.0",
"react-datepicker": "^9.1.0",
"react-day-picker": "^9.13.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.71.1",
"react-i18next": "^16.5.4",
"react-redux": "^9.2.0",
"react-router-dom": "^7.13.0",
"recharts": "^3.7.0",

208
apps/web/pnpm-lock.yaml generated
View File

@@ -21,9 +21,18 @@ importers:
'@firebase/data-connect':
specifier: ^0.3.12
version: 0.3.12(@firebase/app@0.14.7)
'@hello-pangea/dnd':
specifier: ^18.0.1
version: 18.0.1(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-avatar':
specifier: ^1.1.11
version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-dialog':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-dropdown-menu':
specifier: ^2.1.16
version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-label':
specifier: ^2.1.7
version: 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -78,6 +87,15 @@ importers:
framer-motion:
specifier: ^12.29.2
version: 12.29.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
i18next:
specifier: ^25.8.4
version: 25.8.4(typescript@5.9.3)
i18next-browser-languagedetector:
specifier: ^8.2.0
version: 8.2.0
i18next-http-backend:
specifier: ^3.0.2
version: 3.0.2
lucide-react:
specifier: ^0.563.0
version: 0.563.0(react@19.2.4)
@@ -96,6 +114,9 @@ importers:
react-hook-form:
specifier: ^7.71.1
version: 7.71.1(react@19.2.4)
react-i18next:
specifier: ^16.5.4
version: 16.5.4(i18next@25.8.4(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
react-redux:
specifier: ^9.2.0
version: 9.2.0(@types/react@19.2.10)(react@19.2.4)(redux@5.0.1)
@@ -234,6 +255,10 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/runtime@7.28.6':
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
engines: {node: '>=6.9.0'}
'@babel/template@7.28.6':
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
engines: {node: '>=6.9.0'}
@@ -683,6 +708,12 @@ packages:
engines: {node: '>=6'}
hasBin: true
'@hello-pangea/dnd@18.0.1':
resolution: {integrity: sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@@ -832,6 +863,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-avatar@1.1.11':
resolution: {integrity: sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-checkbox@1.3.3':
resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==}
peerDependencies:
@@ -902,6 +946,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-context@1.1.3':
resolution: {integrity: sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-dialog@1.1.15':
resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==}
peerDependencies:
@@ -1961,10 +2014,16 @@ packages:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
cross-fetch@4.0.0:
resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
css-box-model@1.2.1:
resolution: {integrity: sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@@ -2285,9 +2344,26 @@ packages:
hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
html-parse-stringify@3.0.1:
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
http-parser-js@0.5.10:
resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==}
i18next-browser-languagedetector@8.2.0:
resolution: {integrity: sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==}
i18next-http-backend@3.0.2:
resolution: {integrity: sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==}
i18next@25.8.4:
resolution: {integrity: sha512-a9A0MnUjKvzjEN/26ZY1okpra9kA8MEwzYEz1BNm+IyxUKPRH6ihf0p7vj8YvULwZHKHl3zkJ6KOt4hewxBecQ==}
peerDependencies:
typescript: ^5
peerDependenciesMeta:
typescript:
optional: true
idb@7.1.1:
resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==}
@@ -2499,6 +2575,15 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
@@ -2568,6 +2653,9 @@ packages:
'@types/react-dom':
optional: true
raf-schd@4.0.3:
resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==}
react-datepicker@9.1.0:
resolution: {integrity: sha512-lOp+m5bc+ttgtB5MHEjwiVu4nlp4CvJLS/PG1OiOe5pmg9kV73pEqO8H0Geqvg2E8gjqTaL9eRhSe+ZpeKP3nA==}
peerDependencies:
@@ -2595,6 +2683,22 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
react-i18next@16.5.4:
resolution: {integrity: sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g==}
peerDependencies:
i18next: '>= 25.6.2'
react: '>= 16.8.0'
react-dom: '*'
react-native: '*'
typescript: ^5
peerDependenciesMeta:
react-dom:
optional: true
react-native:
optional: true
typescript:
optional: true
react-is@19.2.4:
resolution: {integrity: sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==}
@@ -2768,6 +2872,9 @@ packages:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
ts-api-utils@2.4.0:
resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==}
engines: {node: '>=18.12'}
@@ -2877,9 +2984,16 @@ packages:
yaml:
optional: true
void-elements@3.1.0:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'}
web-vitals@4.2.4:
resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
websocket-driver@0.7.4:
resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==}
engines: {node: '>=0.8.0'}
@@ -2888,6 +3002,9 @@ packages:
resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==}
engines: {node: '>=0.8.0'}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@@ -3020,6 +3137,8 @@ snapshots:
'@babel/core': 7.28.6
'@babel/helper-plugin-utils': 7.28.6
'@babel/runtime@7.28.6': {}
'@babel/template@7.28.6':
dependencies:
'@babel/code-frame': 7.28.6
@@ -3524,6 +3643,18 @@ snapshots:
protobufjs: 7.5.4
yargs: 17.7.2
'@hello-pangea/dnd@18.0.1(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
css-box-model: 1.2.1
raf-schd: 4.0.3
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
react-redux: 9.2.0(@types/react@19.2.10)(react@19.2.4)(redux@5.0.1)
redux: 5.0.1
transitivePeerDependencies:
- '@types/react'
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.7':
@@ -3654,6 +3785,19 @@ snapshots:
'@types/react': 19.2.10
'@types/react-dom': 19.2.3(@types/react@19.2.10)
'@radix-ui/react-avatar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/react-context': 1.1.3(@types/react@19.2.10)(react@19.2.4)
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.10)(react@19.2.4)
'@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.10)(react@19.2.4)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.10)(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.10
'@types/react-dom': 19.2.3(@types/react@19.2.10)
'@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/primitive': 1.1.3
@@ -3724,6 +3868,12 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.10
'@radix-ui/react-context@1.1.3(@types/react@19.2.10)(react@19.2.4)':
dependencies:
react: 19.2.4
optionalDependencies:
'@types/react': 19.2.10
'@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/primitive': 1.1.3
@@ -4814,12 +4964,22 @@ snapshots:
cookie@1.1.1: {}
cross-fetch@4.0.0:
dependencies:
node-fetch: 2.7.0
transitivePeerDependencies:
- encoding
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
shebang-command: 2.0.0
which: 2.0.2
css-box-model@1.2.1:
dependencies:
tiny-invariant: 1.3.3
csstype@3.2.3: {}
d3-array@3.2.4:
@@ -5171,8 +5331,28 @@ snapshots:
dependencies:
hermes-estree: 0.25.1
html-parse-stringify@3.0.1:
dependencies:
void-elements: 3.1.0
http-parser-js@0.5.10: {}
i18next-browser-languagedetector@8.2.0:
dependencies:
'@babel/runtime': 7.28.6
i18next-http-backend@3.0.2:
dependencies:
cross-fetch: 4.0.0
transitivePeerDependencies:
- encoding
i18next@25.8.4(typescript@5.9.3):
dependencies:
'@babel/runtime': 7.28.6
optionalDependencies:
typescript: 5.9.3
idb@7.1.1: {}
ignore@5.3.2: {}
@@ -5328,6 +5508,10 @@ snapshots:
natural-compare@1.4.0: {}
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
node-releases@2.0.27: {}
optionator@0.9.4:
@@ -5451,6 +5635,8 @@ snapshots:
'@types/react': 19.2.10
'@types/react-dom': 19.2.3(@types/react@19.2.10)
raf-schd@4.0.3: {}
react-datepicker@9.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@floating-ui/react': 0.27.17(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -5475,6 +5661,17 @@ snapshots:
dependencies:
react: 19.2.4
react-i18next@16.5.4(i18next@25.8.4(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3):
dependencies:
'@babel/runtime': 7.28.6
html-parse-stringify: 3.0.1
i18next: 25.8.4(typescript@5.9.3)
react: 19.2.4
use-sync-external-store: 1.6.0(react@19.2.4)
optionalDependencies:
react-dom: 19.2.4(react@19.2.4)
typescript: 5.9.3
react-is@19.2.4: {}
react-redux@9.2.0(@types/react@19.2.10)(react@19.2.4)(redux@5.0.1):
@@ -5647,6 +5844,8 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
tr46@0.0.3: {}
ts-api-utils@2.4.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
@@ -5734,8 +5933,12 @@ snapshots:
jiti: 2.6.1
lightningcss: 1.30.2
void-elements@3.1.0: {}
web-vitals@4.2.4: {}
webidl-conversions@3.0.1: {}
websocket-driver@0.7.4:
dependencies:
http-parser-js: 0.5.10
@@ -5744,6 +5947,11 @@ snapshots:
websocket-extensions@0.1.4: {}
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
which@2.0.2:
dependencies:
isexe: 2.0.0

View File

@@ -0,0 +1,48 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,198 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Dot } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroups = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Dot className="h-4 w-4 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName =
DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
function DropdownMenuShortcut({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroups as DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -0,0 +1,281 @@
import { Badge } from "@/common/components/ui/badge";
import { Button } from "@/common/components/ui/button";
import { Card, CardContent } from "@/common/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/common/components/ui/table";
import { InvoiceStatus } from "@/dataconnect-generated";
import { useGetInvoiceById, useUpdateInvoice, useListRecentPaymentsByInvoiceId } from "@/dataconnect-generated/react";
import { dataConnect } from "@/features/auth/firebase";
import DashboardLayout from "@/features/layouts/DashboardLayout";
import type { RootState } from "@/store/store";
import { format, parseISO } from "date-fns";
import { ArrowLeft, Download, Mail, CheckCircle, FileText, User, Calendar, MapPin, DollarSign } from "lucide-react";
import { useSelector } from "react-redux";
import { useNavigate, useParams } from "react-router-dom";
const statusConfig: Record<string, { label: string; className: string }> = {
DRAFT: { label: "Draft", className: "bg-slate-100 text-slate-600 border-transparent" },
PENDING: { label: "Sent", className: "bg-blue-50 text-blue-700 border-blue-200" },
PENDING_REVIEW: { label: "Pending Review", className: "bg-amber-50 text-amber-700 border-amber-200" },
APPROVED: { label: "Approved", className: "bg-emerald-50 text-emerald-700 border-emerald-200" },
DISPUTED: { label: "Disputed", className: "bg-red-50 text-red-700 border-red-200" },
OVERDUE: { label: "Overdue", className: "bg-rose-50 text-rose-700 border-rose-200" },
PAID: { label: "Paid", className: "bg-emerald-50 text-emerald-700 border-emerald-200" },
};
export default function InvoiceDetail() {
const navigate = useNavigate();
const { id: invoiceId } = useParams<{ id: string }>();
const { user } = useSelector((state: RootState) => state.auth);
// Fetch Invoice Data
const { data: invoiceData, isLoading: loadingInvoice } = useGetInvoiceById(dataConnect, { id: invoiceId! });
const invoice = invoiceData?.invoice;
// Fetch Payment History
const { data: paymentsData, isLoading: loadingPayments } = useListRecentPaymentsByInvoiceId(dataConnect, { invoiceId: invoiceId! });
const payments = paymentsData?.recentPayments || [];
// Mutations
const { mutate: updateInvoice } = useUpdateInvoice(dataConnect);
const handleMarkAsPaid = () => {
if (!invoiceId) return;
updateInvoice({ id: invoiceId, status: InvoiceStatus.PAID });
};
const handleSendInvoice = () => {
// Logic for sending invoice (e.g., email service)
console.log("Sending invoice...");
updateInvoice({ id: invoiceId!, status: InvoiceStatus.PENDING });
};
const handleDownloadPDF = () => {
window.print();
};
if (loadingInvoice) {
return (
<DashboardLayout title="Invoice Detail">
<div className="flex items-center justify-center min-h-[50vh]">
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin"></div>
</div>
</DashboardLayout>
);
}
if (!invoice) {
return (
<DashboardLayout title="Invoice Not Found">
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-4">
<p className="text-xl font-semibold">Invoice not found</p>
<Button onClick={() => navigate("/invoices")} leadingIcon={<ArrowLeft />}>
Back to Invoices
</Button>
</div>
</DashboardLayout>
);
}
const status = statusConfig[invoice.status] || { label: invoice.status, className: "" };
const issueDate = invoice.issueDate ? format(parseISO(invoice.issueDate as string), "MMM d, yyyy") : "—";
const dueDate = invoice.dueDate ? format(parseISO(invoice.dueDate as string), "MMM d, yyyy") : "—";
// Parse JSON data for roles/charges (assuming they are JSON from the schema)
const lineItems = (invoice.roles as any[]) || [];
return (
<DashboardLayout
title={`Invoice ${invoice.invoiceNumber}`}
backAction={
<Button variant="ghost" onClick={() => navigate("/invoices")} leadingIcon={<ArrowLeft />}>
Back
</Button>
}
actions={
<div className="flex gap-2 print:hidden">
<Button variant="outline" leadingIcon={<Download />} onClick={handleDownloadPDF}>
Download PDF
</Button>
{invoice.status === "DRAFT" && (
<Button leadingIcon={<Mail />} onClick={handleSendInvoice}>
Send Invoice
</Button>
)}
{invoice.status !== "PAID" && (
<Button variant="default" leadingIcon={<CheckCircle />} onClick={handleMarkAsPaid} className="bg-emerald-600 hover:bg-emerald-700">
Mark as Paid
</Button>
)}
</div>
}
>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
{/* Header & Client Info */}
<Card className="border-border/50 shadow-sm">
<CardContent className="p-6">
<div className="flex flex-col md:flex-row justify-between gap-6">
<div className="space-y-4">
<div>
<h3 className="text-xs font-bold text-muted-foreground uppercase tracking-wider mb-2">Client Information</h3>
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center text-primary">
<User className="w-5 h-5" />
</div>
<div>
<p className="font-bold text-lg">{invoice.business?.businessName}</p>
<p className="text-sm text-secondary-text">{invoice.business?.email}</p>
<p className="text-sm text-secondary-text">{invoice.business?.phone}</p>
</div>
</div>
</div>
<div className="flex items-center gap-2 text-sm text-secondary-text">
<MapPin className="w-4 h-4" />
<span>{invoice.hub || (invoice.order as any)?.teamHub?.hubName}</span>
</div>
</div>
<div className="bg-muted/30 p-4 rounded-xl space-y-3 min-w-[240px]">
<div className="flex justify-between items-center border-b border-border/50 pb-2">
<span className="text-xs font-medium text-secondary-text">Status</span>
<Badge className={`${status.className} font-bold`}>{status.label}</Badge>
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-secondary-text flex items-center gap-1.5"><Calendar className="w-3.5 h-3.5" /> Issued</span>
<span className="font-medium">{issueDate}</span>
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-secondary-text flex items-center gap-1.5"><Calendar className="w-3.5 h-3.5" /> Due Date</span>
<span className="font-medium">{dueDate}</span>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Line Items Table */}
<Card className="border-border/50 shadow-sm overflow-hidden">
<div className="p-4 border-b border-border/50 bg-muted/20">
<h3 className="font-bold text-sm">Line Items (Shifts & Staff)</h3>
</div>
<Table>
<TableHeader>
<TableRow className="bg-muted/10">
<TableHead className="text-[10px] font-bold uppercase py-3">Staff / Role</TableHead>
<TableHead className="text-[10px] font-bold uppercase text-center">Hours</TableHead>
<TableHead className="text-[10px] font-bold uppercase text-right">Rate</TableHead>
<TableHead className="text-[10px] font-bold uppercase text-right">Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{lineItems.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm">
No line items recorded for this invoice.
</TableCell>
</TableRow>
) : (
lineItems.map((item: any, idx: number) => (
<TableRow key={idx} className="border-b border-border/40">
<TableCell className="py-4">
<p className="font-bold text-xs">{item.staffName || "Staff Member"}</p>
<p className="text-[10px] text-muted-foreground">{item.roleName || "Support"}</p>
</TableCell>
<TableCell className="text-center text-xs">{item.hours || 0}h</TableCell>
<TableCell className="text-right text-xs">${(item.rate || 0).toFixed(2)}</TableCell>
<TableCell className="text-right font-bold text-xs">${(item.total || 0).toFixed(2)}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</Card>
{/* Payment History */}
<Card className="border-border/50 shadow-sm overflow-hidden">
<div className="p-4 border-b border-border/50 bg-muted/20">
<h3 className="font-bold text-sm">Payment History</h3>
</div>
<div className="p-0">
{payments.length === 0 ? (
<div className="p-8 text-center text-muted-foreground text-sm">
No payment records found.
</div>
) : (
<Table>
<TableBody>
{payments.map((payment) => (
<TableRow key={payment.id}>
<TableCell className="py-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-emerald-50 text-emerald-600 flex items-center justify-center">
<DollarSign className="w-4 h-4" />
</div>
<div>
<p className="font-bold text-xs">Payment Received</p>
<p className="text-[10px] text-muted-foreground">
{payment.createdAt ? format(parseISO(payment.createdAt as string), "MMM d, yyyy") : "—"}
</p>
</div>
</div>
</TableCell>
<TableCell className="text-right">
<Badge variant="outline" className="text-emerald-700 bg-emerald-50 border-emerald-100 text-[10px]">
{payment.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</Card>
</div>
{/* Financial Summary Sidebar */}
<div className="space-y-6">
<Card className="border-border/50 shadow-sm bg-primary/[0.02]">
<CardContent className="p-6">
<h3 className="font-bold text-sm mb-4">Financial Summary</h3>
<div className="space-y-3">
<div className="flex justify-between text-sm text-secondary-text">
<span>Subtotal</span>
<span className="font-medium">${(invoice.subtotal || 0).toLocaleString(undefined, { minimumFractionDigits: 2 })}</span>
</div>
<div className="flex justify-between text-sm text-secondary-text">
<span>Fees</span>
<span className="font-medium">${(invoice.otherCharges || 0).toLocaleString(undefined, { minimumFractionDigits: 2 })}</span>
</div>
<div className="pt-3 border-t border-border/50 flex justify-between items-end">
<div>
<p className="text-xs font-bold text-muted-foreground uppercase tracking-widest">Grand Total</p>
<p className="text-2xl font-black text-primary-text mt-1">
${invoice.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
</p>
</div>
</div>
</div>
</CardContent>
</Card>
<Card className="border-border/50 shadow-sm">
<CardContent className="p-6">
<h3 className="font-bold text-sm mb-3">Internal Notes</h3>
<p className="text-sm text-secondary-text italic">
{invoice.notes || "No internal notes provided for this invoice."}
</p>
</CardContent>
</Card>
<div className="p-4 bg-muted/20 rounded-xl border border-border/50 text-center">
<FileText className="w-8 h-8 mx-auto mb-2 text-muted-foreground/30" />
<p className="text-xs text-secondary-text leading-relaxed">
For any questions regarding this invoice, please contact support@krowworkforce.com
</p>
</div>
</div>
</div>
</DashboardLayout>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,291 @@
import { Badge } from "@/common/components/ui/badge";
import { Button } from "@/common/components/ui/button";
import { Card, CardContent } from "@/common/components/ui/card";
import { Input } from "@/common/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/common/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/common/components/ui/table";
import { useListInvoices } from "@/dataconnect-generated/react";
import { dataConnect } from "@/features/auth/firebase";
import DashboardLayout from "@/features/layouts/DashboardLayout";
import type { RootState } from "@/store/store";
import { format, isWithinInterval, parseISO } from "date-fns";
import { FileText, Plus, Search, SquarePen } from "lucide-react";
import { useMemo, useState } from "react";
import { useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
// Map InvoiceStatus enum to display labels and colors
const statusConfig: Record<string, { label: string; className: string }> = {
DRAFT: { label: "Draft", className: "bg-slate-100 text-slate-600 border-transparent" },
PENDING: { label: "Sent", className: "bg-blue-50 text-blue-700 border-blue-200" },
PENDING_REVIEW: { label: "Pending Review", className: "bg-amber-50 text-amber-700 border-amber-200" },
APPROVED: { label: "Approved", className: "bg-emerald-50 text-emerald-700 border-emerald-200" },
DISPUTED: { label: "Disputed", className: "bg-red-50 text-red-700 border-red-200" },
OVERDUE: { label: "Overdue", className: "bg-rose-50 text-rose-700 border-rose-200" },
PAID: { label: "Paid", className: "bg-emerald-50 text-emerald-700 border-emerald-200" },
};
export default function InvoiceList() {
const navigate = useNavigate();
const { user } = useSelector((state: RootState) => state.auth);
// Filtering state
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [startDate, setStartDate] = useState<string>("");
const [endDate, setEndDate] = useState<string>("");
// Fetch invoices using Data Connect
const { data, isLoading } = useListInvoices(dataConnect);
const invoices = data?.invoices || [];
// Filter invoices based on user role and filters
const filteredInvoices = useMemo(() => {
return invoices.filter((inv) => {
// Role-based access (simplified for Master List)
// If user is client, they should see their invoices. If admin, they see all.
const userRole = user?.userRole?.toUpperCase();
const isClient = userRole === "CLIENT";
const isVendor = userRole === "VENDOR";
if (isClient && inv.businessId !== user?.uid) return false;
// In a real scenario, we'd match vendorId for vendor users
// if (isVendor && inv.vendorId !== user?.uid) return false;
// Status filter
if (statusFilter !== "all" && inv.status !== statusFilter) return false;
// Search term (Invoice #, Client name, Order name)
if (searchTerm) {
const search = searchTerm.toLowerCase();
const matchesInvoice = inv.invoiceNumber?.toLowerCase().includes(search);
const matchesClient = inv.business?.businessName?.toLowerCase().includes(search);
const matchesEvent = inv.order?.eventName?.toLowerCase().includes(search);
if (!matchesInvoice && !matchesClient && !matchesEvent) return false;
}
// Date range filter
if (startDate || endDate) {
const issueDate = parseISO(inv.issueDate as string);
const start = startDate ? parseISO(startDate) : new Date(0);
const end = endDate ? parseISO(endDate) : new Date(8640000000000000);
if (!isWithinInterval(issueDate, { start, end })) return false;
}
return true;
});
}, [invoices, user, statusFilter, searchTerm, startDate, endDate]);
// Financial Summary
const metrics = useMemo(() => {
const total = filteredInvoices.reduce((sum, inv) => sum + (inv.amount || 0), 0);
const outstanding = filteredInvoices
.filter((inv) => inv.status !== "PAID" && inv.status !== "DRAFT")
.reduce((sum, inv) => sum + (inv.amount || 0), 0);
return { total, outstanding };
}, [filteredInvoices]);
return (
<DashboardLayout
title="Invoices"
subtitle={`${filteredInvoices.length} invoices found`}
actions={
<Button onClick={() => navigate("/invoices/new")} leadingIcon={<Plus />}>
New Invoice
</Button>
}
>
<div className="flex flex-col gap-6">
{/* Summary Card */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card className="border-border/50 shadow-sm">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-secondary-text font-medium">Total Outstanding</p>
<h2 className="text-3xl font-bold text-rose-600 mt-1">
${metrics.outstanding.toLocaleString(undefined, { minimumFractionDigits: 2 })}
</h2>
</div>
<div className="w-12 h-12 bg-rose-50 rounded-full flex items-center justify-center">
<FileText className="w-6 h-6 text-rose-600" />
</div>
</div>
</CardContent>
</Card>
<Card className="border-border/50 shadow-sm">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-secondary-text font-medium">Filtered Total</p>
<h2 className="text-3xl font-bold text-primary-text mt-1">
${metrics.total.toLocaleString(undefined, { minimumFractionDigits: 2 })}
</h2>
</div>
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center">
<Search className="w-6 h-6 text-primary" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters Bar */}
<Card className="border-border/50 shadow-sm">
<CardContent className="p-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="space-y-1.5">
<label className="text-xs font-semibold text-secondary-text uppercase tracking-wider">Search</label>
<Input
placeholder="Invoice #, Client, Event..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
leadingIcon={<Search />}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-semibold text-secondary-text uppercase tracking-wider">Status</label>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger>
<SelectValue placeholder="All Statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
{Object.entries(statusConfig).map(([key, { label }]) => (
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-semibold text-secondary-text uppercase tracking-wider">From Date</label>
<Input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-semibold text-secondary-text uppercase tracking-wider">To Date</label>
<Input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
</div>
</CardContent>
</Card>
{/* Invoices Table */}
<Card className="border-border/50 shadow-sm overflow-hidden bg-card">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-muted/30 hover:bg-muted/30 border-b border-border/50">
<TableHead className="font-bold text-[10px] text-muted-text uppercase tracking-wider py-4">Invoice #</TableHead>
<TableHead className="font-bold text-[10px] text-muted-text uppercase tracking-wider">Client</TableHead>
<TableHead className="font-bold text-[10px] text-muted-text uppercase tracking-wider">Date</TableHead>
<TableHead className="font-bold text-[10px] text-muted-text uppercase tracking-wider text-right">Amount</TableHead>
<TableHead className="font-bold text-[10px] text-muted-text uppercase tracking-wider text-center">Status</TableHead>
<TableHead className="font-bold text-[10px] text-muted-text uppercase tracking-wider text-center">Due Date</TableHead>
<TableHead className="font-bold text-[10px] text-muted-text uppercase tracking-wider text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-16">
<div className="flex justify-center">
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin"></div>
</div>
</TableCell>
</TableRow>
) : filteredInvoices.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-16">
<FileText className="w-12 h-12 mx-auto mb-3 text-muted-foreground/50" />
<p className="font-medium text-secondary-text">No invoices found</p>
<p className="text-sm text-muted-foreground mt-1">Try adjusting your filters</p>
</TableCell>
</TableRow>
) : (
filteredInvoices.map((invoice) => {
const status = statusConfig[invoice.status] || { label: invoice.status, className: "" };
const issueDate = invoice.issueDate ? format(parseISO(invoice.issueDate as string), "MMM d, yyyy") : "—";
const dueDate = invoice.dueDate ? format(parseISO(invoice.dueDate as string), "MMM d, yyyy") : "—";
return (
<TableRow
key={invoice.id}
className="hover:bg-muted/20 cursor-pointer transition-all border-b border-border/50 group"
onClick={() => navigate(`/invoices/${invoice.id}`)}
>
<TableCell className="py-4 font-bold text-primary-text text-xs">
{invoice.invoiceNumber}
</TableCell>
<TableCell>
<p className="font-medium text-primary-text text-sm">
{invoice.business?.businessName || "—"}
</p>
<p className="text-[10px] text-muted-foreground">
{invoice.order?.eventName || "Untitled Event"}
</p>
</TableCell>
<TableCell className="text-sm text-secondary-text">
{issueDate}
</TableCell>
<TableCell className="text-right font-bold text-primary-text">
${invoice.amount?.toLocaleString(undefined, { minimumFractionDigits: 2 })}
</TableCell>
<TableCell className="text-center">
<Badge className={`${status.className} font-semibold text-[10px]`}>
{status.label}
</Badge>
</TableCell>
<TableCell className="text-center text-sm text-secondary-text">
{dueDate}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation();
navigate(`/invoices/${invoice.id}/edit`);
}}
>
<SquarePen className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</Card>
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,353 @@
import { useState, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import DashboardLayout from "@/features/layouts/DashboardLayout";
import { Button } from "@/common/components/ui/button";
import { Card, CardContent } from "@/common/components/ui/card";
import { Badge } from "@/common/components/ui/badge";
import { Avatar, AvatarFallback } from "@/common/components/ui/avatar";
import { Input } from "@/common/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/common/components/ui/select";
import {
Users,
TrendingUp,
TrendingDown,
XCircle,
Search,
ChevronLeft,
ChevronRight,
Briefcase
} from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import {
useListStaff,
useListStaffAvailabilities,
useListStaffAvailabilityStats
} from "@/dataconnect-generated/react";
import { DayOfWeek, AvailabilityStatus } from "@/dataconnect-generated";
import { dataConnect } from "@/features/auth/firebase";
/**
* StaffAvailability Feature Component
* Displays worker availability in a grid view (Staff x Days).
*/
export default function StaffAvailability() {
const navigate = useNavigate();
const [searchTerm, setSearchTerm] = useState("");
const [selectedSkill, setSelectedSkill] = useState("all");
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(50);
const { data: staffData, isLoading: loadingStaff } = useListStaff(dataConnect);
const { data: availabilityData, isLoading: loadingAvail } = useListStaffAvailabilities(dataConnect);
const { data: statsData, isLoading: loadingStats } = useListStaffAvailabilityStats(dataConnect);
const isLoading = loadingStaff || loadingAvail || loadingStats;
const allStaff = staffData?.staffs || [];
const allAvailabilities = availabilityData?.staffAvailabilities || [];
const allStats = statsData?.staffAvailabilityStatss || [];
// Get unique skills for filtering
const allSkills = useMemo(() => {
const skills = new Set<string>();
allStaff.forEach(s => {
s.skills?.forEach(skill => skills.add(skill));
});
return Array.from(skills).sort();
}, [allStaff]);
// Calculate metrics
const metrics = useMemo(() => {
const availableNow = allAvailabilities.filter(a => a.status === AvailabilityStatus.CONFIRMED_AVAILABLE).length;
const blocked = allAvailabilities.filter(a => a.status === AvailabilityStatus.BLOCKED).length;
const totalStaff = allStaff.length;
const highUtilization = allStats.filter(s => (s.utilizationPercentage || 0) >= 90).length;
return { availableNow, blocked, totalStaff, highUtilization };
}, [allAvailabilities, allStaff, allStats]);
// Filter and search logic
const filteredStaff = useMemo(() => {
let filtered = [...allStaff];
// Search
if (searchTerm) {
filtered = filtered.filter(s =>
s.fullName.toLowerCase().includes(searchTerm.toLowerCase())
);
}
// Skill filter
if (selectedSkill !== "all") {
filtered = filtered.filter(s => s.skills?.includes(selectedSkill));
}
return filtered;
}, [allStaff, searchTerm, selectedSkill]);
const days = [
DayOfWeek.MONDAY,
DayOfWeek.TUESDAY,
DayOfWeek.WEDNESDAY,
DayOfWeek.THURSDAY,
DayOfWeek.FRIDAY,
DayOfWeek.SATURDAY,
DayOfWeek.SUNDAY
];
// Pagination
const totalPages = Math.ceil(filteredStaff.length / itemsPerPage) || 1;
const paginatedStaff = filteredStaff.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
);
const getStatusColor = (status?: AvailabilityStatus) => {
switch (status) {
case AvailabilityStatus.CONFIRMED_AVAILABLE:
return "bg-emerald-500";
case AvailabilityStatus.BLOCKED:
return "bg-rose-500";
case AvailabilityStatus.UNKNOWN:
default:
return "bg-gray-400";
}
};
return (
<DashboardLayout
title="Resource Availability"
subtitle={`${filteredStaff.length} command personnel assets tracked`}
>
<div className="space-y-6">
{/* Metrics Grid */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{[
{ label: 'Available Now', value: metrics.availableNow, icon: TrendingDown, color: 'emerald', subtext: 'Ready for deployment' },
{ label: 'Blocked Assets', value: metrics.blocked, icon: XCircle, color: 'rose', subtext: 'Non-deployable' },
{ label: 'High Utilization', value: metrics.highUtilization, icon: TrendingUp, color: 'indigo', subtext: 'Above 90%' },
{ label: 'Total Personnel', value: metrics.totalStaff, icon: Users, color: 'primary', subtext: 'Total force' }
].map((metric, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
>
<Card className="bg-card border-border/50 shadow-sm hover:shadow-md transition-all">
<CardContent className="p-6">
<div className="flex items-center justify-between pointer-events-none">
<div>
<p className="text-secondary-text text-sm font-medium mb-1">{metric.label}</p>
<p className="text-3xl font-medium tracking-tight text-primary-text">{metric.value}</p>
</div>
<div className={`w-12 h-12 ${metric.color === 'primary' ? 'bg-primary/10' : `bg-${metric.color}-500/10`} rounded-xl flex items-center justify-center border ${metric.color === 'primary' ? 'border-primary/20' : `border-${metric.color}-500/20`}`}>
<metric.icon className={`w-6 h-6 ${metric.color === 'primary' ? 'text-primary' : `text-${metric.color}-600`}`} />
</div>
</div>
</CardContent>
</Card>
</motion.div>
))}
</div>
{/* Filter Toolbar */}
<div className="flex flex-col md:flex-row gap-4 items-start">
<div className="relative flex-1">
<Input
placeholder="Search by personnel name..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
leadingIcon={<Search />}
/>
</div>
<div className="flex flex-wrap gap-3">
<Select value={selectedSkill} onValueChange={setSelectedSkill}>
<SelectTrigger className="w-[180px]">
<div className="flex items-center gap-2">
<Briefcase className="w-4 h-4 text-muted-foreground/50" />
<SelectValue placeholder="All Skills" />
</div>
</SelectTrigger>
<SelectContent className="rounded-lg border-border/50 bg-card">
<SelectItem value="all">All Skills</SelectItem>
{allSkills.map(skill => (
<SelectItem key={skill} value={skill}>{skill}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Main View Transition */}
<AnimatePresence mode="wait">
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 animate-pulse">
{[...Array(6)].map((_, i) => (
<div key={i} className="h-64 bg-muted/40 rounded-3xl border border-border/50" />
))}
</div>
) : paginatedStaff.length === 0 ? (
<div className="text-center py-20 bg-card rounded-2xl border border-border/50 shadow-sm">
<div className="w-20 h-20 bg-muted/30 rounded-full flex items-center justify-center mx-auto mb-6">
<Users className="w-10 h-10 text-muted-text" />
</div>
<h3 className="text-xl font-medium text-primary-text mb-2">No personnel located</h3>
<p className="text-secondary-text font-medium max-w-xs mx-auto">Try adjusting your filters or search criteria to locate command assets.</p>
</div>
) : (
<motion.div
key="grid"
initial="hidden"
animate="visible"
exit="hidden"
variants={{
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { staggerChildren: 0.05 } }
}}
className="space-y-4"
>
{paginatedStaff.map((staff) => {
const staffAvails = allAvailabilities.filter(a => a.staffId === staff.id);
const staffStats = allStats.find(s => s.staffId === staff.id);
return (
<motion.div
key={staff.id}
variants={{
hidden: { opacity: 0, y: 10 },
visible: { opacity: 1, y: 0 }
}}
>
<Card className="bg-card border-border/50 shadow-sm hover:shadow-md transition-all group overflow-hidden">
<CardContent className="p-0">
<div className="flex flex-col lg:flex-row">
{/* Staff Info Sidebar */}
<div
className="p-4 lg:w-64 border-b lg:border-b-0 lg:border-r border-border/40 bg-muted/5 cursor-pointer hover:bg-muted/10 transition-colors"
onClick={() => navigate(`/staff/${staff.id}/edit`)}
>
<div className="flex items-center gap-3 mb-3">
<Avatar className="w-10 h-10 rounded-lg border border-border/50 shadow-sm bg-white">
<AvatarFallback className="bg-primary/10 text-primary font-medium uppercase">
{staff.fullName.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="font-semibold text-sm text-primary-text truncate">{staff.fullName}</p>
<p className="text-[10px] text-secondary-text uppercase font-medium tracking-wider">{staff.role || 'Field Staff'}</p>
</div>
</div>
<div className="flex flex-wrap gap-1">
{staff.skills?.slice(0, 3).map(skill => (
<Badge key={skill} variant="secondary" className="text-[9px] px-1 py-0 h-4 bg-muted/40 text-secondary-text border-none">
{skill}
</Badge>
))}
</div>
</div>
{/* Availability Grid */}
<div className="flex-1 grid grid-cols-7 divide-x divide-border/40">
{days.map(day => {
const avail = staffAvails.find(a => a.day === day);
return (
<div key={day} className="flex flex-col min-h-[80px]">
<div className="px-2 py-1.5 border-b border-border/40 bg-muted/20 text-center">
<span className="text-[10px] font-bold text-secondary-text uppercase tracking-widest">{day.substring(0, 3)}</span>
</div>
<div className="flex-1 p-2 flex items-center justify-center">
<div
className={`w-full h-8 rounded-md ${getStatusColor(avail?.status)} opacity-80 flex items-center justify-center text-[10px] font-bold text-white shadow-sm`}
title={avail?.notes || ""}
>
{avail?.status === AvailabilityStatus.CONFIRMED_AVAILABLE ? "AVAIL" :
avail?.status === AvailabilityStatus.BLOCKED ? "BLOCK" : "UNKNOWN"}
</div>
</div>
</div>
);
})}
</div>
{/* Stats Sidebar */}
<div className="p-4 lg:w-48 bg-muted/5 border-t lg:border-t-0 lg:border-l border-border/40 flex flex-col justify-center gap-2">
<div className="flex justify-between items-center">
<span className="text-[10px] font-medium text-secondary-text uppercase tracking-wider">Utilization</span>
<span className="text-xs font-bold text-primary-text">{Math.round(staffStats?.utilizationPercentage || 0)}%</span>
</div>
<div className="w-full h-1 bg-muted rounded-full overflow-hidden">
<div
className={`h-full ${
(staffStats?.utilizationPercentage || 0) < 50 ? 'bg-rose-500' :
(staffStats?.utilizationPercentage || 0) < 80 ? 'bg-amber-500' : 'bg-emerald-500'
}`}
style={{ width: `${Math.min(100, staffStats?.utilizationPercentage || 0)}%` }}
/>
</div>
<div className="flex justify-between items-center mt-1">
<span className="text-[10px] font-medium text-secondary-text uppercase tracking-wider">Score</span>
<span className="text-xs font-bold text-emerald-600">{staffStats?.acceptanceRate || 0}%</span>
</div>
</div>
</div>
</CardContent>
</Card>
</motion.div>
);
})}
</motion.div>
)}
</AnimatePresence>
{/* Pagination Controls */}
<div className="py-4 border-t border-border/40 flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-4 text-sm font-medium text-secondary-text">
<span className="tracking-tight">Showing {((currentPage - 1) * itemsPerPage) + 1}-{Math.min(currentPage * itemsPerPage, filteredStaff.length)} of {filteredStaff.length} personnel</span>
<Select value={itemsPerPage.toString()} onValueChange={(val: string) => { setItemsPerPage(parseInt(val)); setCurrentPage(1); }}>
<SelectTrigger className="w-[100px] h-8 rounded-md bg-card border-border/50 text-[11px] font-medium">
<SelectValue />
</SelectTrigger>
<SelectContent className="rounded-lg border-border/50 bg-card">
<SelectItem value="25">25 / page</SelectItem>
<SelectItem value="50">50 / page</SelectItem>
<SelectItem value="100">100 / page</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="h-8 px-3 rounded-md border-border/50 hover:bg-muted/5 transition-all"
>
<ChevronLeft className="w-4 h-4 mr-1" />
<span className="text-[11px] font-medium uppercase tracking-wider">Previous</span>
</Button>
<div className="flex items-center gap-1 px-2 font-medium text-xs tabular-nums text-secondary-text">
<span className="text-primary-text">{currentPage}</span>
<span className="opacity-40">/</span>
<span>{totalPages}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="h-8 px-3 rounded-md border-border/50 hover:bg-muted/5 transition-all"
>
<span className="text-[11px] font-medium uppercase tracking-wider">Next</span>
<ChevronRight className="w-4 h-4 ml-1" />
</Button>
</div>
</div>
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,231 @@
import { useState, useEffect, useMemo } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router-dom";
import { useSelector } from "react-redux";
import { createPageUrl } from "@/lib/index";
import { Button } from "@/common/components/ui/button";
import { Loader2, AlertTriangle } from "lucide-react";
import DashboardLayout from "@/features/layouts/DashboardLayout";
import OrderReductionAlert from "./components/OrderReductionAlert";
import EventFormWizard from "./components/EventFormWizard";
import { useToast } from "@/common/components/ui/use-toast";
import { useGetOrderById, useUpdateOrder, useListStaff } from "@/dataconnect-generated/react";
import { dataConnect } from "@/features/auth/firebase";
import type { RootState } from "@/store/store";
export default function EditOrder() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { toast } = useToast();
const { id: eventId } = useParams<{ id: string }>();
const { user } = useSelector((state: RootState) => state.auth);
const [showReductionAlert, setShowReductionAlert] = useState(false);
const [pendingUpdate, setPendingUpdate] = useState<any>(null);
const [originalRequested, setOriginalRequested] = useState(0);
const { data: orderData, isLoading: isOrderLoading } = useGetOrderById(
dataConnect,
{ id: eventId || "" },
{ enabled: !!eventId }
);
const event = orderData?.order;
const { data: staffData } = useListStaff(dataConnect);
const allStaff = staffData?.staffs || [];
useEffect(() => {
if (event) {
setOriginalRequested(event.requested || 0);
}
}, [event]);
const updateOrderMutation = useUpdateOrder(dataConnect, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["listOrders"] });
queryClient.invalidateQueries({ queryKey: ["getOrderById", { id: eventId }] });
toast({
title: "✅ Order Updated",
description: "Your changes have been saved successfully.",
});
navigate(createPageUrl("Events"));
},
onError: (error) => {
toast({
title: "❌ Update Failed",
description: error.message || "Could not update the order. Please try again.",
variant: "destructive",
});
},
});
const canModify = useMemo(() => {
if (!event) return false;
if (!event.startDate) return true;
const startTime = new Date(event.startDate);
return startTime > new Date();
}, [event]);
const handleSubmit = (eventData: any) => {
if (!canModify) {
toast({
title: "Cannot Edit Order",
description: "This order has already started and cannot be modified.",
variant: "destructive",
});
return;
}
// CRITICAL: Recalculate requested count from current roles
const totalRequested = eventData.shifts.reduce((sum: number, shift: any) => {
const roles = Array.isArray(shift.roles) ? shift.roles : [];
return sum + roles.reduce((roleSum: number, role: any) => roleSum + (parseInt(role.count) || 0), 0);
}, 0);
const assignedStaff = Array.isArray(event?.assignedStaff) ? event!.assignedStaff : [];
const assignedCount = assignedStaff.length;
const isVendor = user?.userRole === 'vendor' || (user as any)?.role === 'vendor';
// If client is reducing headcount and vendor has already assigned staff
if (!isVendor && totalRequested < originalRequested && assignedCount > totalRequested) {
setPendingUpdate({ ...eventData, requested: totalRequested });
setShowReductionAlert(true);
toast({
title: "⚠️ Headcount Reduced",
description: "Assigned staff exceeds new headcount. Please resolve assignments.",
});
return;
}
if (eventId) {
// Normal update
updateOrderMutation.mutate({
id: eventId,
eventName: eventData.event_name,
date: eventData.date,
startDate: eventData.startDate || eventData.date,
endDate: eventData.endDate,
notes: eventData.notes,
shifts: eventData.shifts,
requested: totalRequested,
total: eventData.total,
poReference: eventData.po_reference,
});
}
};
const handleAutoUnassign = async () => {
if (!pendingUpdate || !event || !eventId) return;
const assignedStaff = Array.isArray(event.assignedStaff) ? (event.assignedStaff as any[]) : [];
const excessCount = assignedStaff.length - pendingUpdate.requested;
// Calculate reliability scores for assigned staff
const staffWithScores = assignedStaff.map(assigned => {
const staffInfo = allStaff.find(s => s.id === assigned.staff_id || s.id === assigned.staffId);
return {
...assigned,
reliability: staffInfo?.averageRating ? staffInfo.averageRating * 20 : 50, // Convert 0-5 to 0-100
};
});
// Sort by reliability (lowest first)
staffWithScores.sort((a: any, b: any) => a.reliability - b.reliability);
// Remove lowest reliability staff
const staffToKeep = staffWithScores.slice(excessCount);
await updateOrderMutation.mutateAsync({
id: eventId,
...pendingUpdate,
assignedStaff: staffToKeep.map((s: any) => ({
staffId: s.staffId || s.staff_id,
staffName: s.staffName || s.staff_name,
role: s.role
}))
});
setShowReductionAlert(false);
setPendingUpdate(null);
toast({
title: "✅ Staff Auto-Unassigned",
description: `Removed ${excessCount} lowest reliability staff members`,
});
};
const handleManualUnassign = () => {
setShowReductionAlert(false);
toast({
title: "Manual Adjustment Required",
description: "Please manually remove excess staff from the order",
});
};
if (isOrderLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
);
}
if (!event) {
return (
<div className="p-12 text-center">
<h2 className="text-2xl font-bold text-primary-text mb-4">Event Not Found</h2>
<Button onClick={() => navigate(createPageUrl("Events"))}>
Back to Events
</Button>
</div>
);
}
return (
<DashboardLayout
title={`Edit ${event.eventName || "Order"}`}
subtitle="Update information for your order"
>
<div className="max-w-7xl mx-auto">
{!canModify && (
<div className="mb-6 p-4 bg-amber-50 border border-amber-200 rounded-xl flex items-center gap-3 text-amber-800">
<AlertTriangle className="w-5 h-5 flex-shrink-0" />
<p className="text-sm font-medium">
This order has already started. Some details may no longer be editable for security and tracking purposes.
</p>
</div>
)}
{showReductionAlert && pendingUpdate && (
<div className="mb-6">
<OrderReductionAlert
originalRequested={originalRequested}
newRequested={pendingUpdate.requested}
currentAssigned={Array.isArray(event.assignedStaff) ? event.assignedStaff.length : 0}
onAutoUnassign={handleAutoUnassign}
onManualUnassign={handleManualUnassign}
lowReliabilityStaff={(Array.isArray(event.assignedStaff) ? event.assignedStaff : []).map((assigned: any) => {
const staffInfo = allStaff.find(s => s.id === assigned.staffId || s.id === assigned.staff_id);
return {
name: assigned.staffName || assigned.staff_name,
reliability: staffInfo?.averageRating ? staffInfo.averageRating * 20 : 50
};
}).sort((a, b) => a.reliability - b.reliability)}
/>
</div>
)}
<EventFormWizard
event={event as any}
onSubmit={handleSubmit}
isSubmitting={updateOrderMutation.isPending}
currentUser={user as any}
onCancel={() => navigate(createPageUrl("Events"))}
/>
</div>
</DashboardLayout>
);
}

View File

@@ -1,17 +1,18 @@
import React, { useMemo } from "react";
import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { format } from "date-fns";
import { useSelector } from "react-redux";
import { Calendar, MapPin, Users, DollarSign, Edit3, X, Copy, Clock } from "lucide-react";
import { Calendar, MapPin, Users, DollarSign, Edit3, X, Copy, Clock, FileText, UserPlus } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/common/components/ui/card";
import { Button } from "@/common/components/ui/button";
import { Badge } from "@/common/components/ui/badge";
import DashboardLayout from "@/features/layouts/DashboardLayout";
import { useGetOrderById, useUpdateOrder } from "@/dataconnect-generated/react";
import { useGetOrderById, useUpdateOrder, useListShiftRolesByBusinessAndOrder } from "@/dataconnect-generated/react";
import { OrderStatus } from "@/dataconnect-generated";
import { dataConnect } from "@/features/auth/firebase";
import { useToast } from "@/common/components/ui/use-toast";
import AssignStaffModal from "./components/AssignStaffModal";
import type { RootState } from "@/store/store";
const safeFormatDate = (value?: string | null): string => {
@@ -85,6 +86,8 @@ export default function OrderDetail() {
const { id } = useParams<{ id: string }>();
const { toast } = useToast();
const { user } = useSelector((state: RootState) => state.auth);
const [selectedShift, setSelectedShift] = useState<any>(null);
const [isAssignModalOpen, setIsAssignModalOpen] = useState(false);
const {
data,
@@ -99,6 +102,22 @@ export default function OrderDetail() {
const order = data?.order;
// Fetch real shift roles to get IDs and accurate counts
const {
data: shiftRolesData,
isLoading: isLoadingShifts,
refetch: refetchShifts
} = useListShiftRolesByBusinessAndOrder(
dataConnect,
{
orderId: id || "",
businessId: order?.businessId || ""
},
{
enabled: !!id && !!order?.businessId,
}
);
const cancelMutation = useUpdateOrder(dataConnect, {
onSuccess: () => {
toast({
@@ -131,8 +150,7 @@ export default function OrderDetail() {
const handleEdit = () => {
if (!order || !id) return;
// Placeholder: route can later be wired to an edit form
navigate(`/orders/create?edit=${id}`);
navigate(`/orders/${id}/edit`);
};
const handleDuplicate = () => {
@@ -141,7 +159,12 @@ export default function OrderDetail() {
navigate(`/orders/create?duplicate=${id}`);
};
const shifts: any[] = Array.isArray(order?.shifts) ? (order!.shifts as any[]) : [];
const shifts: any[] = useMemo(() => {
if (shiftRolesData?.shiftRoles && shiftRolesData.shiftRoles.length > 0) {
return shiftRolesData.shiftRoles;
}
return Array.isArray(order?.shifts) ? (order!.shifts as any[]) : [];
}, [shiftRolesData, order?.shifts]);
const totalRequested = order?.requested ?? 0;
const totalAssigned = Array.isArray(order?.assignedStaff) ? order!.assignedStaff.length : 0;
@@ -239,7 +262,7 @@ export default function OrderDetail() {
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center border border-primary/20">
<FileTextIcon />
<FileText className="w-6 h-6 text-primary" />
</div>
<div>
<p className="text-xs text-secondary-text font-medium uppercase tracking-wider">
@@ -353,6 +376,21 @@ export default function OrderDetail() {
</span>
<span className="font-semibold">{vacancies}</span>
</div>
{!isClient && canModify && (
<Button
variant="ghost"
size="sm"
className="ml-2 text-primary hover:text-primary hover:bg-primary/10 rounded-lg h-8 px-2"
onClick={() => {
setSelectedShift(shift);
setIsAssignModalOpen(true);
}}
>
<UserPlus className="w-4 h-4 mr-1.5" />
Assign
</Button>
)}
</div>
</div>
);
@@ -432,20 +470,20 @@ export default function OrderDetail() {
</CardContent>
</Card>
</div>
{selectedShift && (
<AssignStaffModal
isOpen={isAssignModalOpen}
onClose={() => {
setIsAssignModalOpen(false);
setSelectedShift(null);
}}
shift={selectedShift}
onSuccess={() => {
refetchShifts();
}}
/>
)}
</DashboardLayout>
);
}
const FileTextIcon: React.FC = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
className="w-6 h-6 text-primary"
aria-hidden="true"
>
<path
fill="currentColor"
d="M7 2a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8.414a2 2 0 0 0-.586-1.414l-4.414-4.414A2 2 0 0 0 13.586 2H7zm6 2.414L17.586 9H15a2 2 0 0 1-2-2V4.414zM9 11h6v2H9v-2zm0 4h4v2H9v-2z"
/>
</svg>
);

View File

@@ -0,0 +1,267 @@
import React, { useState, useMemo } from "react";
import { Search, Check, X, UserPlus, Star, Clock, AlertTriangle } from "lucide-react";
import { format, isSameDay, parseISO } from "date-fns";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/common/components/ui/dialog";
import { Button } from "@/common/components/ui/button";
import { Input } from "@/common/components/ui/input";
import { Badge } from "@/common/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/common/components/ui/avatar";
import { useToast } from "@/common/components/ui/use-toast";
import {
useListWorkforceByVendorId,
useCreateAssignment,
useUpdateShiftRole,
useListAssignments,
useListStaffAvailabilitiesByDay
} from "@/dataconnect-generated/react";
import { dataConnect } from "@/features/auth/firebase";
import { AssignmentStatus } from "@/dataconnect-generated";
interface AssignStaffModalProps {
isOpen: boolean;
onClose: () => void;
shift: any; // The ShiftRole object
onSuccess: () => void;
}
export default function AssignStaffModal({ isOpen, onClose, shift, onSuccess }: AssignStaffModalProps) {
const { toast } = useToast();
const [searchQuery, setSearchQuery] = useState("");
const [selectedStaff, setSelectedStaff] = useState<any>(null);
const vendorId = shift.shift?.order?.vendorId || shift.order?.vendorId;
const shiftDate = shift.shift?.date || shift.date;
const shiftStartTime = shift.startTime || shift.start;
const shiftEndTime = shift.endTime || shift.end;
// Fetch all workforce members for this vendor
const { data: workforceData, isLoading: isLoadingWorkforce } = useListWorkforceByVendorId(
dataConnect,
{ vendorId: vendorId || "" },
{ enabled: !!vendorId }
);
// Fetch existing assignments to check for conflicts
const { data: assignmentsData } = useListAssignments(dataConnect);
// Fetch availabilities for the day of the shift
// Note: This is simplified. Proper day of week mapping would be needed.
// const dayOfWeek = format(new Date(shiftDate), "EEEE").toUpperCase();
// const { data: availabilitiesData } = useListStaffAvailabilitiesByDay(
// dataConnect,
// { day: dayOfWeek as any }
// );
const createAssignmentMutation = useCreateAssignment(dataConnect, {
onSuccess: () => {
// Also update the shift role's assigned count
updateShiftRoleMutation.mutate({
shiftId: shift.shiftId,
roleId: shift.roleId,
assigned: (shift.assigned || 0) + 1,
});
},
onError: () => {
toast({
title: "Assignment failed",
description: "Could not assign staff member. Please try again.",
variant: "destructive",
});
},
});
const updateShiftRoleMutation = useUpdateShiftRole(dataConnect, {
onSuccess: () => {
toast({
title: "Staff Assigned",
description: "The staff member has been successfully assigned to the shift.",
});
onSuccess();
onClose();
},
});
const staffList = useMemo(() => {
if (!workforceData?.workforces) return [];
return workforceData.workforces.map((w: any) => {
const staff = w.staff;
const workforceId = w.id;
// Basic skill matching (check if role name is in staff skills)
const roleName = shift.role?.name?.toLowerCase() || "";
const hasSkill = staff.skills?.some((s: string) => s.toLowerCase().includes(roleName)) || false;
// Conflict detection (check if staff is already assigned at this time)
const hasConflict = assignmentsData?.assignments?.some((a: any) => {
if (a.workforce.staff.id !== staff.id) return false;
if (a.status === AssignmentStatus.CANCELED) return false;
const aStart = new Date(a.shiftRole.startTime);
const aEnd = new Date(a.shiftRole.endTime);
const sStart = new Date(shiftStartTime);
const sEnd = new Date(shiftEndTime);
// Overlap check
return sStart < aEnd && aStart < sEnd;
});
return {
...staff,
workforceId,
hasSkill,
hasConflict,
reliability: staff.reliabilityScore || 0,
};
});
}, [workforceData, assignmentsData, shift, shiftStartTime, shiftEndTime]);
const filteredStaff = useMemo(() => {
return staffList.filter((s: any) =>
s.fullName.toLowerCase().includes(searchQuery.toLowerCase()) ||
s.email?.toLowerCase().includes(searchQuery.toLowerCase())
).sort((a: any, b: any) => {
// Sort by skill match and then reliability
if (a.hasSkill && !b.hasSkill) return -1;
if (!a.hasSkill && b.hasSkill) return 1;
return b.reliability - a.reliability;
});
}, [staffList, searchQuery]);
const handleAssign = () => {
if (!selectedStaff) return;
createAssignmentMutation.mutate({
workforceId: selectedStaff.workforceId,
shiftId: shift.shiftId,
roleId: shift.roleId,
status: AssignmentStatus.PENDING,
title: `Assignment for ${shift.role?.name || "Shift"}`,
});
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] flex flex-col p-0 overflow-hidden rounded-2xl border-none shadow-2xl">
<DialogHeader className="p-6 pb-2">
<DialogTitle className="text-2xl font-bold text-primary-text">Assign Staff</DialogTitle>
<DialogDescription className="text-secondary-text">
Select a staff member for the <span className="font-bold text-primary">{shift.role?.name || "Shift"}</span> role.
</DialogDescription>
</DialogHeader>
<div className="p-6 pt-2 flex-1 overflow-y-auto space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search staff by name or email..."
className="pl-10 rounded-xl border-border/50 bg-slate-50/50"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-secondary-text px-1">
Available Staff Members
</h3>
{isLoadingWorkforce ? (
<div className="flex flex-col items-center justify-center py-10 space-y-3">
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
<p className="text-sm text-muted-foreground">Loading workforce...</p>
</div>
) : filteredStaff.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-center bg-slate-50 rounded-2xl border border-dashed">
<p className="text-sm font-medium text-muted-foreground">No staff members found matching your search.</p>
</div>
) : (
<div className="grid grid-cols-1 gap-2">
{filteredStaff.map((staff: any) => (
<div
key={staff.id}
onClick={() => !staff.hasConflict && setSelectedStaff(staff)}
className={`
flex items-center justify-between p-3 rounded-xl border transition-all cursor-pointer
${selectedStaff?.id === staff.id
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border/40 hover:border-border hover:bg-slate-50/50"}
${staff.hasConflict ? "opacity-60 grayscale cursor-not-allowed" : ""}
`}
>
<div className="flex items-center gap-3">
<Avatar className="w-10 h-10 border-2 border-white shadow-sm">
<AvatarImage src={staff.photoUrl} />
<AvatarFallback className="bg-primary/10 text-primary font-bold">
{staff.fullName.charAt(0)}
</AvatarFallback>
</Avatar>
<div>
<div className="flex items-center gap-2">
<p className="font-bold text-primary-text text-sm">{staff.fullName}</p>
{staff.hasSkill && (
<Badge variant="secondary" className="bg-emerald-50 text-emerald-700 hover:bg-emerald-50 text-[9px] h-4 px-1 border-emerald-100">
SKILL MATCH
</Badge>
)}
</div>
<div className="flex items-center gap-3 mt-0.5">
<div className="flex items-center gap-1 text-[10px] text-muted-foreground font-medium">
<Star className="w-3 h-3 text-amber-500 fill-amber-500" />
{staff.reliability}% Reliability
</div>
<div className="flex items-center gap-1 text-[10px] text-muted-foreground font-medium">
<Clock className="w-3 h-3" />
{staff.totalShifts || 0} Shifts
</div>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{staff.hasConflict ? (
<div className="flex flex-col items-end">
<Badge variant="outline" className="text-red-600 bg-red-50 border-red-100 text-[10px]">
<AlertTriangle className="w-3 h-3 mr-1" />
CONFLICT
</Badge>
</div>
) : selectedStaff?.id === staff.id ? (
<div className="w-6 h-6 bg-primary rounded-full flex items-center justify-center text-white">
<Check className="w-4 h-4" />
</div>
) : (
<div className="w-6 h-6 border-2 border-border/50 rounded-full" />
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
<DialogFooter className="p-6 border-t border-border/40 bg-slate-50/50">
<Button variant="ghost" onClick={onClose} className="rounded-xl font-bold uppercase text-xs tracking-wider">
Cancel
</Button>
<Button
onClick={handleAssign}
disabled={!selectedStaff || createAssignmentMutation.isPending}
className="rounded-xl px-8 font-bold uppercase text-xs tracking-wider shadow-lg shadow-primary/20"
>
{createAssignmentMutation.isPending ? "Assigning..." : "Confirm Assignment"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,339 @@
import React, { useState } from "react";
import { useForm, useFieldArray } from "react-hook-form";
import { Button } from "@/common/components/ui/button";
import { Input } from "@/common/components/ui/input";
import { Label } from "@/common/components/ui/label";
import { Textarea } from "@/common/components/ui/textarea";
import { Card, CardContent } from "@/common/components/ui/card";
import {
Plus,
Trash2,
Info,
ChevronRight,
ChevronLeft,
Save,
X,
PlusCircle
} from "lucide-react";
import { format } from "date-fns";
interface Role {
name: string;
count: number;
rate?: number;
}
interface Shift {
id?: string;
title: string;
startTime: string;
endTime: string;
roles: Role[];
}
interface EventFormData {
event_name: string;
date: string;
startDate: string;
endDate: string;
notes: string;
po_reference: string;
total: number;
shifts: Shift[];
}
interface EventFormWizardProps {
event: any;
onSubmit: (data: any) => void;
isSubmitting: boolean;
currentUser: any;
onCancel: () => void;
}
export default function EventFormWizard({
event,
onSubmit,
isSubmitting,
onCancel
}: EventFormWizardProps) {
const [step, setStep] = useState(1);
const { register, control, handleSubmit, formState: { errors } } = useForm<EventFormData>({
defaultValues: {
event_name: event?.eventName || "",
date: event?.date ? format(new Date(event.date), "yyyy-MM-dd") : "",
startDate: event?.startDate ? format(new Date(event.startDate), "yyyy-MM-dd'T'HH:mm") : "",
endDate: event?.endDate ? format(new Date(event.endDate), "yyyy-MM-dd'T'HH:mm") : "",
notes: event?.notes || "",
po_reference: event?.poReference || "",
total: event?.total || 0,
shifts: event?.shifts?.map((s: any) => ({
...s,
roles: Array.isArray(s.roles) ? s.roles : []
})) || []
}
});
const { fields: shiftFields, append: appendShift, remove: removeShift } = useFieldArray({
control,
name: "shifts"
});
const nextStep = () => setStep(prev => prev + 1);
const prevStep = () => setStep(prev => prev - 1);
return (
<div className="space-y-6">
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-4">
{[1, 2].map((i) => (
<React.Fragment key={i}>
<div
className={`w-10 h-10 rounded-full flex items-center justify-center font-bold transition-colors ${
step >= i ? "bg-primary text-white" : "bg-muted text-muted-foreground"
}`}
>
{i}
</div>
{i < 2 && <div className={`w-12 h-0.5 ${step > i ? "bg-primary" : "bg-muted"}`} />}
</React.Fragment>
))}
</div>
<div className="text-right">
<p className="text-xs font-black text-muted-foreground uppercase tracking-widest">
Step {step} of 2
</p>
<p className="text-sm font-bold text-primary-text">
{step === 1 ? "Order Details" : "Shift Management"}
</p>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
{step === 1 && (
<div className="space-y-6 animate-in fade-in slide-in-from-right-4 duration-300">
<Card className="border-border shadow-sm">
<CardContent className="p-8 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label className="text-xs font-black uppercase tracking-widest text-muted-foreground">Event Name</Label>
<Input
{...register("event_name", { required: "Event name is required" })}
placeholder="e.g., Annual Gala 2024"
className="rounded-xl h-12"
/>
{errors.event_name && <p className="text-xs text-destructive font-bold">{errors.event_name.message}</p>}
</div>
<div className="space-y-2">
<Label className="text-xs font-black uppercase tracking-widest text-muted-foreground">PO Reference (Optional)</Label>
<Input
{...register("po_reference")}
placeholder="PO-12345"
className="rounded-xl h-12"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<Label className="text-xs font-black uppercase tracking-widest text-muted-foreground">Primary Date</Label>
<Input
type="date"
{...register("date", { required: "Date is required" })}
className="rounded-xl h-12"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-black uppercase tracking-widest text-muted-foreground">Start Date & Time</Label>
<Input
type="datetime-local"
{...register("startDate", { required: "Start time is required" })}
className="rounded-xl h-12"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-black uppercase tracking-widest text-muted-foreground">End Date & Time</Label>
<Input
type="datetime-local"
{...register("endDate", { required: "End time is required" })}
className="rounded-xl h-12"
/>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs font-black uppercase tracking-widest text-muted-foreground">Additional Notes</Label>
<Textarea
{...register("notes")}
placeholder="Enter any special instructions or requirements..."
className="rounded-xl min-h-[120px]"
/>
</div>
</CardContent>
</Card>
<div className="flex justify-between pt-4">
<Button type="button" variant="outline" onClick={onCancel} className="rounded-xl px-8 font-bold">
Cancel
</Button>
<Button type="button" onClick={nextStep} className="rounded-xl px-8 font-bold" trailingIcon={<ChevronRight className="w-4 h-4" />}>
Next: Manage Shifts
</Button>
</div>
</div>
)}
{step === 2 && (
<div className="space-y-6 animate-in fade-in slide-in-from-right-4 duration-300">
<div className="flex items-center justify-between">
<h3 className="text-xl font-bold text-primary-text">Shifts & Staffing</h3>
<Button
type="button"
onClick={() => appendShift({ title: "New Shift", startTime: "", endTime: "", roles: [] })}
variant="outline"
className="rounded-xl font-bold"
leadingIcon={<PlusCircle className="w-4 h-4" />}
>
Add Shift
</Button>
</div>
{shiftFields.map((field, shiftIndex) => (
<Card key={field.id} className="border-border shadow-sm overflow-hidden">
<div className="bg-muted/30 px-6 py-4 border-b border-border flex items-center justify-between">
<div className="flex items-center gap-4 flex-1">
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center text-primary font-bold">
{shiftIndex + 1}
</div>
<Input
{...register(`shifts.${shiftIndex}.title`)}
className="bg-transparent border-none font-bold text-lg p-0 focus-visible:ring-0"
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeShift(shiftIndex)}
className="text-destructive hover:bg-destructive/10"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
<CardContent className="p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground">Shift Start</Label>
<Input
type="datetime-local"
{...register(`shifts.${shiftIndex}.startTime`)}
className="rounded-xl"
/>
</div>
<div className="space-y-2">
<Label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground">Shift End</Label>
<Input
type="datetime-local"
{...register(`shifts.${shiftIndex}.endTime`)}
className="rounded-xl"
/>
</div>
</div>
<div className="space-y-4">
<Label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground">Roles & Headcount</Label>
<ShiftRoles
shiftIndex={shiftIndex}
control={control}
register={register}
/>
</div>
</CardContent>
</Card>
))}
{shiftFields.length === 0 && (
<div className="text-center py-12 bg-muted/20 rounded-2xl border-2 border-dashed border-border">
<Info className="w-12 h-12 text-muted-foreground/30 mx-auto mb-4" />
<p className="font-bold text-muted-foreground">No shifts added yet.</p>
<p className="text-sm text-muted-foreground mb-6">Every order needs at least one shift to request staff.</p>
<Button
type="button"
onClick={() => appendShift({ title: "Primary Shift", startTime: "", endTime: "", roles: [] })}
className="rounded-xl font-bold"
>
Add Your First Shift
</Button>
</div>
)}
<div className="flex justify-between pt-4">
<Button type="button" variant="outline" onClick={prevStep} className="rounded-xl px-8 font-bold" leadingIcon={<ChevronLeft className="w-4 h-4" />}>
Back
</Button>
<Button
type="submit"
disabled={isSubmitting}
className="rounded-xl px-12 font-bold shadow-lg shadow-primary/20"
leadingIcon={isSubmitting ? <Plus className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
>
{isSubmitting ? "Saving Changes..." : "Save Order Changes"}
</Button>
</div>
</div>
)}
</form>
</div>
);
}
function ShiftRoles({ shiftIndex, control, register }: { shiftIndex: number, control: any, register: any }) {
const { fields, append, remove } = useFieldArray({
control,
name: `shifts.${shiftIndex}.roles`
});
return (
<div className="space-y-3">
{fields.map((role, roleIndex) => (
<div key={role.id} className="flex items-center gap-3 bg-muted/20 p-3 rounded-xl border border-border/40">
<div className="flex-1">
<Input
{...register(`shifts.${shiftIndex}.roles.${roleIndex}.name`)}
placeholder="Role (e.g. Bartender)"
className="bg-transparent border-none font-bold h-8 focus-visible:ring-0"
/>
</div>
<div className="flex items-center gap-2">
<Label className="text-[10px] font-black text-muted-foreground uppercase">Count</Label>
<Input
type="number"
{...register(`shifts.${shiftIndex}.roles.${roleIndex}.count`)}
className="w-20 h-8 rounded-lg font-bold"
min="1"
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => remove(roleIndex)}
className="h-8 w-8 text-muted-foreground hover:text-destructive"
>
<X className="w-3 h-3" />
</Button>
</div>
))}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => append({ name: "", count: 1 })}
className="text-xs font-bold text-primary hover:bg-primary/5"
leadingIcon={<Plus className="w-3 h-3" />}
>
Add Role
</Button>
</div>
);
}

View File

@@ -0,0 +1,117 @@
import { Alert, AlertDescription } from "@/common/components/ui/alert";
import { Badge } from "@/common/components/ui/badge";
import { Button } from "@/common/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/common/components/ui/card";
import { AlertTriangle, CheckCircle, TrendingDown, UserMinus } from "lucide-react";
interface OrderReductionAlertProps {
originalRequested: number;
newRequested: number;
currentAssigned: number;
onAutoUnassign: () => void;
onManualUnassign: () => void;
lowReliabilityStaff?: Array<{ name: string; reliability: number }>;
}
export default function OrderReductionAlert({
originalRequested,
newRequested,
currentAssigned,
onAutoUnassign,
onManualUnassign,
lowReliabilityStaff = []
}: OrderReductionAlertProps) {
const excessStaff = currentAssigned - newRequested;
if (excessStaff <= 0) return null;
return (
<Card className="border-2 border-orange-500 bg-orange-50 shadow-lg">
<CardHeader className="bg-gradient-to-r from-orange-100 to-red-50 border-b border-orange-200">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-orange-500 rounded-xl flex items-center justify-center">
<AlertTriangle className="w-6 h-6 text-white" />
</div>
<div>
<CardTitle className="text-xl font-bold text-orange-900">
Order Size Reduction Detected
</CardTitle>
<p className="text-sm text-orange-700 mt-1">
Client reduced headcount from {originalRequested} to {newRequested}
</p>
</div>
</div>
</CardHeader>
<CardContent className="p-6 space-y-4">
<Alert className="bg-white border-orange-300">
<AlertDescription className="text-slate-900">
<div className="flex items-center gap-2 mb-2">
<TrendingDown className="w-5 h-5 text-orange-600" />
<span className="font-bold">Action Required:</span>
</div>
<p className="text-sm">
You have <strong className="text-orange-700">{excessStaff} staff member{excessStaff !== 1 ? 's' : ''}</strong> assigned
that exceed{excessStaff === 1 ? 's' : ''} the new request.
You must unassign {excessStaff} worker{excessStaff !== 1 ? 's' : ''} to match the new headcount.
</p>
</AlertDescription>
</Alert>
<div className="grid grid-cols-3 gap-4">
<div className="bg-white border-2 border-slate-200 rounded-xl p-4 text-center">
<p className="text-xs text-slate-500 mb-1">Original Request</p>
<p className="text-2xl font-bold text-slate-900">{originalRequested}</p>
</div>
<div className="bg-white border-2 border-orange-300 rounded-xl p-4 text-center">
<p className="text-xs text-orange-600 mb-1">New Request</p>
<p className="text-2xl font-bold text-orange-700">{newRequested}</p>
</div>
<div className="bg-white border-2 border-red-300 rounded-xl p-4 text-center">
<p className="text-xs text-red-600 mb-1">Must Remove</p>
<p className="text-2xl font-bold text-red-700">{excessStaff}</p>
</div>
</div>
<div className="space-y-3">
<Button
onClick={onManualUnassign}
variant="outline"
className="w-full border-2 border-slate-300 hover:bg-slate-50"
>
<UserMinus className="w-4 h-4 mr-2" />
Manually Select Which Staff to Remove
</Button>
{lowReliabilityStaff.length > 0 && (
<Button
onClick={onAutoUnassign}
className="w-full bg-orange-600 hover:bg-orange-700 text-white"
>
<CheckCircle className="w-4 h-4 mr-2" />
Auto-Remove {excessStaff} Lowest Reliability Staff
</Button>
)}
</div>
{lowReliabilityStaff.length > 0 && (
<div className="bg-white border border-orange-200 rounded-lg p-4">
<p className="text-xs font-bold text-slate-700 mb-3 uppercase">
Suggested for Auto-Removal (Lowest Reliability):
</p>
<div className="space-y-2">
{lowReliabilityStaff.slice(0, excessStaff).map((staff, idx) => (
<div key={idx} className="flex items-center justify-between p-2 bg-red-50 rounded-lg border border-red-200">
<span className="text-sm font-medium text-slate-900">{staff.name}</span>
<Badge variant="outline" className="border-red-400 text-red-700">
Reliability: {staff.reliability}%
</Badge>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,487 @@
import { useQueryClient } from "@tanstack/react-query";
import {
addDays,
addWeeks,
addMonths,
format,
isSameDay,
isToday,
isValid,
parseISO,
startOfWeek,
startOfMonth,
endOfMonth,
endOfWeek,
eachDayOfInterval,
subWeeks,
subMonths,
subDays
} from "date-fns";
import {
Calendar as CalendarIcon,
ChevronLeft,
ChevronRight,
Clock,
DollarSign,
Plus,
CalendarDays,
Users,
AlertTriangle
} from "lucide-react";
import { useState, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { Badge } from "@/common/components/ui/badge";
import { Button } from "@/common/components/ui/button";
import { Card, CardContent } from "@/common/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription
} from "@/common/components/ui/dialog";
import { Alert, AlertDescription, AlertTitle } from "@/common/components/ui/alert";
import { Tabs, TabsList, TabsTrigger } from "@/common/components/ui/tabs";
import DashboardLayout from "@/features/layouts/DashboardLayout";
import { useListOrders, useUpdateOrder } from "@/dataconnect-generated/react";
import { dataConnect } from "@/features/auth/firebase";
import { OrderStatus } from "@/dataconnect-generated";
import { useToast } from "@/common/components/ui/use-toast";
/**
* Maps OrderStatus to appropriate Tailwind color classes
*/
const getStatusColor = (status: OrderStatus | undefined) => {
switch (status) {
case OrderStatus.DRAFT:
case OrderStatus.CANCELLED:
return "bg-slate-100 text-slate-600 border-slate-200";
case OrderStatus.FILLED:
case OrderStatus.FULLY_STAFFED:
case OrderStatus.COMPLETED:
return "bg-emerald-50 text-emerald-700 border-emerald-200";
case OrderStatus.POSTED:
case OrderStatus.PENDING:
case OrderStatus.PARTIAL_STAFFED:
return "bg-blue-50 text-blue-700 border-blue-200";
default:
return "bg-slate-50 text-slate-600 border-slate-200";
}
};
/**
* Safely parses various date formats into a Date object
*/
const safeParseDate = (dateString: any) => {
if (!dateString) return null;
try {
const date = typeof dateString === 'string' ? parseISO(dateString) : new Date(dateString);
return isValid(date) ? date : null;
} catch {
return null;
}
};
export default function Schedule() {
const navigate = useNavigate();
const { toast } = useToast();
const queryClient = useQueryClient();
const [currentDate, setCurrentDate] = useState(new Date());
const [viewMode, setViewMode] = useState<'day' | 'week' | 'month'>('week');
const [selectedOrder, setSelectedOrder] = useState<any>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
// State for rescheduling confirmation
const [rescheduleData, setRescheduleData] = useState<{ order: any; newDate: Date } | null>(null);
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
// Fetch real data from Data Connect
const { data, isLoading } = useListOrders(dataConnect);
const orders = data?.orders || [];
// Mutation for drag-and-drop rescheduling
const updateOrderMutation = useUpdateOrder(dataConnect, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["listOrders"] });
toast({
title: "Order Rescheduled",
description: "The order date has been updated successfully.",
});
setIsConfirmOpen(false);
setRescheduleData(null);
},
onError: () => {
toast({
title: "Update Failed",
description: "Could not reschedule the order. Please try again.",
variant: "destructive",
});
}
});
// Calculate days to display based on current view mode
const calendarDays = useMemo(() => {
if (viewMode === 'month') {
const start = startOfWeek(startOfMonth(currentDate));
const end = endOfWeek(endOfMonth(currentDate));
return eachDayOfInterval({ start, end });
} else if (viewMode === 'week') {
const start = startOfWeek(currentDate);
const end = endOfWeek(currentDate);
return eachDayOfInterval({ start, end });
} else {
return [currentDate];
}
}, [currentDate, viewMode]);
const getOrdersForDay = (date: Date) => {
return orders.filter((order) => {
const orderDate = safeParseDate(order.date);
return orderDate && isSameDay(orderDate, date);
});
};
// Calculate metrics for the current visible range
const metrics = useMemo(() => {
const visibleOrders = orders.filter(order => {
const orderDate = safeParseDate(order.date);
if (!orderDate) return false;
return orderDate >= calendarDays[0] && orderDate <= calendarDays[calendarDays.length - 1];
});
const totalHours = visibleOrders.reduce((sum, order) => {
const shifts = Array.isArray(order.shifts) ? order.shifts : [];
const orderHours = shifts.reduce((shiftSum: number, shift: any) => {
const roles = Array.isArray(shift.roles) ? shift.roles : [];
return shiftSum + roles.reduce((roleSum: number, role: any) => roleSum + (Number(role.hours) || 0), 0);
}, 0);
return sum + orderHours;
}, 0);
const totalCost = visibleOrders.reduce((sum, order) => sum + (order.total || 0), 0);
const totalShifts = visibleOrders.reduce((sum, order) => sum + (Array.isArray(order.shifts) ? order.shifts.length : 0), 0);
return { totalHours, totalCost, totalShifts };
}, [orders, calendarDays]);
// Navigation handlers
const handlePrev = () => {
if (viewMode === 'month') setCurrentDate(subMonths(currentDate, 1));
else if (viewMode === 'week') setCurrentDate(subWeeks(currentDate, 1));
else setCurrentDate(subDays(currentDate, 1));
};
const handleNext = () => {
if (viewMode === 'month') setCurrentDate(addMonths(currentDate, 1));
else if (viewMode === 'week') setCurrentDate(addWeeks(currentDate, 1));
else setCurrentDate(addDays(currentDate, 1));
};
const handleToday = () => setCurrentDate(new Date());
const handleEventClick = (order: any, e: React.MouseEvent) => {
e.stopPropagation();
setSelectedOrder(order);
setIsModalOpen(true);
};
// Drag and Drop Logic
const onDragStart = (e: React.DragEvent, order: any) => {
e.dataTransfer.setData("orderId", order.id);
};
const onDrop = async (e: React.DragEvent, date: Date) => {
e.preventDefault();
const orderId = e.dataTransfer.getData("orderId");
const order = orders.find(o => o.id === orderId);
if (order && !isSameDay(safeParseDate(order.date)!, date)) {
setRescheduleData({ order, newDate: date });
setIsConfirmOpen(true);
}
};
const confirmReschedule = () => {
if (rescheduleData) {
updateOrderMutation.mutate({
id: rescheduleData.order.id,
date: rescheduleData.newDate.toISOString()
});
}
};
if (isLoading) {
return (
<DashboardLayout title="Schedule" subtitle="Loading shifts...">
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
</DashboardLayout>
);
}
return (
<DashboardLayout
title="Schedule"
subtitle="Plan and manage staff shifts"
actions={
<Button onClick={() => navigate('/orders/new')}>
<Plus className="w-4 h-4 mr-2" />
New Order
</Button>
}
>
<div className="space-y-6">
{/* Metrics Section */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="bg-white border-border/50 shadow-sm">
<CardContent className="p-6 flex items-center justify-between">
<div>
<p className="text-secondary-text text-xs font-bold uppercase tracking-wider mb-1">Total Hours</p>
<p className="text-2xl font-bold text-primary-text">{metrics.totalHours.toFixed(1)}</p>
</div>
<div className="p-3 bg-blue-50 rounded-xl text-blue-600">
<Clock className="w-5 h-5" />
</div>
</CardContent>
</Card>
<Card className="bg-white border-border/50 shadow-sm">
<CardContent className="p-6 flex items-center justify-between">
<div>
<p className="text-secondary-text text-xs font-bold uppercase tracking-wider mb-1">Labor Cost</p>
<p className="text-2xl font-bold text-primary-text">${metrics.totalCost.toLocaleString()}</p>
</div>
<div className="p-3 bg-emerald-50 rounded-xl text-emerald-600">
<DollarSign className="w-5 h-5" />
</div>
</CardContent>
</Card>
<Card className="bg-white border-border/50 shadow-sm">
<CardContent className="p-6 flex items-center justify-between">
<div>
<p className="text-secondary-text text-xs font-bold uppercase tracking-wider mb-1">Total Shifts</p>
<p className="text-2xl font-bold text-primary-text">{metrics.totalShifts}</p>
</div>
<div className="p-3 bg-teal-50 rounded-xl text-teal-600">
<CalendarIcon className="w-5 h-5" />
</div>
</CardContent>
</Card>
</div>
{/* Calendar Controls */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-white p-4 rounded-xl border border-border/50 shadow-sm">
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" onClick={handlePrev} className="h-9 w-9">
<ChevronLeft className="w-4 h-4" />
</Button>
<Button variant="outline" onClick={handleToday} className="h-9 px-4 text-xs font-bold uppercase tracking-wider">
Today
</Button>
<Button variant="outline" size="icon" onClick={handleNext} className="h-9 w-9">
<ChevronRight className="w-4 h-4" />
</Button>
<h2 className="ml-4 text-lg font-bold text-primary-text">
{format(currentDate, viewMode === 'month' ? 'MMMM yyyy' : 'MMMM d, yyyy')}
</h2>
</div>
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as any)} className="w-full md:w-auto">
<TabsList className="grid grid-cols-3 w-full md:w-[240px]">
<TabsTrigger value="day" className="text-xs font-bold uppercase tracking-wider">Day</TabsTrigger>
<TabsTrigger value="week" className="text-xs font-bold uppercase tracking-wider">Week</TabsTrigger>
<TabsTrigger value="month" className="text-xs font-bold uppercase tracking-wider">Month</TabsTrigger>
</TabsList>
</Tabs>
</div>
{/* Calendar Grid */}
<div className={`grid gap-4 ${
viewMode === 'month' ? 'grid-cols-7' :
viewMode === 'week' ? 'grid-cols-1 md:grid-cols-7' :
'grid-cols-1'
}`}>
{/* Day Names */}
{(viewMode === 'month' || (viewMode === 'week' && window.innerWidth > 768)) &&
['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
<div key={day} className="text-center py-2 text-[10px] font-bold uppercase tracking-widest text-secondary-text">
{day}
</div>
))
}
{calendarDays.map((day) => {
const dayOrders = getOrdersForDay(day);
const isTodayDay = isToday(day);
const isDifferentMonth = viewMode === 'month' && day.getMonth() !== currentDate.getMonth();
return (
<div
key={day.toISOString()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => onDrop(e, day)}
className={`min-h-[140px] flex flex-col p-2 rounded-xl border transition-all ${
isTodayDay ? 'bg-primary/5 border-primary ring-1 ring-primary/20' :
isDifferentMonth ? 'bg-slate-50/50 border-border/30 opacity-60' :
'bg-white border-border/50 hover:border-border hover:shadow-sm'
} ${viewMode === 'day' ? 'min-h-[500px]' : ''}`}
>
<div className="flex items-center justify-between mb-2 px-1">
<span className={`text-sm font-bold ${isTodayDay ? 'text-primary' : 'text-primary-text'}`}>
{format(day, viewMode === 'day' ? 'EEEE, MMMM d' : 'd')}
</span>
{dayOrders.length > 0 && (
<span className="text-[10px] font-bold text-secondary-text bg-slate-100 px-1.5 py-0.5 rounded">
{dayOrders.length}
</span>
)}
</div>
<div className="flex-1 space-y-1 overflow-y-auto max-h-[300px] scrollbar-hide">
{dayOrders.map((order: any) => (
<div
key={order.id}
draggable
onDragStart={(e) => onDragStart(e, order)}
onClick={(e) => handleEventClick(order, e)}
className={`p-2 rounded-lg border text-left cursor-pointer transition-all hover:scale-[1.02] active:scale-95 shadow-sm ${getStatusColor(order.status)}`}
>
<p className="text-[11px] font-bold truncate leading-tight mb-1">
{order.eventName || "Unnamed Order"}
</p>
<div className="flex items-center gap-1 opacity-80">
<Clock className="w-3 h-3" />
<span className="text-[9px] font-medium">
{order.shifts?.[0]?.roles?.[0]?.start_time || "00:00"}
</span>
</div>
</div>
))}
{dayOrders.length === 0 && viewMode !== 'month' && (
<div className="flex-1 flex items-center justify-center opacity-30 border border-dashed rounded-lg py-8">
<p className="text-[10px] font-bold uppercase tracking-wider">No Orders</p>
</div>
)}
</div>
</div>
);
})}
</div>
</div>
{/* Order Detail Modal */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<div className="flex items-center justify-between pr-8">
<DialogTitle className="text-xl font-bold">{selectedOrder?.eventName || "Order Details"}</DialogTitle>
<Badge className={getStatusColor(selectedOrder?.status)}>
{selectedOrder?.status}
</Badge>
</div>
</DialogHeader>
{selectedOrder && (
<div className="space-y-6 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<p className="text-[10px] font-bold text-secondary-text uppercase tracking-wider">Date</p>
<div className="flex items-center gap-2">
<CalendarDays className="w-4 h-4 text-primary" />
<span className="text-sm font-medium">{format(safeParseDate(selectedOrder.date)!, 'PPPP')}</span>
</div>
</div>
<div className="space-y-1">
<p className="text-[10px] font-bold text-secondary-text uppercase tracking-wider">Total Value</p>
<div className="flex items-center gap-2">
<DollarSign className="w-4 h-4 text-emerald-600" />
<span className="text-sm font-bold text-emerald-700">${selectedOrder.total?.toLocaleString() || '0.00'}</span>
</div>
</div>
</div>
<div className="space-y-1">
<p className="text-[10px] font-bold text-secondary-text uppercase tracking-wider">Business</p>
<div className="flex items-center gap-2 bg-slate-50 p-2 rounded-lg border border-slate-100">
<Users className="w-4 h-4 text-blue-600" />
<span className="text-sm font-medium">{selectedOrder.business?.businessName || "Krow Workforce"}</span>
</div>
</div>
<div className="space-y-3">
<p className="text-[10px] font-bold text-secondary-text uppercase tracking-wider">Shifts & Staffing</p>
<div className="space-y-2">
{Array.isArray(selectedOrder.shifts) && selectedOrder.shifts.map((shift: any, i: number) => (
<div key={i} className="p-3 rounded-lg border border-border/60 bg-white shadow-sm flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center text-xs font-bold">
{i + 1}
</div>
<div>
<p className="text-xs font-bold text-primary-text">{shift.shiftName || `Shift ${i+1}`}</p>
<p className="text-[10px] text-secondary-text font-medium">
{shift.roles?.[0]?.start_time} - {shift.roles?.[0]?.end_time}
</p>
</div>
</div>
<Badge variant="outline" className="text-[9px] font-bold uppercase tracking-wider">
{Array.isArray(shift.assignedStaff) ? shift.assignedStaff.length : 0} Assigned
</Badge>
</div>
))}
</div>
</div>
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => navigate(`/orders/${selectedOrder?.id}`)} className="w-full sm:w-auto">
View Full Details
</Button>
<Button onClick={() => navigate(`/orders/${selectedOrder?.id}/edit`)} className="w-full sm:w-auto">
Edit Order
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Reschedule Confirmation Dialog */}
<Dialog open={isConfirmOpen} onOpenChange={setIsConfirmOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Confirm Reschedule</DialogTitle>
<DialogDescription>
Are you sure you want to move this order to a different date?
</DialogDescription>
</DialogHeader>
{rescheduleData && (
<div className="py-4">
<Alert variant="default" className="bg-amber-50 border-amber-200">
<AlertTriangle className="h-4 w-4 text-amber-600" />
<AlertTitle className="text-amber-800">Confirm Date Change</AlertTitle>
<AlertDescription className="text-amber-700">
Moving <strong>{rescheduleData.order.eventName}</strong> to <strong>{format(rescheduleData.newDate, 'PPPP')}</strong>.
</AlertDescription>
</Alert>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setIsConfirmOpen(false)}>
Cancel
</Button>
<Button
onClick={confirmReschedule}
disabled={updateOrderMutation.isPending}
>
{updateOrderMutation.isPending ? "Updating..." : "Confirm Move"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,277 @@
import { useMemo, useState } from "react";
import { Button } from "@/common/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/common/components/ui/dropdown-menu";
import { Input } from "@/common/components/ui/input";
import { useToast } from "@/common/components/ui/use-toast";
import DashboardLayout from "@/features/layouts/DashboardLayout";
import { DragDropContext, Draggable,type DraggableProvided,type DropResult } from "@hello-pangea/dnd";
import { useQueryClient } from "@tanstack/react-query";
import { ArrowUpDown, Calendar, Link2, MoreVertical, Palette, Ruler, Search, Users } from "lucide-react";
import { useListShifts, useUpdateShift, useListBusinesses } from "@/dataconnect-generated/react";
import { ShiftStatus } from "@/dataconnect-generated";
import { dataConnect } from "@/features/auth/firebase";
import TaskCard from "./TaskCard";
import TaskColumn from "./TaskColumn";
import { format } from "date-fns";
/**
* TaskBoard Feature Component
* Kanban board for managing shift assignments and their current status.
* Optimized for real-time data using Firebase Data Connect.
*/
export default function TaskBoard() {
const { toast } = useToast();
const queryClient = useQueryClient();
// UI State
const [searchQuery, setSearchQuery] = useState("");
const [filterClient, setFilterClient] = useState("all");
const [filterDate, setFilterDate] = useState("");
const [sortBy, setSortBy] = useState("date");
const [itemHeight, setItemHeight] = useState<"compact" | "normal" | "comfortable">("normal");
const [conditionalColoring, setConditionalColoring] = useState(true);
// Queries
const { data: shiftsData, isLoading: shiftsLoading } = useListShifts(dataConnect);
const { data: clientsData } = useListBusinesses(dataConnect);
const shifts = useMemo(() => shiftsData?.shifts || [], [shiftsData]);
const clients = useMemo(() => clientsData?.businesses || [], [clientsData]);
// Filtering & Sorting Logic
const filteredShifts = useMemo(() => {
let result = [...shifts];
if (searchQuery) {
result = result.filter(s =>
s.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
s.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
s.location?.toLowerCase().includes(searchQuery.toLowerCase())
);
}
if (filterClient !== "all") {
result = result.filter(s => s.order?.businessId === filterClient);
}
if (filterDate) {
result = result.filter(s => {
if (!s.date) return false;
return format(new Date(s.date), 'yyyy-MM-dd') === filterDate;
});
}
// Sort
return result.sort((a, b) => {
switch (sortBy) {
case "date":
return new Date(a.date || '9999-12-31').getTime() - new Date(b.date || '9999-12-31').getTime();
case "title":
return (a.title || '').localeCompare(b.title || '');
default:
return 0;
}
});
}, [shifts, searchQuery, filterClient, filterDate, sortBy]);
// Grouping Logic
const shiftsByStatus = useMemo<Partial<Record<ShiftStatus, typeof filteredShifts>>>(() => ({
[ShiftStatus.OPEN]: filteredShifts.filter(s => s.status === ShiftStatus.OPEN),
[ShiftStatus.PENDING]: filteredShifts.filter(s => s.status === ShiftStatus.PENDING),
[ShiftStatus.CONFIRMED]: filteredShifts.filter(s => s.status === ShiftStatus.CONFIRMED),
[ShiftStatus.IN_PROGRESS]: filteredShifts.filter(s => s.status === ShiftStatus.IN_PROGRESS),
[ShiftStatus.COMPLETED]: filteredShifts.filter(s => s.status === ShiftStatus.COMPLETED),
}), [filteredShifts]);
const overallProgress = useMemo(() => {
if (shifts.length === 0) return 0;
const completedCount = shifts.filter(s => s.status === ShiftStatus.COMPLETED).length;
return Math.round((completedCount / shifts.length) * 100);
}, [shifts]);
// Mutations
const { mutate: updateShiftStatus } = useUpdateShift(dataConnect, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listShifts'] });
toast({ title: "Status Updated", description: "Shift status has been updated successfully." });
},
});
// Handlers
const handleDragEnd = (result: DropResult) => {
if (!result.destination) return;
const { source, destination, draggableId } = result;
if (source.droppableId === destination.droppableId && source.index === destination.index) return;
updateShiftStatus({
id: draggableId,
status: destination.droppableId as ShiftStatus
});
};
return (
<DashboardLayout
title="Shift Assignment Board"
subtitle={`${overallProgress}% Global Completion`}
actions={[
(
<Button variant="outline" leadingIcon={<Link2 />}>
Share Board
</Button>
)
]}
>
{/* Page Header */}
<div className="flex flex-col gap-2">
{/* Main Toolbar */}
<div className="flex items-center gap-4 flex-wrap">
<Input
placeholder="Search shifts, events, or locations..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="relative w-full md:w-96"
leadingIcon={<Search />}
/>
<div className="flex items-center gap-2 flex-1 justify-end">
<Input
type="date"
value={filterDate}
onChange={(e) => setFilterDate(e.target.value)}
className="w-44"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" leadingIcon={<Users />}>
Clients
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64 rounded-2xl p-2 shadow-2xl border-border/50 ">
<DropdownMenuLabel className="px-3 pb-2 pt-1 text-[10px] font-bold uppercase text-muted-foreground tracking-widest leading-none">Filter by Client</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setFilterClient("all")} className="rounded-xl font-medium py-3 px-4 hover:bg-primary/5 transition-colors">All Clients</DropdownMenuItem>
<DropdownMenuSeparator className="my-1 opacity-40 mx-2" />
<div className="max-h-60 overflow-y-auto custom-scrollbar">
{clients.map((client) => (
<DropdownMenuItem
key={client.id}
onClick={() => setFilterClient(client.id)}
className="rounded-xl font-medium px-4 flex items-center gap-3 hover:bg-primary/5 transition-colors"
>
<span className="text-sm">{client.businessName}</span>
</DropdownMenuItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" leadingIcon={<ArrowUpDown />}>
Sort
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64 rounded-2xl p-2 shadow-2xl border-border/50 ">
<DropdownMenuLabel className="text-[10px] font-bold uppercase text-muted-foreground tracking-widest">Ordering Options</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setSortBy("date")} className="rounded-xl font-medium flex items-center justify-between">Date <Calendar className="w-3.5 h-3.5 opacity-40" /></DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy("title")} className="rounded-xl font-medium flex items-center justify-between">Title <Ruler className="w-3.5 h-3.5 opacity-40" /></DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="w-px h-8 bg-border/40 mx-1" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" leadingIcon={<MoreVertical />}>
View Options
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64 rounded-2xl p-2 shadow-2xl border-border/50 ">
<DropdownMenuLabel className="text-[10px] font-bold uppercase text-muted-foreground tracking-widest">Display Settings</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setItemHeight("compact")} className="rounded-xl font-medium gap-3 hover:bg-primary/5"><Ruler className="w-4 h-4 opacity-40" /> Compact Cards</DropdownMenuItem>
<DropdownMenuItem onClick={() => setItemHeight("normal")} className="rounded-xl font-medium gap-3 hover:bg-primary/5"><Ruler className="w-4 h-4 opacity-40" /> Normal Layout</DropdownMenuItem>
<DropdownMenuItem onClick={() => setItemHeight("comfortable")} className="rounded-xl font-medium gap-3 hover:bg-primary/5"><Ruler className="w-4 h-4 opacity-40" /> Comfort View</DropdownMenuItem>
<DropdownMenuSeparator className="my-1 opacity-40 mx-2" />
<DropdownMenuItem onClick={() => setConditionalColoring(!conditionalColoring)} className="rounded-xl font-medium gap-3 hover:bg-primary/5">
<Palette className={`w-4 h-4 ${conditionalColoring ? 'text-primary' : 'opacity-40'}`} />
{conditionalColoring ? 'Minimalist Mode' : 'Enhanced Visuals'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Overall Progress */}
<div className="my-8 flex items-center gap-8 bg-primary/5 p-6 rounded-2xl border border-primary/10">
<div className="flex-1">
<div className="flex items-center justify-between mb-3">
<span className="text-[10px] font-bold text-primary uppercase tracking-[0.1em]">Global Completion</span>
<span className="text-xl font-medium text-primary tabular-nums tracking-tight">{overallProgress}%</span>
</div>
<div className="h-2 bg-primary/10 rounded-full overflow-hidden shadow-inner">
<div
className="h-full bg-primary transition-all duration-1000 ease-in-out shadow-[0_0_15px_rgba(var(--primary),0.3)]"
style={{ width: `${overallProgress}%` }}
/>
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-8">
{/* Kanban Area */}
<DragDropContext onDragEnd={handleDragEnd}>
<div className="flex gap-6 overflow-x-auto pb-12 snap-x px-4 -mx-4">
{[
{ id: ShiftStatus.OPEN, title: "Unassigned" },
{ id: ShiftStatus.PENDING, title: "Pending Acceptance" },
{ id: ShiftStatus.CONFIRMED, title: "Confirmed" },
{ id: ShiftStatus.IN_PROGRESS, title: "In Progress" },
{ id: ShiftStatus.COMPLETED, title: "Completed" },
].map((column) => (
<TaskColumn
key={column.id}
status={column.id}
title={column.title}
tasks={shiftsByStatus[column.id] || []}
>
{(shiftsByStatus[column.id] || []).map((shift, index) => (
<Draggable key={shift.id} draggableId={shift.id} index={index}>
{(provided: DraggableProvided) => (
<TaskCard
task={shift as any}
provided={provided}
onClick={() => {}}
itemHeight={itemHeight}
conditionalColoring={conditionalColoring}
/>
)}
</Draggable>
))}
</TaskColumn>
))}
</div>
</DragDropContext>
{filteredShifts.length === 0 && (
<div className="flex flex-col items-center justify-center py-32 bg-white/50 rounded-[2.5rem] border border-dashed border-border/50 text-center animate-premium-fade-in ">
<div className="w-24 h-24 bg-primary/5 rounded-[2rem] flex items-center justify-center mb-8 shadow-inner border border-primary/10">
<Calendar className="w-12 h-12 text-primary/20" />
</div>
<h3 className="text-2xl font-medium text-foreground mb-4 tracking-tight">No shifts found</h3>
<p className="text-muted-foreground font-medium max-w-sm mx-auto mb-10 leading-relaxed text-sm">No shifts matching your current filters. Adjust your search or filters to see more.</p>
</div>
)}
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,120 @@
import { Card } from "@/common/components/ui/card";
import { Badge } from "@/common/components/ui/badge";
import { MoreVertical, Calendar, MapPin, Building2, Users } from "lucide-react";
import { format } from "date-fns";
import {type DraggableProvided } from "@hello-pangea/dnd";
import { ShiftStatus } from "@/dataconnect-generated";
interface TaskCardProps {
task: any; // Using any for Shift data from Data Connect
provided?: DraggableProvided;
onClick: () => void;
itemHeight?: "compact" | "normal" | "comfortable";
conditionalColoring?: boolean;
}
const statusConfig: Record<string, { bg: string; border: string; text: string; label: string }> = {
[ShiftStatus.OPEN]: { bg: "bg-slate-500/10", border: "border-slate-500/20", text: "text-slate-700", label: "Open" },
[ShiftStatus.PENDING]: { bg: "bg-blue-500/10", border: "border-blue-500/20", text: "text-blue-700", label: "Pending" },
[ShiftStatus.CONFIRMED]: { bg: "bg-emerald-500/10", border: "border-emerald-500/20", text: "text-emerald-700", label: "Confirmed" },
[ShiftStatus.IN_PROGRESS]: { bg: "bg-amber-500/10", border: "border-amber-500/20", text: "text-amber-700", label: "In Progress" },
[ShiftStatus.COMPLETED]: { bg: "bg-green-500/10", border: "border-green-500/20", text: "text-green-700", label: "Completed" }
};
/**
* TaskCard Component refactored for Shifts
* Renders a single shift card in the Kanban board.
*/
export default function TaskCard({
task,
provided,
onClick,
itemHeight = "normal",
conditionalColoring = true
}: TaskCardProps) {
const heightClasses = {
compact: "p-3",
normal: "p-5",
comfortable: "p-6"
};
const cardPadding = heightClasses[itemHeight] || heightClasses.normal;
const status = statusConfig[task.status] || statusConfig[ShiftStatus.OPEN];
const staffingProgress = task.workersNeeded > 0 ? (task.filled / task.workersNeeded) * 100 : 0;
const progressColor = staffingProgress === 100 ? "bg-emerald-500" : "bg-primary";
return (
<Card
ref={provided?.innerRef}
{...provided?.draggableProps}
{...provided?.dragHandleProps}
onClick={onClick}
className={`bg-white border border-border/50 hover:shadow-xl hover:border-primary/20 transition-premium cursor-pointer mb-3 rounded-2xl group`}
>
<div className={cardPadding}>
{/* Client & Header */}
<div className="flex flex-col gap-1 mb-3">
<div className="flex items-center gap-1.5 text-[10px] font-bold text-primary uppercase tracking-wider">
<Building2 className="w-3 h-3" />
{task.order?.business?.businessName || "No Client"}
</div>
<div className="flex items-start justify-between">
<h4 className="font-medium text-foreground text-sm flex-1 tracking-tight leading-snug group-hover:text-primary transition-colors">
{task.title}
</h4>
<button className="text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 transition-opacity">
<MoreVertical className="w-4 h-4" />
</button>
</div>
</div>
{/* Event Name */}
{task.order?.eventName && (
<p className="text-[11px] text-muted-foreground mb-4 font-medium italic">
{task.order.eventName}
</p>
)}
{/* Status & Date */}
<div className="flex items-center justify-between mb-4">
<Badge className={`${status.bg} ${status.text} ${status.border} text-[10px] font-medium uppercase tracking-wider px-2 py-0.5 border shadow-none ring-0`}>
{status.label}
</Badge>
{task.date && (
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground font-medium">
<Calendar className="w-3.5 h-3.5 text-primary/60" />
{format(new Date(task.date), 'd MMM yyyy')}
</div>
)}
</div>
{/* Staffing Progress */}
<div className="mb-4">
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-1.5 text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
<Users className="w-3 h-3" />
Staffing: {task.filled}/{task.workersNeeded}
</div>
<span className="text-[10px] font-medium text-muted-foreground tabular-nums">{Math.round(staffingProgress)}%</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden shadow-inner">
<div
className={`h-full ${progressColor} transition-all duration-700 ease-out`}
style={{ width: `${staffingProgress}%` }}
/>
</div>
</div>
{/* Footer: Location */}
{task.location && (
<div className="flex items-center gap-1.5 pt-3 border-t border-border/40 text-[11px] text-muted-foreground font-medium">
<MapPin className="w-3.5 h-3.5 text-primary/60" />
<span className="truncate">{task.location}</span>
</div>
)}
</div>
</Card>
);
}

View File

@@ -0,0 +1,76 @@
import React from "react";
import { Badge } from "@/common/components/ui/badge";
import { Plus, MoreVertical } from "lucide-react";
import { Droppable,type DroppableProvided,type DroppableStateSnapshot } from "@hello-pangea/dnd";
import { ShiftStatus } from "@/dataconnect-generated";
interface TaskColumnProps {
status: string;
title?: string;
tasks: any[];
children: React.ReactNode;
onAddTask?: (status: string) => void;
}
const columnConfig: Record<string, { bg: string; text: string; border: string; label: string; dot: string }> = {
[ShiftStatus.OPEN]: { bg: "bg-slate-500/10", text: "text-slate-700", border: "border-slate-500/20", label: "Unassigned", dot: "bg-slate-500" },
[ShiftStatus.PENDING]: { bg: "bg-blue-500/10", text: "text-blue-700", border: "border-blue-500/20", label: "Pending", dot: "bg-blue-500" },
[ShiftStatus.CONFIRMED]: { bg: "bg-emerald-500/10", text: "text-emerald-700", border: "border-emerald-500/20", label: "Confirmed", dot: "bg-emerald-500" },
[ShiftStatus.IN_PROGRESS]: { bg: "bg-amber-500/10", text: "text-amber-700", border: "border-amber-500/20", label: "In Progress", dot: "bg-amber-500" },
[ShiftStatus.COMPLETED]: { bg: "bg-green-500/10", text: "text-green-700", border: "border-green-500/20", label: "Completed", dot: "bg-green-500" }
};
/**
* TaskColumn Component
* Renders a Kanban column with a droppable area for Shifts.
*/
export default function TaskColumn({ status, title, tasks, children, onAddTask }: TaskColumnProps) {
const config = columnConfig[status] || { bg: "bg-gray-500/10", text: "text-gray-700", border: "border-gray-500/20", label: title || status, dot: "bg-gray-500" };
return (
<div className="flex-1 min-w-[320px] flex flex-col h-full group/column">
{/* Column Header */}
<div className={`${config.bg} ${config.text} ${config.border} border rounded-t-2xl px-5 py-4 flex items-center justify-between shadow-sm transition-premium`}>
<div className="flex items-center gap-3">
<div className={`w-2 h-2 rounded-full ${config.dot} shadow-[0_0_8px_rgba(var(--primary),0.4)]`} />
<span className="font-bold uppercase tracking-widest text-[11px]">{title || config.label}</span>
<Badge variant="outline" className="bg-white/50 text-current border-current/20 font-medium px-2 py-0 h-5 text-[10px] shadow-sm">
{tasks.length}
</Badge>
</div>
<div className="flex items-center gap-1.5">
{onAddTask && (
<button
onClick={() => onAddTask(status)}
className="w-8 h-8 hover:bg-white/50 rounded-xl flex items-center justify-center transition-premium active:scale-90 border border-transparent hover:border-current/20"
>
<Plus className="w-4 h-4" />
</button>
)}
<button className="w-8 h-8 hover:bg-white/50 rounded-xl flex items-center justify-center transition-premium border border-transparent hover:border-current/20">
<MoreVertical className="w-4 h-4" />
</button>
</div>
</div>
{/* Droppable Area */}
<Droppable droppableId={status}>
{(provided: DroppableProvided, snapshot: DroppableStateSnapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className={`flex-1 min-h-[500px] rounded-b-2xl p-4 transition-premium ${snapshot.isDraggingOver
? 'bg-primary/5 border-x-2 border-b-2 border-dashed border-primary/20 shadow-inner'
: 'bg-muted/[0.15] border-x border-b border-border/50'
}`}
>
<div className="space-y-1">
{children}
</div>
{provided.placeholder}
</div>
)}
</Droppable>
</div>
);
}

View File

@@ -60,6 +60,10 @@ export interface User {
user_role?: string; // MVP uses both sometimes
company_name?: string;
name: string;
preferred_vendor_id?: string;
preferredVendorId?: string;
preferred_vendor_name?: string;
preferredVendorName?: string;
}
export interface Event {
@@ -68,4 +72,17 @@ export interface Event {
business_name?: string;
created_by?: string;
assigned_staff?: { staff_id: string }[];
// Fields used in EventFormWizard
event_name?: string;
vendor_id?: string;
vendor_name?: string;
hub?: string;
date?: string;
total?: number;
shifts?: any[];
order_type?: string;
is_rapid?: boolean;
is_recurring?: boolean;
status?: string;
}

View File

@@ -0,0 +1,15 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import Backend from "i18next-http-backend";
i18n
.use(LanguageDetector) // detects browser language
.use(initReactI18next)// passes i18n down to react-i18next
.use(Backend) // loads translations asynchoronusly from /locales/{{lng}}/{{ns}}.json
.init({
fallbackLng: "en", // fallback language
debug: true,
});
export default i18n;

View File

@@ -0,0 +1,5 @@
{
"welcome": "Welcome",
"login": "Login",
"logout": "Logout"
}

View File

@@ -0,0 +1,5 @@
{
"welcome": "Bienvenido",
"login": "Iniciar sesión",
"logout": "Cerrar sesión"
}

View File

@@ -2,9 +2,13 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import "@/i18n"
import { Suspense } from 'react'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<Suspense fallback={<div>Loading...</div>}>
<App />
</Suspense>
</StrictMode>,
)

View File

@@ -20,6 +20,13 @@ import OrderList from './features/operations/orders/OrderList';
import OrderDetail from './features/operations/orders/OrderDetail';
import ClientOrderList from './features/operations/orders/ClientOrderList';
import VendorOrderList from './features/operations/orders/VendorOrderList';
import EditOrder from './features/operations/orders/EditOrder';
import Schedule from './features/operations/schedule/Schedule';
import StaffAvailability from './features/operations/availability/StaffAvailability';
import TaskBoard from './features/operations/tasks/TaskBoard';
import InvoiceList from './features/finance/invoices/InvoiceList';
import InvoiceDetail from './features/finance/invoices/InvoiceDetail';
import InvoiceEditor from './features/finance/invoices/InvoiceEditor';
/**
* AppRoutes Component
@@ -94,12 +101,26 @@ const AppRoutes: React.FC = () => {
<Route path="/clients/:id/edit" element={<EditClient />} />
<Route path="/clients/add" element={<AddClient />} />
<Route path="/rates" element={<ServiceRates />} />
{/* Operations Routes */}
<Route path="/orders" element={<OrderList />} />
<Route path="/orders/client" element={<ClientOrderList />} />
<Route path="/orders/:id" element={<OrderDetail />} />
<Route path="/orders/vendor" element={<VendorOrderList />} />
<Route path="/orders/:id/edit" element={<EditOrder />} />
<Route path="/schedule" element={<Schedule />} />
<Route path='/availability' element={<StaffAvailability />} />
<Route path="/tasks" element={<TaskBoard />} />
{/* Finance Routes */}
<Route path="/invoices" element={<InvoiceList />} />
<Route path="/invoices/:id" element={<InvoiceDetail />} />
<Route path="/invoices/:id/edit" element={<InvoiceEditor />} />
<Route path="/invoices/new" element={<InvoiceEditor />} />
</Route>
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>