diff --git a/apps/web/package.json b/apps/web/package.json index 216a1d3b..cfbdab04 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,7 +13,10 @@ "@dataconnect/generated": "link:src/dataconnect-generated", "@firebase/analytics": "^0.10.19", "@firebase/data-connect": "^0.3.12", + "@hello-pangea/dnd": "^18.0.1", + "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-slot": "^1.2.4", @@ -32,12 +35,16 @@ "date-fns": "^4.1.0", "firebase": "^12.8.0", "framer-motion": "^12.29.2", + "i18next": "^25.8.4", + "i18next-browser-languagedetector": "^8.2.0", + "i18next-http-backend": "^3.0.2", "lucide-react": "^0.563.0", "react": "^19.2.0", "react-datepicker": "^9.1.0", "react-day-picker": "^9.13.0", "react-dom": "^19.2.0", "react-hook-form": "^7.71.1", + "react-i18next": "^16.5.4", "react-redux": "^9.2.0", "react-router-dom": "^7.13.0", "recharts": "^3.7.0", diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index 951ab6aa..4b1de6e6 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -21,9 +21,18 @@ importers: '@firebase/data-connect': specifier: ^0.3.12 version: 0.3.12(@firebase/app@0.14.7) + '@hello-pangea/dnd': + specifier: ^18.0.1 + version: 18.0.1(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-avatar': + specifier: ^1.1.11 + version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.16 + version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-label': specifier: ^2.1.7 version: 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -78,6 +87,15 @@ importers: framer-motion: specifier: ^12.29.2 version: 12.29.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + i18next: + specifier: ^25.8.4 + version: 25.8.4(typescript@5.9.3) + i18next-browser-languagedetector: + specifier: ^8.2.0 + version: 8.2.0 + i18next-http-backend: + specifier: ^3.0.2 + version: 3.0.2 lucide-react: specifier: ^0.563.0 version: 0.563.0(react@19.2.4) @@ -96,6 +114,9 @@ importers: react-hook-form: specifier: ^7.71.1 version: 7.71.1(react@19.2.4) + react-i18next: + specifier: ^16.5.4 + version: 16.5.4(i18next@25.8.4(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) react-redux: specifier: ^9.2.0 version: 9.2.0(@types/react@19.2.10)(react@19.2.4)(redux@5.0.1) @@ -234,6 +255,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -683,6 +708,12 @@ packages: engines: {node: '>=6'} hasBin: true + '@hello-pangea/dnd@18.0.1': + resolution: {integrity: sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -832,6 +863,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-avatar@1.1.11': + resolution: {integrity: sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-checkbox@1.3.3': resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} peerDependencies: @@ -902,6 +946,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.1.3': + resolution: {integrity: sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dialog@1.1.15': resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} peerDependencies: @@ -1961,10 +2014,16 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cross-fetch@4.0.0: + resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-box-model@1.2.1: + resolution: {integrity: sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -2285,9 +2344,26 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + http-parser-js@0.5.10: resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==} + i18next-browser-languagedetector@8.2.0: + resolution: {integrity: sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==} + + i18next-http-backend@3.0.2: + resolution: {integrity: sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==} + + i18next@25.8.4: + resolution: {integrity: sha512-a9A0MnUjKvzjEN/26ZY1okpra9kA8MEwzYEz1BNm+IyxUKPRH6ihf0p7vj8YvULwZHKHl3zkJ6KOt4hewxBecQ==} + peerDependencies: + typescript: ^5 + peerDependenciesMeta: + typescript: + optional: true + idb@7.1.1: resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} @@ -2499,6 +2575,15 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -2568,6 +2653,9 @@ packages: '@types/react-dom': optional: true + raf-schd@4.0.3: + resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==} + react-datepicker@9.1.0: resolution: {integrity: sha512-lOp+m5bc+ttgtB5MHEjwiVu4nlp4CvJLS/PG1OiOe5pmg9kV73pEqO8H0Geqvg2E8gjqTaL9eRhSe+ZpeKP3nA==} peerDependencies: @@ -2595,6 +2683,22 @@ packages: peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 + react-i18next@16.5.4: + resolution: {integrity: sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g==} + peerDependencies: + i18next: '>= 25.6.2' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + react-is@19.2.4: resolution: {integrity: sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==} @@ -2768,6 +2872,9 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -2877,9 +2984,16 @@ packages: yaml: optional: true + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + web-vitals@4.2.4: resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==} + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + websocket-driver@0.7.4: resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==} engines: {node: '>=0.8.0'} @@ -2888,6 +3002,9 @@ packages: resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==} engines: {node: '>=0.8.0'} + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3020,6 +3137,8 @@ snapshots: '@babel/core': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 + '@babel/runtime@7.28.6': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.28.6 @@ -3524,6 +3643,18 @@ snapshots: protobufjs: 7.5.4 yargs: 17.7.2 + '@hello-pangea/dnd@18.0.1(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.28.6 + css-box-model: 1.2.1 + raf-schd: 4.0.3 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-redux: 9.2.0(@types/react@19.2.10)(react@19.2.4)(redux@5.0.1) + redux: 5.0.1 + transitivePeerDependencies: + - '@types/react' + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -3654,6 +3785,19 @@ snapshots: '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-avatar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-context': 1.1.3(@types/react@19.2.10)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.10)(react@19.2.4) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.10)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.10)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -3724,6 +3868,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.10 + '@radix-ui/react-context@1.1.3(@types/react@19.2.10)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.10 + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -4814,12 +4964,22 @@ snapshots: cookie@1.1.1: {} + cross-fetch@4.0.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 + css-box-model@1.2.1: + dependencies: + tiny-invariant: 1.3.3 + csstype@3.2.3: {} d3-array@3.2.4: @@ -5171,8 +5331,28 @@ snapshots: dependencies: hermes-estree: 0.25.1 + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + http-parser-js@0.5.10: {} + i18next-browser-languagedetector@8.2.0: + dependencies: + '@babel/runtime': 7.28.6 + + i18next-http-backend@3.0.2: + dependencies: + cross-fetch: 4.0.0 + transitivePeerDependencies: + - encoding + + i18next@25.8.4(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.28.6 + optionalDependencies: + typescript: 5.9.3 + idb@7.1.1: {} ignore@5.3.2: {} @@ -5328,6 +5508,10 @@ snapshots: natural-compare@1.4.0: {} + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + node-releases@2.0.27: {} optionator@0.9.4: @@ -5451,6 +5635,8 @@ snapshots: '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) + raf-schd@4.0.3: {} + react-datepicker@9.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@floating-ui/react': 0.27.17(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -5475,6 +5661,17 @@ snapshots: dependencies: react: 19.2.4 + react-i18next@16.5.4(i18next@25.8.4(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.28.6 + html-parse-stringify: 3.0.1 + i18next: 25.8.4(typescript@5.9.3) + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + react-dom: 19.2.4(react@19.2.4) + typescript: 5.9.3 + react-is@19.2.4: {} react-redux@9.2.0(@types/react@19.2.10)(react@19.2.4)(redux@5.0.1): @@ -5647,6 +5844,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tr46@0.0.3: {} + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -5734,8 +5933,12 @@ snapshots: jiti: 2.6.1 lightningcss: 1.30.2 + void-elements@3.1.0: {} + web-vitals@4.2.4: {} + webidl-conversions@3.0.1: {} + websocket-driver@0.7.4: dependencies: http-parser-js: 0.5.10 @@ -5744,6 +5947,11 @@ snapshots: websocket-extensions@0.1.4: {} + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which@2.0.2: dependencies: isexe: 2.0.0 diff --git a/apps/web/src/common/components/ui/avatar.tsx b/apps/web/src/common/components/ui/avatar.tsx new file mode 100644 index 00000000..7c0eb5ff --- /dev/null +++ b/apps/web/src/common/components/ui/avatar.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/apps/web/src/common/components/ui/dropdown-menu.tsx b/apps/web/src/common/components/ui/dropdown-menu.tsx new file mode 100644 index 00000000..a97a3995 --- /dev/null +++ b/apps/web/src/common/components/ui/dropdown-menu.tsx @@ -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, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = + DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +function DropdownMenuShortcut({ + className, + ...props +}: React.HTMLAttributes) { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroups as DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/apps/web/src/features/finance/invoices/InvoiceDetail.tsx b/apps/web/src/features/finance/invoices/InvoiceDetail.tsx new file mode 100644 index 00000000..6c8c5313 --- /dev/null +++ b/apps/web/src/features/finance/invoices/InvoiceDetail.tsx @@ -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 = { + 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 ( + +
+
+
+
+ ); + } + + if (!invoice) { + return ( + +
+

Invoice not found

+ +
+
+ ); + } + + 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 ( + navigate("/invoices")} leadingIcon={}> + Back + + } + actions={ +
+ + {invoice.status === "DRAFT" && ( + + )} + {invoice.status !== "PAID" && ( + + )} +
+ } + > +
+
+ {/* Header & Client Info */} + + +
+
+
+

Client Information

+
+
+ +
+
+

{invoice.business?.businessName}

+

{invoice.business?.email}

+

{invoice.business?.phone}

+
+
+
+
+ + {invoice.hub || (invoice.order as any)?.teamHub?.hubName} +
+
+ +
+
+ Status + {status.label} +
+
+ Issued + {issueDate} +
+
+ Due Date + {dueDate} +
+
+
+
+
+ + {/* Line Items Table */} + +
+

Line Items (Shifts & Staff)

+
+ + + + Staff / Role + Hours + Rate + Total + + + + {lineItems.length === 0 ? ( + + + No line items recorded for this invoice. + + + ) : ( + lineItems.map((item: any, idx: number) => ( + + +

{item.staffName || "Staff Member"}

+

{item.roleName || "Support"}

+
+ {item.hours || 0}h + ${(item.rate || 0).toFixed(2)} + ${(item.total || 0).toFixed(2)} +
+ )) + )} +
+
+
+ + {/* Payment History */} + +
+

Payment History

+
+
+ {payments.length === 0 ? ( +
+ No payment records found. +
+ ) : ( + + + {payments.map((payment) => ( + + +
+
+ +
+
+

Payment Received

+

+ {payment.createdAt ? format(parseISO(payment.createdAt as string), "MMM d, yyyy") : "—"} +

+
+
+
+ + + {payment.status} + + +
+ ))} +
+
+ )} +
+
+
+ + {/* Financial Summary Sidebar */} +
+ + +

Financial Summary

+
+
+ Subtotal + ${(invoice.subtotal || 0).toLocaleString(undefined, { minimumFractionDigits: 2 })} +
+
+ Fees + ${(invoice.otherCharges || 0).toLocaleString(undefined, { minimumFractionDigits: 2 })} +
+
+
+

Grand Total

+

+ ${invoice.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })} +

+
+
+
+
+
+ + + +

Internal Notes

+

+ {invoice.notes || "No internal notes provided for this invoice."} +

+
+
+ +
+ +

+ For any questions regarding this invoice, please contact support@krowworkforce.com +

+
+
+
+
+ ); +} diff --git a/apps/web/src/features/finance/invoices/InvoiceEditor.tsx b/apps/web/src/features/finance/invoices/InvoiceEditor.tsx new file mode 100644 index 00000000..e4d48597 --- /dev/null +++ b/apps/web/src/features/finance/invoices/InvoiceEditor.tsx @@ -0,0 +1,1318 @@ +import { Badge } from "@/common/components/ui/badge"; +import { Button } from "@/common/components/ui/button"; +import { Card } from "@/common/components/ui/card"; +import { Input } from "@/common/components/ui/input"; +import { Label } from "@/common/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/common/components/ui/popover"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/common/components/ui/select"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/common/components/ui/table"; +import { Textarea } from "@/common/components/ui/textarea"; +import { useToast } from "@/common/components/ui/use-toast"; +import { InvoiceStatus, InovicePaymentTerms } from "@/dataconnect-generated"; +import { + useCreateInvoice, + useCreateInvoiceTemplate, + useDeleteInvoiceTemplate, + useGetInvoiceById, + useListBusinesses, + useListInvoices, + useListInvoiceTemplates, + useListOrders, + useListStaff, + useListVendorRates, + useUpdateInvoice +} from "@/dataconnect-generated/react"; +import { dataConnect } from "@/features/auth/firebase"; +import DashboardLayout from "@/features/layouts/DashboardLayout"; +import { addDays, format, parseISO } from "date-fns"; +import { ArrowLeft, Building2, Calendar, Clock, FileText, Mail, Phone, Plus, Trash2, User } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useNavigate, useParams, useSearchParams } from "react-router-dom"; + +// Helper to convert 24h to 12h format +const convertTo12Hour = (time24?: string) => { + if (!time24) return "09:00 AM"; + const [hours, minutes] = time24.split(':'); + const hour = parseInt(hours); + const ampm = hour >= 12 ? 'PM' : 'AM'; + const hour12 = hour % 12 || 12; + return `${String(hour12).padStart(2, '0')}:${minutes} ${ampm}`; +}; + +export default function InvoiceEditor() { + const navigate = useNavigate(); + const { id: pathId } = useParams<{ id: string }>(); + const { toast } = useToast(); + const [searchParams] = useSearchParams(); + + const invoiceId = searchParams.get('id'); + const disputedIndices = searchParams.get('disputed')?.split(',').map(Number).filter(n => !isNaN(n)) || []; + + const effectiveInvoiceId = invoiceId || (pathId !== 'new' ? pathId : null); + const isEdit = !!effectiveInvoiceId; + + // Data Connect Queries + const { data: invoicesData } = useListInvoices(dataConnect); + const invoices = invoicesData?.invoices || []; + + const { data: eventsData } = useListOrders(dataConnect); + const events = eventsData?.orders || []; + + const { data: businessesData } = useListBusinesses(dataConnect); + const businesses = businessesData?.businesses || []; + + const { data: vendorRatesData } = useListVendorRates(dataConnect); + const vendorRates = vendorRatesData?.vendorRates || []; + + const { data: staffData } = useListStaff(dataConnect); + const staffDirectory = staffData?.staffs || []; + + const { data: templatesData, refetch: refetchTemplates } = useListInvoiceTemplates(dataConnect); + const templates = templatesData?.invoiceTemplates || []; + + const { data: currentInvoiceData } = useGetInvoiceById(dataConnect, { id: effectiveInvoiceId || "" }, { enabled: isEdit && !!effectiveInvoiceId }); + const existingInvoice = currentInvoiceData?.invoice; + + const [selectedClientId, setSelectedClientId] = useState(""); + + useEffect(() => { + if (existingInvoice) { + setSelectedClientId(existingInvoice.businessId); + } + }, [existingInvoice]); + + // Generate sequential invoice number based on client prefix + const generateInvoiceNumber = (clientName: string, existingInvoices: any[]) => { + const extractCompanyName = (name: string) => { + if (!name) return ''; + return name.split(/\s*[-–]\s*/)[0].trim(); + }; + + const generatePrefix = (name: string) => { + if (!name) return 'INV'; + const companyName = extractCompanyName(name); + const words = companyName.trim().split(/\s+/).filter(w => w.length > 0); + if (words.length === 0) return 'INV'; + if (words.length === 1) { + return words[0].substring(0, 4).toUpperCase(); + } else { + return words.slice(0, 4).map(w => w[0]).join('').toUpperCase(); + } + }; + + const prefix = generatePrefix(clientName); + + const existingNumbers = (existingInvoices || []) + .filter(inv => inv.invoiceNumber?.startsWith(prefix + '-')) + .map(inv => { + const match = inv.invoiceNumber.match(new RegExp(`^${prefix}-(\\d+)$`)); + return match ? parseInt(match[1]) : 0; + }) + .filter(n => !isNaN(n)); + + const nextNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1001; + return `${prefix}-${nextNumber}`; + }; + + const [formData, setFormData] = useState({ + invoice_number: "", + event_id: "", + event_name: "", + invoice_date: format(new Date(), 'yyyy-MM-dd'), + due_date: format(addDays(new Date(), 45), 'yyyy-MM-dd'), + payment_terms: "NET_45", + hub: "", + manager: "", + vendor_id: "", + department: "", + po_reference: "", + from_company: { + name: "KROW Workforce", + address: "848 E Gish Rd Ste 1, San Jose, CA 95112", + phone: "(408) 936-0180", + email: "billing@krowworkforce.com" + }, + to_company: { + name: "", + phone: "", + email: "", + address: "", + manager_name: "", + hub_name: "", + vendor_id: "" + }, + staff_entries: [], + charges: [], + other_charges: 0, + notes: "", + }); + + useEffect(() => { + if (existingInvoice) { + setFormData({ + invoice_number: existingInvoice.invoiceNumber, + event_id: existingInvoice.orderId || "", + event_name: existingInvoice.order?.eventName || "", + invoice_date: existingInvoice.issueDate ? format(parseISO(existingInvoice.issueDate as string), 'yyyy-MM-dd') : format(new Date(), 'yyyy-MM-dd'), + due_date: existingInvoice.dueDate ? format(parseISO(existingInvoice.dueDate as string), 'yyyy-MM-dd') : format(addDays(new Date(), 45), 'yyyy-MM-dd'), + payment_terms: existingInvoice.paymentTerms || "NET_45", + hub: existingInvoice.hub || "", + manager: existingInvoice.managerName || "", + vendor_id: existingInvoice.vendorNumber || "", + department: existingInvoice.order?.deparment || "", + po_reference: existingInvoice.order?.poReference || "", + from_company: { + name: existingInvoice.vendor?.companyName || "KROW Workforce", + address: existingInvoice.vendor?.address || "848 E Gish Rd Ste 1, San Jose, CA 95112", + phone: existingInvoice.vendor?.phone || "(408) 936-0180", + email: existingInvoice.vendor?.email || "billing@krowworkforce.com" + }, + to_company: { + name: existingInvoice.business?.businessName || "", + phone: existingInvoice.business?.phone || "", + email: existingInvoice.business?.email || "", + address: existingInvoice.business?.address || "", + manager_name: existingInvoice.business?.contactName || "", + hub_name: existingInvoice.hub || "", + vendor_id: existingInvoice.vendorNumber || "" + }, + staff_entries: existingInvoice.roles || [], + charges: existingInvoice.charges || [], + other_charges: existingInvoice.otherCharges || 0, + notes: existingInvoice.notes || "", + }); + } else if (invoices.length > 0 && !formData.invoice_number) { + setFormData((prev: any) => ({ ...prev, invoice_number: generateInvoiceNumber('', invoices) })); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [existingInvoice, invoices]); + + const [timePickerOpen, setTimePickerOpen] = useState(null); + const [selectedTime, setSelectedTime] = useState({ hours: "09", minutes: "00", period: "AM" }); + + const getRateForPosition = (position: string) => { + const selectedBusiness = businesses.find(b => b.id === selectedClientId); + if (!selectedBusiness || !position) return 0; + + const businessName = selectedBusiness.businessName || ""; + const extractCompanyName = (name: string) => { + if (!name) return ''; + return name.split(/\s*[-–]\s*/)[0].trim(); + }; + const mainCompanyName = extractCompanyName(businessName); + + // Logic similar to MVP + const clientSpecificRate = vendorRates.find(rate => + rate.roleName?.toLowerCase() === position.toLowerCase() && + rate.client_name === businessName // This field might be different in Data Connect, checking listVendorRates output + ); + + if (clientSpecificRate) return clientSpecificRate.clientRate || 0; + + const defaultRate = vendorRates.find(rate => + rate.roleName?.toLowerCase() === position.toLowerCase() + ); + + return defaultRate?.clientRate || 0; + }; + + const handleClientSelect = (clientId: string) => { + setSelectedClientId(clientId); + const selectedBusiness = businesses.find(b => b.id === clientId); + + if (selectedBusiness) { + const hubName = selectedBusiness.hubBuilding || ""; + const newInvoiceNumber = generateInvoiceNumber(selectedBusiness.businessName, invoices); + + setFormData((prev: any) => ({ + ...prev, + invoice_number: isEdit ? prev.invoice_number : newInvoiceNumber, + to_company: { + name: selectedBusiness.businessName || "", + phone: selectedBusiness.phone || "", + email: selectedBusiness.email || "", + address: selectedBusiness.address || "", + manager_name: selectedBusiness.contactName || "", + hub_name: hubName, + vendor_id: "" // erp_vendor_id was not in business query, assuming empty for now + }, + hub: hubName, + manager: selectedBusiness.contactName || "", + department: "", + po_reference: "" + })); + } + }; + + const handleImportFromEvent = (event: any) => { + const staffEntries = (event.assignedStaff || []).map((staff: any) => { + const shift = (event.shifts as any[])?.[0]; + const role = shift?.roles?.find((r: any) => r.role === staff.role) || shift?.roles?.[0]; + + return { + name: staff.fullName || staff.staff_name || "", + date: event.date ? format(parseISO(event.date as string), 'MM/dd/yyyy') : format(new Date(), 'MM/dd/yyyy'), + position: staff.role || role?.role || "", + check_in: role?.start_time ? convertTo12Hour(role.start_time) : "09:00 AM", + check_out: role?.end_time ? convertTo12Hour(role.end_time) : "05:00 PM", + lunch: role?.break_minutes || 30, + worked_hours: role?.hours || 8, + regular_hours: Math.min(role?.hours || 8, 8), + ot_hours: Math.max(0, (role?.hours || 8) - 8), + dt_hours: 0, + rate: role?.cost_per_hour || getRateForPosition(staff.role || '') || 0, + regular_value: 0, + ot_value: 0, + dt_value: 0, + total: 0 + }; + }); + + staffEntries.forEach((entry: any) => { + entry.regular_value = entry.regular_hours * entry.rate; + entry.ot_value = entry.ot_hours * entry.rate * 1.5; + entry.dt_value = entry.dt_hours * entry.rate * 2; + entry.total = entry.regular_value + entry.ot_value + entry.dt_value; + }); + + const business = businesses.find(b => b.id === event.businessId); + + setFormData((prev: any) => ({ + ...prev, + event_id: event.id, + event_name: event.eventName || "", + hub: event.teamHub?.hubName || "", + manager: event.business?.contactName || "", + po_reference: event.poReference || "", + to_company: business ? { + name: business.businessName || "", + phone: business.phone || "", + email: business.email || "", + address: business.address || "", + manager_name: business.contactName || "", + hub_name: event.teamHub?.hubName || "", + vendor_id: "" + } : { + ...prev.to_company, + name: event.business?.businessName || prev.to_company.name, + hub_name: event.teamHub?.hubName || "" + }, + staff_entries: staffEntries.length > 0 ? staffEntries : prev.staff_entries + })); + + if (business) { + setSelectedClientId(business.id); + } + + toast({ + title: "✅ Event Imported", + description: `Imported ${staffEntries.length} staff entries from ${event.eventName}`, + }); + }; + + const handleDuplicateInvoice = (invoice: any) => { + const newStaffEntries = (invoice.roles || []).map((entry: any) => ({ + ...entry, + date: format(new Date(), 'MM/dd/yyyy') + })); + + const newInvoiceNumber = generateInvoiceNumber(invoice.business?.businessName || '', invoices); + + setFormData({ + invoice_number: newInvoiceNumber, + event_id: "", + event_name: invoice.order?.eventName || "", + invoice_date: format(new Date(), 'yyyy-MM-dd'), + due_date: format(addDays(new Date(), 45), 'yyyy-MM-dd'), + payment_terms: invoice.paymentTerms || "NET_45", + hub: invoice.hub || "", + manager: invoice.managerName || "", + vendor_id: invoice.vendorNumber || "", + department: invoice.order?.deparment || "", + po_reference: invoice.order?.poReference || "", + from_company: { + name: invoice.vendor?.companyName || formData.from_company.name, + address: invoice.vendor?.address || formData.from_company.address, + phone: invoice.vendor?.phone || formData.from_company.phone, + email: invoice.vendor?.email || formData.from_company.email, + }, + to_company: { + name: invoice.business?.businessName || "", + phone: invoice.business?.phone || "", + email: invoice.business?.email || "", + address: invoice.business?.address || "", + manager_name: invoice.business?.contactName || "", + hub_name: invoice.hub || "", + vendor_id: invoice.vendorNumber || "" + }, + staff_entries: newStaffEntries, + charges: invoice.charges || [], + other_charges: invoice.otherCharges || 0, + notes: invoice.notes || "", + }); + + if (invoice.businessId) { + setSelectedClientId(invoice.businessId); + } + + toast({ + title: "✅ Invoice Duplicated", + description: `Copied from ${invoice.invoiceNumber} - update dates and details as needed`, + }); + }; + + const { mutate: createTemplate } = useCreateInvoiceTemplate(dataConnect); + const { mutate: deleteTemplate } = useDeleteInvoiceTemplate(dataConnect); + + const handleUseTemplate = (template: any) => { + const newStaffEntries = (template.roles || []).map((entry: any) => ({ + ...entry, + name: "", + date: format(new Date(), 'MM/dd/yyyy'), + worked_hours: 0, + regular_hours: 0, + ot_hours: 0, + dt_hours: 0, + regular_value: 0, + ot_value: 0, + dt_value: 0, + total: 0 + })); + + const newInvoiceNumber = generateInvoiceNumber(template.business?.businessName || '', invoices); + + setFormData((prev: any) => ({ + ...prev, + invoice_number: newInvoiceNumber, + invoice_date: format(new Date(), 'yyyy-MM-dd'), + due_date: format(addDays(new Date(), 45), 'yyyy-MM-dd'), + payment_terms: template.paymentTerms || "NET_45", + hub: template.hub || "", + department: "", + po_reference: template.order?.poReference || "", + from_company: { + name: template.vendor?.companyName || prev.from_company.name, + address: template.vendor?.address || prev.from_company.address, + phone: template.vendor?.phone || prev.from_company.phone, + email: template.vendor?.email || prev.from_company.email, + }, + to_company: { + name: template.business?.businessName || "", + phone: template.business?.phone || "", + email: template.business?.email || "", + address: template.business?.address || "", + manager_name: template.business?.contactName || "", + hub_name: template.hub || "", + vendor_id: template.vendorNumber || "" + }, + staff_entries: newStaffEntries, + charges: template.charges || [], + notes: template.notes || "", + })); + + if (template.businessId) { + setSelectedClientId(template.businessId); + } + + toast({ + title: "✅ Template Applied", + description: `Applied "${template.name}" - fill in staff names and times`, + }); + }; + + const handleSaveTemplate = async (templateName: string) => { + const selectedBusiness = businesses.find(b => b.id === selectedClientId); + + await createTemplate({ + name: templateName, + ownerId: "00000000-0000-0000-0000-000000000000", // placeholder, usually from auth + businessId: selectedClientId || undefined, + vendorId: "00000000-0000-0000-0000-000000000000", // placeholder + paymentTerms: formData.payment_terms as InovicePaymentTerms, + invoiceNumber: formData.invoice_number, + issueDate: new Date(formData.invoice_date).toISOString(), + dueDate: new Date(formData.due_date).toISOString(), + hub: formData.hub, + managerName: formData.manager, + roles: formData.staff_entries, + charges: formData.charges, + otherCharges: parseFloat(formData.other_charges) || 0, + subtotal: totals.subtotal, + amount: totals.grandTotal, + notes: formData.notes, + staffCount: formData.staff_entries.length, + chargesCount: formData.charges.length, + }); + + refetchTemplates(); + + toast({ + title: "✅ Template Saved", + description: `"${templateName}" can now be reused for future invoices`, + }); + }; + + const handleDeleteTemplate = async (templateId: string) => { + await deleteTemplate({ id: templateId }); + refetchTemplates(); + toast({ + title: "Template Deleted", + description: "Template has been removed", + }); + }; + + const parseTimeToMinutes = (timeStr: string) => { + if (!timeStr || timeStr === "hh:mm") return null; + const match = timeStr.match(/(\d{1,2}):(\d{2})\s*(AM|PM)/i); + if (!match) return null; + let hours = parseInt(match[1]); + const minutes = parseInt(match[2]); + const period = match[3].toUpperCase(); + if (period === "PM" && hours !== 12) hours += 12; + if (period === "AM" && hours === 12) hours = 0; + return hours * 60 + minutes; + }; + + const calculateWorkedHours = (checkIn: string, checkOut: string, lunch: string | number) => { + const startMinutes = parseTimeToMinutes(checkIn); + const endMinutes = parseTimeToMinutes(checkOut); + if (startMinutes === null || endMinutes === null) return 0; + let totalMinutes = endMinutes - startMinutes; + if (totalMinutes < 0) totalMinutes += 24 * 60; + + if (lunch !== "NB" && (typeof lunch === 'number' ? lunch : parseInt(lunch)) >= 20) { + totalMinutes -= (typeof lunch === 'number' ? lunch : parseInt(lunch)); + } + return Math.max(0, totalMinutes / 60); + }; + + const calculateBillableHours = (checkIn: string, checkOut: string, lunch: string | number) => { + const startMinutes = parseTimeToMinutes(checkIn); + const endMinutes = parseTimeToMinutes(checkOut); + if (startMinutes === null || endMinutes === null) return { total: 0, nbPenalty: 0 }; + let totalMinutes = endMinutes - startMinutes; + if (totalMinutes < 0) totalMinutes += 24 * 60; + + let nbPenalty = 0; + if (lunch === "NB") { + nbPenalty = 1; + } else if ((typeof lunch === 'number' ? lunch : parseInt(lunch)) >= 20) { + totalMinutes -= (typeof lunch === 'number' ? lunch : parseInt(lunch)); + } + return { total: Math.max(0, totalMinutes / 60), nbPenalty }; + }; + + const handlePositionChange = (index: number, position: string) => { + const rate = getRateForPosition(position); + const newEntries = [...formData.staff_entries]; + newEntries[index] = { + ...newEntries[index], + position: position, + rate: rate || newEntries[index].rate + }; + + const entry = newEntries[index]; + entry.regular_value = (entry.regular_hours || 0) * (entry.rate || 0); + entry.ot_value = (entry.ot_hours || 0) * (entry.rate || 0) * 1.5; + entry.dt_value = (entry.dt_hours || 0) * (entry.rate || 0) * 2; + entry.total = entry.regular_value + entry.ot_value + entry.dt_value; + + setFormData({ ...formData, staff_entries: newEntries }); + }; + + const handleStaffChange = (index: number, field: string, value: any) => { + const newEntries = [...formData.staff_entries]; + newEntries[index] = { ...newEntries[index], [field]: value }; + + const entry = newEntries[index]; + + if (['check_in', 'check_out', 'lunch'].includes(field)) { + const workedHours = calculateWorkedHours(entry.check_in, entry.check_out, entry.lunch); + const { total: billableHours, nbPenalty } = calculateBillableHours(entry.check_in, entry.check_out, entry.lunch); + entry.worked_hours = parseFloat(workedHours.toFixed(2)); + + if (billableHours <= 8) { + entry.regular_hours = parseFloat(billableHours.toFixed(2)); + entry.ot_hours = 0; + entry.dt_hours = 0; + } else if (billableHours <= 12) { + entry.regular_hours = 8; + entry.ot_hours = parseFloat((billableHours - 8).toFixed(2)); + entry.dt_hours = 0; + } else { + entry.regular_hours = 8; + entry.ot_hours = 4; + entry.dt_hours = parseFloat((billableHours - 12).toFixed(2)); + } + + if (nbPenalty > 0) { + entry.regular_hours = parseFloat((entry.regular_hours + nbPenalty).toFixed(2)); + } + } + + if (['check_in', 'check_out', 'lunch', 'worked_hours', 'regular_hours', 'ot_hours', 'dt_hours', 'rate'].includes(field)) { + entry.regular_value = (entry.regular_hours || 0) * (entry.rate || 0); + entry.ot_value = (entry.ot_hours || 0) * (entry.rate || 0) * 1.5; + entry.dt_value = (entry.dt_hours || 0) * (entry.rate || 0) * 2; + entry.total = entry.regular_value + entry.ot_value + entry.dt_value; + } + + setFormData({ ...formData, staff_entries: newEntries }); + }; + + const handleChargeChange = (index: number, field: string, value: any) => { + const newCharges = [...formData.charges]; + newCharges[index] = { ...newCharges[index], [field]: value }; + + if (['qty', 'rate'].includes(field)) { + newCharges[index].price = (newCharges[index].qty || 0) * (newCharges[index].rate || 0); + } + + setFormData({ ...formData, charges: newCharges }); + }; + + const handleRemoveStaff = (index: number) => { + setFormData({ + ...formData, + staff_entries: formData.staff_entries.filter((_: any, i: number) => i !== index) + }); + }; + + const handleRemoveCharge = (index: number) => { + setFormData({ + ...formData, + charges: formData.charges.filter((_: any, i: number) => i !== index) + }); + }; + + const handleTimeSelect = (entryIndex: number, field: string) => { + const timeString = `${selectedTime.hours}:${selectedTime.minutes} ${selectedTime.period}`; + handleStaffChange(entryIndex, field, timeString); + setTimePickerOpen(null); + }; + + const handleAddStaffEntry = () => { + setFormData({ + ...formData, + staff_entries: [ + ...formData.staff_entries, + { + name: "", + date: format(new Date(), 'MM/dd/yyyy'), + position: "", + check_in: "hh:mm", + lunch: 0, + check_out: "", + worked_hours: 0, + regular_hours: 0, + ot_hours: 0, + dt_hours: 0, + rate: 0, + regular_value: 0, + ot_value: 0, + dt_value: 0, + total: 0 + } + ] + }); + }; + + const handleAddCharge = () => { + setFormData({ + ...formData, + charges: [ + ...formData.charges, + { + name: "Gas Compensation", + qty: 1, + rate: 0, + price: 0 + } + ] + }); + }; + + const calculateTotals = () => { + const staffTotal = formData.staff_entries.reduce((sum: number, entry: any) => sum + (entry.total || 0), 0); + const chargesTotal = formData.charges.reduce((sum: number, charge: any) => sum + (charge.price || 0), 0); + const subtotal = staffTotal + chargesTotal; + const otherCharges = parseFloat(formData.other_charges) || 0; + const grandTotal = subtotal + otherCharges; + + return { subtotal, otherCharges, grandTotal }; + }; + + const totals = calculateTotals(); + + const { mutateAsync: createInvoice, isPending: creating } = useCreateInvoice(dataConnect); + const { mutateAsync: updateInvoice, isPending: updating } = useUpdateInvoice(dataConnect); + + const handleSave = async (statusOverride?: string) => { + const data = formData; + const staffTotal = data.staff_entries.reduce((sum: number, entry: any) => sum + (entry.total || 0), 0); + const chargesTotal = data.charges.reduce((sum: number, charge: any) => sum + ((charge.qty * charge.rate) || 0), 0); + const subtotal = staffTotal + chargesTotal; + const total = subtotal + (parseFloat(data.other_charges) || 0); + + const invoiceStatus = (statusOverride || (existingInvoice?.status === InvoiceStatus.DISPUTED ? InvoiceStatus.PENDING_REVIEW : (existingInvoice?.status || InvoiceStatus.DRAFT))) as InvoiceStatus; + + const payload = { + status: invoiceStatus, + vendorId: "00000000-0000-0000-0000-000000000000", // placeholder + businessId: selectedClientId || "00000000-0000-0000-0000-000000000000", // placeholder + orderId: data.event_id || "00000000-0000-0000-0000-000000000000", // placeholder + paymentTerms: data.payment_terms as InovicePaymentTerms, + invoiceNumber: data.invoice_number, + issueDate: new Date(data.invoice_date).toISOString(), + dueDate: new Date(data.due_date).toISOString(), + hub: data.hub, + managerName: data.manager, + vendorNumber: data.vendor_id, + roles: data.staff_entries, + charges: data.charges, + subtotal: subtotal, + otherCharges: parseFloat(data.other_charges) || 0, + amount: total, + notes: data.notes, + staffCount: data.staff_entries.length, + chargesCount: data.charges.length, + }; + + try { + if (effectiveInvoiceId) { + await updateInvoice({ id: effectiveInvoiceId, ...payload }); + } else { + await createInvoice(payload); + } + + toast({ + title: isEdit ? "✅ Invoice Updated" : "✅ Invoice Created", + description: isEdit ? "Invoice has been updated successfully" : "Invoice has been created successfully", + }); + navigate("/invoices"); + } catch (error) { + console.error("Error saving invoice:", error); + toast({ + title: "❌ Error", + description: "Failed to save invoice. Please try again.", + variant: "destructive" + }); + } + }; + + const isSaving = creating || updating; + + // Mock email sending + const handleSendToClient = async () => { + const invoiceData = { ...formData, status: InvoiceStatus.PENDING_REVIEW }; + await handleSave(InvoiceStatus.PENDING_REVIEW); + + // Simulate email + await new Promise(resolve => setTimeout(resolve, 1000)); + + toast({ + title: "📧 Email Sent", + description: `Invoice emailed to ${invoiceData.to_company?.email || 'client'}`, + }); + }; + + return ( + + {existingInvoice?.status || "Draft"} + + } + backAction={ + + } + > +
+ {/* Dispute Alert Banner */} + {existingInvoice?.status === InvoiceStatus.DISPUTED && disputedIndices.length > 0 && ( +
+
+
+ ! +
+
+

Disputed Items Highlighted

+

+ {disputedIndices.length} line item(s) have been disputed by the client. + Reason: {existingInvoice.dispute_reason || "Not specified"} +

+ {existingInvoice.dispute_details && ( +

+ "{existingInvoice.dispute_details}" +

+ )} +

+ Please review and correct the highlighted rows, then resubmit for approval. +

+
+
+
+ )} + +
+ {/* Quick Actions - Simplified */} + {!isEdit && ( +
+
+
+ +
+
+

Import from Event

+

Quickly fill invoice from a completed event's shifts

+
+
+ +
+ )} + + {/* Client Selection - Top Section */} + {!isEdit && ( +
+
+
+ +
+
+

Select Client

+

Choose a client to auto-fill company details and rates

+
+
+ +
+ )} + + {/* Invoice Details Header */} +
+
+
+
+ +
+
+

Invoice Details

+
+ + Event: {formData.event_name || "Internal Support"} +
+
+
+ +
+
Invoice Number
+
{formData.invoice_number}
+
+ +
+
+ + setFormData({ ...formData, invoice_date: e.target.value })} + className="mt-1" + /> +
+
+ + setFormData({ ...formData, due_date: e.target.value })} + className="mt-1" + /> +
+
+ +
+
+ + setFormData({ ...formData, hub: e.target.value })} + placeholder="Hub" + className="mt-1" + /> +
+
+ + setFormData({ ...formData, manager: e.target.value })} + placeholder="Manager Name" + className="mt-1" + /> +
+
+ +
+ + setFormData({ ...formData, vendor_id: e.target.value })} + placeholder="Vendor #" + className="mt-1" + /> +
+
+ +
+
+ +
+ {[ + { label: "30 days", value: "NET_30", days: 30 }, + { label: "45 days", value: "NET_45", days: 45 }, + { label: "60 days", value: "NET_60", days: 60 } + ].map(term => ( + setFormData({ + ...formData, + payment_terms: term.value, + due_date: format(addDays(new Date(formData.invoice_date), term.days), 'yyyy-MM-dd') + })} + > + {term.label} + + ))} +
+
+ +
+
+ Department: + setFormData({ ...formData, department: e.target.value })} + placeholder="Dept Code" + className="h-9 w-full md:w-48" + /> +
+
+ PO#: + setFormData({ ...formData, po_reference: e.target.value })} + placeholder="PO Number" + className="h-9 w-full md:w-48" + /> +
+
+
+
+ + {/* From and To - Compact */} +
+ {/* From Company */} +
+
+
F
+ From (Vendor) +
+ {/* Inputs for from_company */} +
+ {['name', 'address', 'phone', 'email'].map(field => ( +
+ setFormData({ ...formData, from_company: { ...formData.from_company, [field]: e.target.value } })} + className="w-full text-sm font-medium text-primary-text bg-transparent border-0 border-b border-transparent hover:border-border focus:border-primary focus:ring-0 px-0 py-1 transition-all placeholder:text-muted-foreground/50" + placeholder={field.charAt(0).toUpperCase() + field.slice(1)} + /> + {field === 'email' && } + {field === 'phone' && } +
+ ))} +
+
+ + {/* To Company */} +
+
+
T
+ To (Client) +
+
+ {['name', 'hub_name', 'department', 'po_reference', 'address', 'phone', 'email'].map(field => ( +
+ { + if (field === 'department' || field === 'po_reference') { + setFormData({ ...formData, [field]: e.target.value }); + } else { + setFormData({ ...formData, to_company: { ...formData.to_company, [field]: e.target.value } }); + } + }} + placeholder={field.replace('_', ' ').charAt(0).toUpperCase() + field.slice(1)} + className="w-full text-sm text-secondary-text bg-transparent border-0 border-b border-transparent hover:border-border focus:border-primary focus:ring-0 px-0 py-1 transition-all placeholder:text-muted-foreground/50" + /> +
+ ))} +
+
+
+ + {/* Staff Table */} +
+
+
+
+ +
+
+

Staff Entries

+

{formData.staff_entries.length} entries

+
+
+ +
+ + +
+ + + + # + Name + Position + ClockIn + Lunch + Checkout + Wrkd + Reg + OT + DT + Rate + Reg$ + OT$ + DT$ + Total + + + + + {formData.staff_entries.map((entry: any, idx: number) => ( + + {idx + 1} + + + + + + + { }} + /> +
+ {staffDirectory.map(staff => ( + + ))} +
+
+
+
+ + + + + setTimePickerOpen(open ? `checkin-${idx}` : null)}> + + + + +
+
+ + +
+ {['AM', 'PM'].map(p => ( + + ))} +
+
+ +
+
+
+
+ + + + + setTimePickerOpen(open ? `checkout-${idx}` : null)}> + + + + +
+
+ + +
+ {['AM', 'PM'].map(p => ( + + ))} +
+
+ +
+
+
+
+ + handleStaffChange(idx, 'worked_hours', parseFloat(e.target.value))} className="h-8 w-14 text-xs px-1 text-center font-medium bg-muted/20 border-0" /> + + + handleStaffChange(idx, 'regular_hours', parseFloat(e.target.value))} className="h-8 w-12 text-xs px-1 text-center" /> + + + handleStaffChange(idx, 'ot_hours', parseFloat(e.target.value))} className="h-8 w-12 text-xs px-1 text-center" /> + + + handleStaffChange(idx, 'dt_hours', parseFloat(e.target.value))} className="h-8 w-12 text-xs px-1 text-center" /> + + + handleStaffChange(idx, 'rate', parseFloat(e.target.value))} className="h-8 w-14 text-xs px-1 text-right" /> + + ${entry.regular_value?.toFixed(0) || "0"} + ${entry.ot_value?.toFixed(0) || "0"} + ${entry.dt_value?.toFixed(0) || "0"} + ${entry.total?.toFixed(2) || "0"} + + + +
+ ))} +
+
+
+
+
+ + {/* Charges */} +
+
+
+
+ 💰 +
+
+

Additional Charges

+

{formData.charges.length} charges

+
+
+ +
+ + +
+ + + + # + Name + QTY + Rate + Price + Actions + + + + {formData.charges.map((charge: any, idx: number) => ( + + {idx + 1} + + handleChargeChange(idx, 'name', e.target.value)} className="h-8 max-w-md" /> + + + handleChargeChange(idx, 'qty', parseFloat(e.target.value))} className="h-8 w-20" /> + + + handleChargeChange(idx, 'rate', parseFloat(e.target.value))} className="h-8 w-24" /> + + ${charge.price?.toFixed(2) || "0.00"} + + + + + ))} + +
+
+
+
+ + {/* Totals */} +
+
+
+
+ Sub total: + ${totals.subtotal.toFixed(2)} +
+
+ Other charges: + setFormData({ ...formData, other_charges: e.target.value })} className="h-8 w-32 text-right bg-white" /> +
+
+ Grand total: + ${totals.grandTotal.toFixed(2)} +
+
+
+
+ + {/* Notes */} +
+ +