Merge pull request #387 from Oloodi/authentication-web
Invoices and Staff View related PR
This commit is contained in:
@@ -13,7 +13,10 @@
|
|||||||
"@dataconnect/generated": "link:src/dataconnect-generated",
|
"@dataconnect/generated": "link:src/dataconnect-generated",
|
||||||
"@firebase/analytics": "^0.10.19",
|
"@firebase/analytics": "^0.10.19",
|
||||||
"@firebase/data-connect": "^0.3.12",
|
"@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-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
@@ -32,12 +35,16 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"firebase": "^12.8.0",
|
"firebase": "^12.8.0",
|
||||||
"framer-motion": "^12.29.2",
|
"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",
|
"lucide-react": "^0.563.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-datepicker": "^9.1.0",
|
"react-datepicker": "^9.1.0",
|
||||||
"react-day-picker": "^9.13.0",
|
"react-day-picker": "^9.13.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hook-form": "^7.71.1",
|
"react-hook-form": "^7.71.1",
|
||||||
|
"react-i18next": "^16.5.4",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
"react-router-dom": "^7.13.0",
|
"react-router-dom": "^7.13.0",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
|
|||||||
208
apps/web/pnpm-lock.yaml
generated
208
apps/web/pnpm-lock.yaml
generated
@@ -21,9 +21,18 @@ importers:
|
|||||||
'@firebase/data-connect':
|
'@firebase/data-connect':
|
||||||
specifier: ^0.3.12
|
specifier: ^0.3.12
|
||||||
version: 0.3.12(@firebase/app@0.14.7)
|
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':
|
'@radix-ui/react-dialog':
|
||||||
specifier: ^1.1.15
|
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)
|
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':
|
'@radix-ui/react-label':
|
||||||
specifier: ^2.1.7
|
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)
|
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:
|
framer-motion:
|
||||||
specifier: ^12.29.2
|
specifier: ^12.29.2
|
||||||
version: 12.29.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
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:
|
lucide-react:
|
||||||
specifier: ^0.563.0
|
specifier: ^0.563.0
|
||||||
version: 0.563.0(react@19.2.4)
|
version: 0.563.0(react@19.2.4)
|
||||||
@@ -96,6 +114,9 @@ importers:
|
|||||||
react-hook-form:
|
react-hook-form:
|
||||||
specifier: ^7.71.1
|
specifier: ^7.71.1
|
||||||
version: 7.71.1(react@19.2.4)
|
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:
|
react-redux:
|
||||||
specifier: ^9.2.0
|
specifier: ^9.2.0
|
||||||
version: 9.2.0(@types/react@19.2.10)(react@19.2.4)(redux@5.0.1)
|
version: 9.2.0(@types/react@19.2.10)(react@19.2.4)(redux@5.0.1)
|
||||||
@@ -234,6 +255,10 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@babel/core': ^7.0.0-0
|
'@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':
|
'@babel/template@7.28.6':
|
||||||
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
|
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -683,6 +708,12 @@ packages:
|
|||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
hasBin: true
|
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':
|
'@humanfs/core@0.19.1':
|
||||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||||
engines: {node: '>=18.18.0'}
|
engines: {node: '>=18.18.0'}
|
||||||
@@ -832,6 +863,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
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':
|
'@radix-ui/react-checkbox@1.3.3':
|
||||||
resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==}
|
resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -902,6 +946,15 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
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':
|
'@radix-ui/react-dialog@1.1.15':
|
||||||
resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==}
|
resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1961,10 +2014,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
cross-fetch@4.0.0:
|
||||||
|
resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
css-box-model@1.2.1:
|
||||||
|
resolution: {integrity: sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==}
|
||||||
|
|
||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
@@ -2285,9 +2344,26 @@ packages:
|
|||||||
hermes-parser@0.25.1:
|
hermes-parser@0.25.1:
|
||||||
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
|
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
|
||||||
|
|
||||||
|
html-parse-stringify@3.0.1:
|
||||||
|
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
|
||||||
|
|
||||||
http-parser-js@0.5.10:
|
http-parser-js@0.5.10:
|
||||||
resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==}
|
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:
|
idb@7.1.1:
|
||||||
resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==}
|
resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==}
|
||||||
|
|
||||||
@@ -2499,6 +2575,15 @@ packages:
|
|||||||
natural-compare@1.4.0:
|
natural-compare@1.4.0:
|
||||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
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:
|
node-releases@2.0.27:
|
||||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||||
|
|
||||||
@@ -2568,6 +2653,9 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
raf-schd@4.0.3:
|
||||||
|
resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==}
|
||||||
|
|
||||||
react-datepicker@9.1.0:
|
react-datepicker@9.1.0:
|
||||||
resolution: {integrity: sha512-lOp+m5bc+ttgtB5MHEjwiVu4nlp4CvJLS/PG1OiOe5pmg9kV73pEqO8H0Geqvg2E8gjqTaL9eRhSe+ZpeKP3nA==}
|
resolution: {integrity: sha512-lOp+m5bc+ttgtB5MHEjwiVu4nlp4CvJLS/PG1OiOe5pmg9kV73pEqO8H0Geqvg2E8gjqTaL9eRhSe+ZpeKP3nA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2595,6 +2683,22 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17 || ^18 || ^19
|
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:
|
react-is@19.2.4:
|
||||||
resolution: {integrity: sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==}
|
resolution: {integrity: sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==}
|
||||||
|
|
||||||
@@ -2768,6 +2872,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
tr46@0.0.3:
|
||||||
|
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
||||||
|
|
||||||
ts-api-utils@2.4.0:
|
ts-api-utils@2.4.0:
|
||||||
resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==}
|
resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==}
|
||||||
engines: {node: '>=18.12'}
|
engines: {node: '>=18.12'}
|
||||||
@@ -2877,9 +2984,16 @@ packages:
|
|||||||
yaml:
|
yaml:
|
||||||
optional: true
|
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:
|
web-vitals@4.2.4:
|
||||||
resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==}
|
resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==}
|
||||||
|
|
||||||
|
webidl-conversions@3.0.1:
|
||||||
|
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||||
|
|
||||||
websocket-driver@0.7.4:
|
websocket-driver@0.7.4:
|
||||||
resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==}
|
resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==}
|
||||||
engines: {node: '>=0.8.0'}
|
engines: {node: '>=0.8.0'}
|
||||||
@@ -2888,6 +3002,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==}
|
resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==}
|
||||||
engines: {node: '>=0.8.0'}
|
engines: {node: '>=0.8.0'}
|
||||||
|
|
||||||
|
whatwg-url@5.0.0:
|
||||||
|
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
||||||
|
|
||||||
which@2.0.2:
|
which@2.0.2:
|
||||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -3020,6 +3137,8 @@ snapshots:
|
|||||||
'@babel/core': 7.28.6
|
'@babel/core': 7.28.6
|
||||||
'@babel/helper-plugin-utils': 7.28.6
|
'@babel/helper-plugin-utils': 7.28.6
|
||||||
|
|
||||||
|
'@babel/runtime@7.28.6': {}
|
||||||
|
|
||||||
'@babel/template@7.28.6':
|
'@babel/template@7.28.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.28.6
|
'@babel/code-frame': 7.28.6
|
||||||
@@ -3524,6 +3643,18 @@ snapshots:
|
|||||||
protobufjs: 7.5.4
|
protobufjs: 7.5.4
|
||||||
yargs: 17.7.2
|
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/core@0.19.1': {}
|
||||||
|
|
||||||
'@humanfs/node@0.16.7':
|
'@humanfs/node@0.16.7':
|
||||||
@@ -3654,6 +3785,19 @@ snapshots:
|
|||||||
'@types/react': 19.2.10
|
'@types/react': 19.2.10
|
||||||
'@types/react-dom': 19.2.3(@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)':
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.3
|
'@radix-ui/primitive': 1.1.3
|
||||||
@@ -3724,6 +3868,12 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.10
|
'@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)':
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.3
|
'@radix-ui/primitive': 1.1.3
|
||||||
@@ -4814,12 +4964,22 @@ snapshots:
|
|||||||
|
|
||||||
cookie@1.1.1: {}
|
cookie@1.1.1: {}
|
||||||
|
|
||||||
|
cross-fetch@4.0.0:
|
||||||
|
dependencies:
|
||||||
|
node-fetch: 2.7.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- encoding
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
shebang-command: 2.0.0
|
shebang-command: 2.0.0
|
||||||
which: 2.0.2
|
which: 2.0.2
|
||||||
|
|
||||||
|
css-box-model@1.2.1:
|
||||||
|
dependencies:
|
||||||
|
tiny-invariant: 1.3.3
|
||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
d3-array@3.2.4:
|
d3-array@3.2.4:
|
||||||
@@ -5171,8 +5331,28 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
hermes-estree: 0.25.1
|
hermes-estree: 0.25.1
|
||||||
|
|
||||||
|
html-parse-stringify@3.0.1:
|
||||||
|
dependencies:
|
||||||
|
void-elements: 3.1.0
|
||||||
|
|
||||||
http-parser-js@0.5.10: {}
|
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: {}
|
idb@7.1.1: {}
|
||||||
|
|
||||||
ignore@5.3.2: {}
|
ignore@5.3.2: {}
|
||||||
@@ -5328,6 +5508,10 @@ snapshots:
|
|||||||
|
|
||||||
natural-compare@1.4.0: {}
|
natural-compare@1.4.0: {}
|
||||||
|
|
||||||
|
node-fetch@2.7.0:
|
||||||
|
dependencies:
|
||||||
|
whatwg-url: 5.0.0
|
||||||
|
|
||||||
node-releases@2.0.27: {}
|
node-releases@2.0.27: {}
|
||||||
|
|
||||||
optionator@0.9.4:
|
optionator@0.9.4:
|
||||||
@@ -5451,6 +5635,8 @@ snapshots:
|
|||||||
'@types/react': 19.2.10
|
'@types/react': 19.2.10
|
||||||
'@types/react-dom': 19.2.3(@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):
|
react-datepicker@9.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/react': 0.27.17(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
'@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:
|
dependencies:
|
||||||
react: 19.2.4
|
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-is@19.2.4: {}
|
||||||
|
|
||||||
react-redux@9.2.0(@types/react@19.2.10)(react@19.2.4)(redux@5.0.1):
|
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)
|
fdir: 6.5.0(picomatch@4.0.3)
|
||||||
picomatch: 4.0.3
|
picomatch: 4.0.3
|
||||||
|
|
||||||
|
tr46@0.0.3: {}
|
||||||
|
|
||||||
ts-api-utils@2.4.0(typescript@5.9.3):
|
ts-api-utils@2.4.0(typescript@5.9.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
@@ -5734,8 +5933,12 @@ snapshots:
|
|||||||
jiti: 2.6.1
|
jiti: 2.6.1
|
||||||
lightningcss: 1.30.2
|
lightningcss: 1.30.2
|
||||||
|
|
||||||
|
void-elements@3.1.0: {}
|
||||||
|
|
||||||
web-vitals@4.2.4: {}
|
web-vitals@4.2.4: {}
|
||||||
|
|
||||||
|
webidl-conversions@3.0.1: {}
|
||||||
|
|
||||||
websocket-driver@0.7.4:
|
websocket-driver@0.7.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
http-parser-js: 0.5.10
|
http-parser-js: 0.5.10
|
||||||
@@ -5744,6 +5947,11 @@ snapshots:
|
|||||||
|
|
||||||
websocket-extensions@0.1.4: {}
|
websocket-extensions@0.1.4: {}
|
||||||
|
|
||||||
|
whatwg-url@5.0.0:
|
||||||
|
dependencies:
|
||||||
|
tr46: 0.0.3
|
||||||
|
webidl-conversions: 3.0.1
|
||||||
|
|
||||||
which@2.0.2:
|
which@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
isexe: 2.0.0
|
isexe: 2.0.0
|
||||||
|
|||||||
48
apps/web/src/common/components/ui/avatar.tsx
Normal file
48
apps/web/src/common/components/ui/avatar.tsx
Normal 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 }
|
||||||
198
apps/web/src/common/components/ui/dropdown-menu.tsx
Normal file
198
apps/web/src/common/components/ui/dropdown-menu.tsx
Normal 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,
|
||||||
|
}
|
||||||
281
apps/web/src/features/finance/invoices/InvoiceDetail.tsx
Normal file
281
apps/web/src/features/finance/invoices/InvoiceDetail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1318
apps/web/src/features/finance/invoices/InvoiceEditor.tsx
Normal file
1318
apps/web/src/features/finance/invoices/InvoiceEditor.tsx
Normal file
File diff suppressed because it is too large
Load Diff
291
apps/web/src/features/finance/invoices/InvoiceList.tsx
Normal file
291
apps/web/src/features/finance/invoices/InvoiceList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
231
apps/web/src/features/operations/orders/EditOrder.tsx
Normal file
231
apps/web/src/features/operations/orders/EditOrder.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
import React, { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { useSelector } from "react-redux";
|
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 { Card, CardContent, CardHeader, CardTitle } from "@/common/components/ui/card";
|
||||||
import { Button } from "@/common/components/ui/button";
|
import { Button } from "@/common/components/ui/button";
|
||||||
import { Badge } from "@/common/components/ui/badge";
|
import { Badge } from "@/common/components/ui/badge";
|
||||||
import DashboardLayout from "@/features/layouts/DashboardLayout";
|
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 { OrderStatus } from "@/dataconnect-generated";
|
||||||
import { dataConnect } from "@/features/auth/firebase";
|
import { dataConnect } from "@/features/auth/firebase";
|
||||||
import { useToast } from "@/common/components/ui/use-toast";
|
import { useToast } from "@/common/components/ui/use-toast";
|
||||||
|
import AssignStaffModal from "./components/AssignStaffModal";
|
||||||
import type { RootState } from "@/store/store";
|
import type { RootState } from "@/store/store";
|
||||||
|
|
||||||
const safeFormatDate = (value?: string | null): string => {
|
const safeFormatDate = (value?: string | null): string => {
|
||||||
@@ -85,6 +86,8 @@ export default function OrderDetail() {
|
|||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { user } = useSelector((state: RootState) => state.auth);
|
const { user } = useSelector((state: RootState) => state.auth);
|
||||||
|
const [selectedShift, setSelectedShift] = useState<any>(null);
|
||||||
|
const [isAssignModalOpen, setIsAssignModalOpen] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
@@ -99,6 +102,22 @@ export default function OrderDetail() {
|
|||||||
|
|
||||||
const order = data?.order;
|
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, {
|
const cancelMutation = useUpdateOrder(dataConnect, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
@@ -131,8 +150,7 @@ export default function OrderDetail() {
|
|||||||
|
|
||||||
const handleEdit = () => {
|
const handleEdit = () => {
|
||||||
if (!order || !id) return;
|
if (!order || !id) return;
|
||||||
// Placeholder: route can later be wired to an edit form
|
navigate(`/orders/${id}/edit`);
|
||||||
navigate(`/orders/create?edit=${id}`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDuplicate = () => {
|
const handleDuplicate = () => {
|
||||||
@@ -141,7 +159,12 @@ export default function OrderDetail() {
|
|||||||
navigate(`/orders/create?duplicate=${id}`);
|
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 totalRequested = order?.requested ?? 0;
|
||||||
const totalAssigned = Array.isArray(order?.assignedStaff) ? order!.assignedStaff.length : 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="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-secondary-text font-medium uppercase tracking-wider">
|
<p className="text-xs text-secondary-text font-medium uppercase tracking-wider">
|
||||||
@@ -353,6 +376,21 @@ export default function OrderDetail() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="font-semibold">{vacancies}</span>
|
<span className="font-semibold">{vacancies}</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -432,20 +470,20 @@ export default function OrderDetail() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedShift && (
|
||||||
|
<AssignStaffModal
|
||||||
|
isOpen={isAssignModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsAssignModalOpen(false);
|
||||||
|
setSelectedShift(null);
|
||||||
|
}}
|
||||||
|
shift={selectedShift}
|
||||||
|
onSuccess={() => {
|
||||||
|
refetchShifts();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</DashboardLayout>
|
</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>
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
487
apps/web/src/features/operations/schedule/Schedule.tsx
Normal file
487
apps/web/src/features/operations/schedule/Schedule.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
277
apps/web/src/features/operations/tasks/TaskBoard.tsx
Normal file
277
apps/web/src/features/operations/tasks/TaskBoard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
apps/web/src/features/operations/tasks/TaskCard.tsx
Normal file
120
apps/web/src/features/operations/tasks/TaskCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
apps/web/src/features/operations/tasks/TaskColumn.tsx
Normal file
76
apps/web/src/features/operations/tasks/TaskColumn.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -60,6 +60,10 @@ export interface User {
|
|||||||
user_role?: string; // MVP uses both sometimes
|
user_role?: string; // MVP uses both sometimes
|
||||||
company_name?: string;
|
company_name?: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
preferred_vendor_id?: string;
|
||||||
|
preferredVendorId?: string;
|
||||||
|
preferred_vendor_name?: string;
|
||||||
|
preferredVendorName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Event {
|
export interface Event {
|
||||||
@@ -68,4 +72,17 @@ export interface Event {
|
|||||||
business_name?: string;
|
business_name?: string;
|
||||||
created_by?: string;
|
created_by?: string;
|
||||||
assigned_staff?: { staff_id: 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;
|
||||||
}
|
}
|
||||||
|
|||||||
15
apps/web/src/i18n/index.ts
Normal file
15
apps/web/src/i18n/index.ts
Normal 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;
|
||||||
5
apps/web/src/i18n/locales/en/translation.json
Normal file
5
apps/web/src/i18n/locales/en/translation.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"welcome": "Welcome",
|
||||||
|
"login": "Login",
|
||||||
|
"logout": "Logout"
|
||||||
|
}
|
||||||
5
apps/web/src/i18n/locales/es/translation.json
Normal file
5
apps/web/src/i18n/locales/es/translation.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"welcome": "Bienvenido",
|
||||||
|
"login": "Iniciar sesión",
|
||||||
|
"logout": "Cerrar sesión"
|
||||||
|
}
|
||||||
@@ -2,9 +2,13 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
import "@/i18n"
|
||||||
|
import { Suspense } from 'react'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
|
<App />
|
||||||
|
</Suspense>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ import OrderList from './features/operations/orders/OrderList';
|
|||||||
import OrderDetail from './features/operations/orders/OrderDetail';
|
import OrderDetail from './features/operations/orders/OrderDetail';
|
||||||
import ClientOrderList from './features/operations/orders/ClientOrderList';
|
import ClientOrderList from './features/operations/orders/ClientOrderList';
|
||||||
import VendorOrderList from './features/operations/orders/VendorOrderList';
|
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
|
* AppRoutes Component
|
||||||
@@ -94,11 +101,25 @@ const AppRoutes: React.FC = () => {
|
|||||||
<Route path="/clients/:id/edit" element={<EditClient />} />
|
<Route path="/clients/:id/edit" element={<EditClient />} />
|
||||||
<Route path="/clients/add" element={<AddClient />} />
|
<Route path="/clients/add" element={<AddClient />} />
|
||||||
<Route path="/rates" element={<ServiceRates />} />
|
<Route path="/rates" element={<ServiceRates />} />
|
||||||
|
|
||||||
{/* Operations Routes */}
|
{/* Operations Routes */}
|
||||||
<Route path="/orders" element={<OrderList />} />
|
<Route path="/orders" element={<OrderList />} />
|
||||||
<Route path="/orders/client" element={<ClientOrderList />} />
|
<Route path="/orders/client" element={<ClientOrderList />} />
|
||||||
<Route path="/orders/:id" element={<OrderDetail />} />
|
<Route path="/orders/:id" element={<OrderDetail />} />
|
||||||
<Route path="/orders/vendor" element={<VendorOrderList />} />
|
<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>
|
||||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||||
|
|||||||
Reference in New Issue
Block a user