Merge pull request #380 from Oloodi/authentication-web

Order's PR
This commit is contained in:
Achintha Isuru
2026-02-05 22:24:19 -05:00
committed by GitHub
25 changed files with 4142 additions and 9 deletions

View File

@@ -10,11 +10,14 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@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",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/themes": "^3.2.1", "@radix-ui/themes": "^3.2.1",
"@reduxjs/toolkit": "^2.11.2", "@reduxjs/toolkit": "^2.11.2",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
@@ -31,6 +34,7 @@
"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-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-hook-form": "^7.71.1", "react-hook-form": "^7.71.1",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",

View File

@@ -5,29 +5,36 @@ settings:
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
overrides: overrides:
'@firebasegen/example-connector': link:src/dataconnect-generated
dataconnect-generated: link:../../../../../AppData/Local/pnpm/global/5/node_modules/src/dataconnect-generated dataconnect-generated: link:../../../../../AppData/Local/pnpm/global/5/node_modules/src/dataconnect-generated
'@dataconnect/generated': link:src/dataconnect-generated
importers: importers:
.: .:
dependencies: dependencies:
'@dataconnect/generated':
specifier: link:src/dataconnect-generated
version: link:src/dataconnect-generated
'@firebase/analytics': '@firebase/analytics':
specifier: ^0.10.19 specifier: ^0.10.19
version: 0.10.19(@firebase/app@0.14.7) version: 0.10.19(@firebase/app@0.14.7)
'@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)
'@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-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)
'@radix-ui/react-popover':
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-slot': '@radix-ui/react-slot':
specifier: ^1.2.4 specifier: ^1.2.4
version: 1.2.4(@types/react@19.2.10)(react@19.2.4) version: 1.2.4(@types/react@19.2.10)(react@19.2.4)
'@radix-ui/react-switch':
specifier: ^1.2.6
version: 1.2.6(@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-tabs':
specifier: ^1.1.13
version: 1.1.13(@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/themes': '@radix-ui/themes':
specifier: ^3.2.1 specifier: ^3.2.1
version: 3.2.1(@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: 3.2.1(@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)
@@ -76,6 +83,9 @@ importers:
react-datepicker: react-datepicker:
specifier: ^9.1.0 specifier: ^9.1.0
version: 9.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 9.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react-day-picker:
specifier: ^9.13.0
version: 9.13.0(react@19.2.4)
react-dom: react-dom:
specifier: ^19.2.0 specifier: ^19.2.0
version: 19.2.4(react@19.2.4) version: 19.2.4(react@19.2.4)
@@ -232,6 +242,9 @@ packages:
resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@date-fns/tz@1.4.1':
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
'@esbuild/aix-ppc64@0.27.2': '@esbuild/aix-ppc64@0.27.2':
resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1504,66 +1517,79 @@ packages:
resolution: {integrity: sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==} resolution: {integrity: sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.57.0': '@rollup/rollup-linux-arm-musleabihf@4.57.0':
resolution: {integrity: sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==} resolution: {integrity: sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.57.0': '@rollup/rollup-linux-arm64-gnu@4.57.0':
resolution: {integrity: sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==} resolution: {integrity: sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.57.0': '@rollup/rollup-linux-arm64-musl@4.57.0':
resolution: {integrity: sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==} resolution: {integrity: sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.57.0': '@rollup/rollup-linux-loong64-gnu@4.57.0':
resolution: {integrity: sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==} resolution: {integrity: sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.57.0': '@rollup/rollup-linux-loong64-musl@4.57.0':
resolution: {integrity: sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==} resolution: {integrity: sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.57.0': '@rollup/rollup-linux-ppc64-gnu@4.57.0':
resolution: {integrity: sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==} resolution: {integrity: sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.57.0': '@rollup/rollup-linux-ppc64-musl@4.57.0':
resolution: {integrity: sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==} resolution: {integrity: sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.57.0': '@rollup/rollup-linux-riscv64-gnu@4.57.0':
resolution: {integrity: sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==} resolution: {integrity: sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.57.0': '@rollup/rollup-linux-riscv64-musl@4.57.0':
resolution: {integrity: sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==} resolution: {integrity: sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.57.0': '@rollup/rollup-linux-s390x-gnu@4.57.0':
resolution: {integrity: sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==} resolution: {integrity: sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.57.0': '@rollup/rollup-linux-x64-gnu@4.57.0':
resolution: {integrity: sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==} resolution: {integrity: sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.57.0': '@rollup/rollup-linux-x64-musl@4.57.0':
resolution: {integrity: sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==} resolution: {integrity: sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.57.0': '@rollup/rollup-openbsd-x64@4.57.0':
resolution: {integrity: sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==} resolution: {integrity: sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==}
@@ -1639,24 +1665,28 @@ packages:
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.18': '@tailwindcss/oxide-linux-arm64-musl@4.1.18':
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.18': '@tailwindcss/oxide-linux-x64-gnu@4.1.18':
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.18': '@tailwindcss/oxide-linux-x64-musl@4.1.18':
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.18': '@tailwindcss/oxide-wasm32-wasi@4.1.18':
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
@@ -1995,6 +2025,9 @@ packages:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'} engines: {node: '>=12'}
date-fns-jalali@4.1.0-0:
resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==}
date-fns@4.1.0: date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
@@ -2384,24 +2417,28 @@ packages:
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.30.2: lightningcss-linux-arm64-musl@1.30.2:
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.30.2: lightningcss-linux-x64-gnu@1.30.2:
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.30.2: lightningcss-linux-x64-musl@1.30.2:
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.30.2: lightningcss-win32-arm64-msvc@1.30.2:
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
@@ -2558,6 +2595,12 @@ packages:
date-fns-tz: date-fns-tz:
optional: true optional: true
react-day-picker@9.13.0:
resolution: {integrity: sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ==}
engines: {node: '>=18'}
peerDependencies:
react: '>=16.8.0'
react-dom@19.2.4: react-dom@19.2.4:
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
peerDependencies: peerDependencies:
@@ -3017,6 +3060,8 @@ snapshots:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.28.5
'@date-fns/tz@1.4.1': {}
'@esbuild/aix-ppc64@0.27.2': '@esbuild/aix-ppc64@0.27.2':
optional: true optional: true
@@ -4832,6 +4877,8 @@ snapshots:
d3-timer@3.0.1: {} d3-timer@3.0.1: {}
date-fns-jalali@4.1.0-0: {}
date-fns@4.1.0: {} date-fns@4.1.0: {}
debug@4.4.3: debug@4.4.3:
@@ -5429,6 +5476,13 @@ snapshots:
react: 19.2.4 react: 19.2.4
react-dom: 19.2.4(react@19.2.4) react-dom: 19.2.4(react@19.2.4)
react-day-picker@9.13.0(react@19.2.4):
dependencies:
'@date-fns/tz': 1.4.1
date-fns: 4.1.0
date-fns-jalali: 4.1.0-0
react: 19.2.4
react-dom@19.2.4(react@19.2.4): react-dom@19.2.4(react@19.2.4):
dependencies: dependencies:
react: 19.2.4 react: 19.2.4

View File

@@ -1,4 +1,3 @@
overrides: overrides:
'@dataconnect/generated': link:src/dataconnect-generated
'@firebasegen/example-connector': link:src/dataconnect-generated
dataconnect-generated: link:../../../../../AppData/Local/pnpm/global/5/node_modules/src/dataconnect-generated dataconnect-generated: link:../../../../../AppData/Local/pnpm/global/5/node_modules/src/dataconnect-generated

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -13,6 +13,8 @@ const badgeVariants = cva(
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
success:
"border-transparent bg-emerald-500 text-white hover:bg-emerald-500/80",
outline: "text-foreground", outline: "text-foreground",
}, },
}, },

View File

@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const buttonVariants = cva( export const buttonVariants = cva(
"inline-flex items-center justify-center transition-premium gap-2 whitespace-nowrap rounded-xl text-base font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 active:scale-[0.98]", "inline-flex items-center justify-center transition-premium gap-2 whitespace-nowrap rounded-xl text-base font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 active:scale-[0.98]",
{ {
variants: { variants: {

View File

@@ -0,0 +1,73 @@
import { ChevronLeft, ChevronRight } from "lucide-react"
import { DayPicker, type DayPickerProps } from "react-day-picker"
import { buttonVariants } from "@/common/components/ui/button"
import { cn } from "@/lib/utils"
export type CalendarProps = DayPickerProps & {
className?: string;
};
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: cn(
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md",
props.mode === "range"
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
: "[&:has([aria-selected])]:rounded-md"
),
day: cn(
buttonVariants({ variant: "ghost" }),
"h-8 w-8 p-0 font-normal aria-selected:opacity-100"
),
day_range_start: "day-range-start",
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
Chevron: ({ orientation }) => {
const Icon = orientation === "left" ? ChevronLeft : ChevronRight;
return <Icon className="h-4 w-4" />;
},
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar }

View File

@@ -0,0 +1,152 @@
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/common/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
interface CommandDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,119 @@
import React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-1.5 text-center sm:text-left',
className
)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View File

@@ -0,0 +1,25 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<React.ElementRef<typeof SwitchPrimitives.Root>, React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>>(
({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-[24px] w-[44px] shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
)
)
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -0,0 +1,120 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-4 align-middle [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,52 @@
import React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '@/lib/utils';
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -0,0 +1,25 @@
// Simplified use-toast hook
import { useState } from "react";
type ToastProps = {
title?: string;
description?: string;
variant?: "default" | "destructive";
};
export const useToast = () => {
const [toasts, setToasts] = useState<ToastProps[]>([]);
const toast = ({ title, description, variant = "default" }: ToastProps) => {
const newToast = { title, description, variant };
setToasts((prev) => [...prev, newToast]);
console.log("Toast:", title, description);
// Auto dismiss after 3 seconds
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t !== newToast));
}, 3000);
};
return { toast, toasts };
};

View File

@@ -0,0 +1,405 @@
import { Button } from "@/common/components/ui/button";
import { Input } from "@/common/components/ui/input";
import { Label } from "@/common/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/common/components/ui/select";
import { Textarea } from "@/common/components/ui/textarea";
import DashboardLayout from "@/features/layouts/DashboardLayout";
import { ArrowLeft, Loader2, Save, X, Mail } from "lucide-react";
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { useSelector } from "react-redux";
import type { RootState } from "@/store/store";
import {
useCreateBusiness,
useCreateTeamHub,
useCreateTeam
} from "@/dataconnect-generated/react";
import {
BusinessArea,
BusinessSector,
BusinessStatus,
BusinessRateGroup,
} from "@/dataconnect-generated";
import { dataConnect } from "@/features/auth/firebase";
import { motion, AnimatePresence } from "framer-motion";
export default function AddClient() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { user } = useSelector((state: RootState) => state.auth);
const [showSnackbar, setShowSnackbar] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState("");
const [formData, setFormData] = useState({
businessName: "",
companyLogoUrl: "",
contactName: "",
phone: "",
email: "",
hubBuilding: "",
address: "",
city: "",
area: BusinessArea.BAY_AREA,
sector: BusinessSector.OTHER,
rateGroup: BusinessRateGroup.STANDARD,
status: BusinessStatus.ACTIVE,
notes: ""
});
const { mutateAsync: createBusiness, isPending: isCreatingBusiness } = useCreateBusiness(dataConnect);
const { mutateAsync: createHub, isPending: isCreatingHub } = useCreateTeamHub(dataConnect);
const { mutateAsync: createTeam, isPending: isCreatingTeam } = useCreateTeam(dataConnect);
const handleChange = (field: string, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!user?.uid) return;
try {
// 1. Create the business record
const businessResult = await createBusiness({
businessName: formData.businessName,
contactName: formData.contactName,
userId: user.uid,
companyLogoUrl: formData.companyLogoUrl,
phone: formData.phone,
email: formData.email,
hubBuilding: formData.hubBuilding,
address: formData.address,
city: formData.city,
area: formData.area,
sector: formData.sector,
rateGroup: formData.rateGroup,
status: formData.status,
notes: formData.notes
});
console.log("Business created:", businessResult);
const businessId = businessResult.business_insert.id;
if (!businessId) {
throw new Error("Business creation failed — no ID returned.");
}
// Create the team for this business
const teamResult = await createTeam({
teamName: `${formData.businessName} Team`,
ownerId: businessId,
ownerName: formData.contactName,
ownerRole: "ADMIN",
email: formData.email,
companyLogo: formData.companyLogoUrl || null,
totalMembers: 0,
activeMembers: 0,
totalHubs: 0
});
const teamId = teamResult.team_insert.id;
if (!teamId) {
throw new Error("Team creation failed — no ID returned.");
}
// 2. Automatically create the client's first "hub" or location
await createHub({
teamId: teamId,
hubName: `${formData.businessName} - Main Hub`,
address: formData.address || "Main Office",
city: formData.city,
isActive: true
});
// 3. Show snackbar for welcome email
setSnackbarMessage(`Welcome email sent to ${formData.contactName} (${formData.email})`);
setShowSnackbar(true);
// Invalidate queries and navigate after a delay to show snackbar
queryClient.invalidateQueries({ queryKey: ['businesses'] });
setTimeout(() => {
navigate("/clients");
}, 3000);
} catch (error) {
console.error("Error creating client partnership:", error);
setSnackbarMessage("Failed to create client partnership. Please try again.");
setShowSnackbar(true);
}
};
const isPending = isCreatingBusiness || isCreatingHub;
return (
<DashboardLayout
title="Register Business"
subtitle="Initialize a new client partnership and hub location."
actions={
<Button
variant="outline"
onClick={() => navigate("/clients")}
leadingIcon={<ArrowLeft />}
>
Back to Directory
</Button>
}
>
<div className=" mx-auto pb-20 relative">
<form onSubmit={handleSubmit}>
<div className="py-8 space-y-8">
{/* Business Name & Company Logo */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="businessName">
Business Name <span className="text-red-500">*</span>
</Label>
<Input
id="businessName"
value={formData.businessName}
onChange={(e) => handleChange('businessName', e.target.value)}
placeholder="Enter business name"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="companyLogoUrl">
Company Logo URL
</Label>
<Input
id="companyLogoUrl"
value={formData.companyLogoUrl}
onChange={(e) => handleChange('companyLogoUrl', e.target.value)}
placeholder="https://example.com/logo.png"
/>
<p className="text-xs text-muted-text">Optional: URL to company logo image</p>
</div>
</div>
{/* Primary Contact */}
<div className="space-y-2">
<Label htmlFor="contactName">
Primary Contact <span className="text-red-500">*</span>
</Label>
<Input
id="contactName"
value={formData.contactName}
onChange={(e) => handleChange('contactName', e.target.value)}
placeholder="Contact name"
required
/>
</div>
{/* Contact Number & Email */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="phone">
Contact Number
</Label>
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => handleChange('phone', e.target.value)}
placeholder="(555) 123-4567"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">
Email <span className="text-red-500">*</span>
</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => handleChange('email', e.target.value)}
placeholder="business@example.com"
required
/>
</div>
</div>
{/* Hub / Building */}
<div className="space-y-2">
<Label htmlFor="hubBuilding">
Hub / Building
</Label>
<Input
id="hubBuilding"
value={formData.hubBuilding}
onChange={(e) => handleChange('hubBuilding', e.target.value)}
placeholder="Building name or location"
/>
</div>
{/* Billing Address */}
<div className="space-y-2">
<Label htmlFor="address">
Billing Address <span className="text-red-500">*</span>
</Label>
<Input
id="address"
value={formData.address}
onChange={(e) => handleChange('address', e.target.value)}
placeholder="Street address"
required
/>
</div>
{/* City & Area */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="city">
City <span className="text-red-500">*</span>
</Label>
<Input
id="city"
value={formData.city}
onChange={(e) => handleChange('city', e.target.value)}
placeholder="City"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="area">
Area
</Label>
<Select value={formData.area} onValueChange={(value : any) => handleChange('area', value)}>
<SelectTrigger>
<SelectValue placeholder="Select area" />
</SelectTrigger>
<SelectContent>
<SelectItem value={BusinessArea.BAY_AREA}>Bay Area</SelectItem>
<SelectItem value={BusinessArea.SOUTHERN_CALIFORNIA}>Southern California</SelectItem>
<SelectItem value={BusinessArea.NORTHERN_CALIFORNIA}>Northern California</SelectItem>
<SelectItem value={BusinessArea.CENTRAL_VALLEY}>Central Valley</SelectItem>
<SelectItem value={BusinessArea.OTHER}>Other</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Sector & Rate Group */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="sector">
Industry / Sector <span className="text-red-500">*</span>
</Label>
<Select value={formData.sector} onValueChange={(value :any) => handleChange('sector', value)}>
<SelectTrigger>
<SelectValue placeholder="Select sector" />
</SelectTrigger>
<SelectContent>
<SelectItem value={BusinessSector.BON_APPETIT}>Bon Appétit</SelectItem>
<SelectItem value={BusinessSector.EUREST}>Eurest</SelectItem>
<SelectItem value={BusinessSector.ARAMARK}>Aramark</SelectItem>
<SelectItem value={BusinessSector.EPICUREAN_GROUP}>Epicurean Group</SelectItem>
<SelectItem value={BusinessSector.CHARTWELLS}>Chartwells</SelectItem>
<SelectItem value={BusinessSector.OTHER}>Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="rateGroup">
Rate Group <span className="text-red-500">*</span>
</Label>
<Select value={formData.rateGroup} onValueChange={(value : any) => handleChange('rateGroup', value)} required>
<SelectTrigger>
<SelectValue placeholder="Select pricing tier" />
</SelectTrigger>
<SelectContent>
<SelectItem value={BusinessRateGroup.STANDARD}>Standard</SelectItem>
<SelectItem value={BusinessRateGroup.PREMIUM}>Premium</SelectItem>
<SelectItem value={BusinessRateGroup.ENTERPRISE}>Enterprise</SelectItem>
<SelectItem value={BusinessRateGroup.CUSTOM}>Custom</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Status */}
<div className="space-y-2">
<Label htmlFor="status">
Status
</Label>
<Select value={formData.status} onValueChange={(value : any) => handleChange('status', value)}>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value={BusinessStatus.ACTIVE}>Active</SelectItem>
<SelectItem value={BusinessStatus.INACTIVE}>Inactive</SelectItem>
<SelectItem value={BusinessStatus.PENDING}>Pending</SelectItem>
</SelectContent>
</Select>
</div>
{/* Notes */}
<div className="space-y-2">
<Label htmlFor="notes">Notes</Label>
<Textarea
id="notes"
value={formData.notes}
onChange={(e) => handleChange('notes', e.target.value)}
rows={4}
placeholder="Additional notes about this business..."
/>
</div>
</div>
<div className="flex justify-end gap-3 mt-8">
<Button
type="button"
variant="outline"
onClick={() => navigate("/clients")}
>
Cancel
</Button>
<Button
type="submit"
disabled={isPending}
leadingIcon={isPending ? <Loader2 className="animate-spin" /> : <Save />}
>
{isPending ? "Initializing Partnership..." : "Create Business Client"}
</Button>
</div>
</form>
{/* Snackbar Notification */}
<AnimatePresence>
{showSnackbar && (
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 50 }}
className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 bg-slate-900 text-white px-6 py-4 rounded-2xl shadow-2xl border border-white/10 min-w-[320px]"
>
<div className="p-2 bg-emerald-500/20 rounded-lg text-emerald-400">
<Mail size={18} />
</div>
<p className="text-sm font-bold flex-1">{snackbarMessage}</p>
<button
onClick={() => setShowSnackbar(false)}
className="p-1 hover:bg-white/10 rounded-full transition-colors"
>
<X size={16} />
</button>
</motion.div>
)}
</AnimatePresence>
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,280 @@
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 { Badge } from "@/common/components/ui/badge";
import DashboardLayout from "@/features/layouts/DashboardLayout";
import {
Building2,
Plus,
Search,
ExternalLink,
Layers,
Activity
} from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useSelector } from "react-redux";
import type { RootState } from "@/store/store";
import { useListBusinesses, useListTeamHubs, useListOrders } from "@/dataconnect-generated/react";
import { dataConnect } from "@/features/auth/firebase";
import { format } from "date-fns";
export default function ClientList() {
const navigate = useNavigate();
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [industryFilter, setIndustryFilter] = useState("all");
const { user } = useSelector((state: RootState) => state.auth);
const isAdmin = user?.userRole === 'admin' || user?.userRole === 'ADMIN';
const { data: businessData, isLoading: loadingBusinesses } = useListBusinesses(dataConnect);
const { data: hubData, isLoading: loadingHubs } = useListTeamHubs(dataConnect);
const { data: orderData, isLoading: loadingOrders } = useListOrders(dataConnect);
const isLoading = loadingBusinesses || loadingHubs || loadingOrders;
const businesses = businessData?.businesses || [];
const hubs = hubData?.teamHubs || [];
const orders = orderData?.orders || [];
const industries = useMemo(() => {
return [...new Set(businesses.map(b => b.sector).filter(Boolean))];
}, [businesses]);
const processedClients = useMemo(() => {
return businesses.map(business => {
const businessHubs = hubs.filter(h => h.teamId === business.id);
const businessOrders = orders.filter(o => o.businessId === business.id);
const lastOrder = businessOrders.length > 0
? [...businessOrders].sort((a, b) => {
const timeA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const timeB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return timeB - timeA;
})[0]
: null;
return {
...business,
hubCount: businessHubs.length,
lastOrderDate: lastOrder ? lastOrder.createdAt : null
};
});
}, [businesses, hubs, orders]);
const filteredClients = useMemo(() => {
return processedClients.filter(client => {
const matchesSearch = client.businessName?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === "all" || client.status === statusFilter;
const matchesIndustry = industryFilter === "all" || client.sector === industryFilter;
return matchesSearch && matchesStatus && matchesIndustry;
});
}, [processedClients, searchTerm, statusFilter, industryFilter]);
if (!isAdmin) {
return (
<DashboardLayout title="Access Denied" subtitle="Unauthorized Access">
<div className="flex flex-col items-center justify-center min-h-[40vh] text-center">
<div className="w-16 h-16 bg-destructive/10 rounded-full flex items-center justify-center text-destructive mb-4">
<Activity className="w-8 h-8" />
</div>
<h2 className="text-2xl font-bold">Restricted Access</h2>
<p className="text-muted-foreground mt-2 max-w-sm">Only administrators are authorized to view and manage business client records.</p>
<Button onClick={() => navigate("/")} variant="outline" className="mt-6 rounded-xl font-bold">
Return to Dashboard
</Button>
</div>
</DashboardLayout>
);
}
return (
<DashboardLayout
title="Business Clients"
subtitle="Manage and monitor all business accounts"
actions={
<Button
onClick={() => navigate("/clients/add")}
leadingIcon={<Plus />}
>
Add Client
</Button>
}
>
<div className="space-y-6">
{/* KPI Summary */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
<CardContent className="p-6">
<div className="flex items-center gap-4">
<div className="p-3 bg-primary/10 rounded-xl text-primary">
<Building2 className="w-6 h-6" />
</div>
<div>
<p className="text-sm text-muted-foreground font-medium">Total Clients</p>
<p className="text-2xl font-bold">{businesses.length}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
<CardContent className="p-6">
<div className="flex items-center gap-4">
<div className="p-3 bg-emerald-500/10 rounded-xl text-emerald-600">
<Activity className="w-6 h-6" />
</div>
<div>
<p className="text-sm text-muted-foreground font-medium">Active Hubs</p>
<p className="text-2xl font-bold">{hubs.length}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
<CardContent className="p-6">
<div className="flex items-center gap-4">
<div className="p-3 bg-blue-500/10 rounded-xl text-blue-600">
<Layers className="w-6 h-6" />
</div>
<div>
<p className="text-sm text-muted-foreground font-medium">Total Orders</p>
<p className="text-2xl font-bold">{orders.length}</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<div className="flex flex-col md:flex-row gap-4 items-center">
<div className="relative flex-1 w-full">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search by business name..."
className="pl-10"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex gap-2 w-full md:w-auto">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="ACTIVE">Active</SelectItem>
<SelectItem value="PENDING">Pending</SelectItem>
<SelectItem value="SUSPENDED">Suspended</SelectItem>
</SelectContent>
</Select>
<Select value={industryFilter} onValueChange={setIndustryFilter}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Industry" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Industries</SelectItem>
{industries.map(industry => (
<SelectItem key={industry} value={industry}>{industry}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Table View */}
<div className="bg-card/50 backdrop-blur-sm border border-border rounded-2xl overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-border bg-muted/30">
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-muted-foreground">Business Name</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-muted-foreground text-center">Logo</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-muted-foreground">Industry</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-muted-foreground">Status</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-muted-foreground text-center">Hubs</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-muted-foreground">Last Order</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-muted-foreground text-right">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{isLoading ? (
<tr>
<td colSpan={7} className="px-6 py-12 text-center">
<div className="flex flex-col items-center gap-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 font-medium">Loading clients...</p>
</div>
</td>
</tr>
) : filteredClients.length > 0 ? (
filteredClients.map((client) => (
<tr
key={client.id}
className="hover:bg-muted/40 cursor-pointer transition-colors group"
onClick={() => navigate(`/clients/${client.id}/edit`)}
>
<td className="px-6 py-4">
<div className="font-bold text-foreground group-hover:text-primary transition-colors">
{client.businessName}
</div>
<div className="text-xs text-muted-foreground">{client.email}</div>
</td>
<td className="px-6 py-4">
<div className="flex justify-center">
<div className="w-10 h-10 rounded-lg bg-muted border border-border overflow-hidden flex items-center justify-center">
{client.companyLogoUrl ? (
<img src={client.companyLogoUrl} alt={client.businessName} className="w-full h-full object-cover" />
) : (
<Building2 className="w-5 h-5 text-muted-foreground" />
)}
</div>
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm font-medium">{client.sector || 'N/A'}</div>
</td>
<td className="px-6 py-4">
<Badge variant={client.status === 'ACTIVE' ? 'success' : client.status === 'INACTIVE' ? 'destructive' : 'secondary'} className="font-bold uppercase text-[10px]">
{client.status?.toLowerCase() || 'PENDING'}
</Badge>
</td>
<td className="px-6 py-4 text-center">
<div className="text-sm font-bold bg-muted/50 w-8 h-8 rounded-full flex items-center justify-center mx-auto border border-border">
{client.hubCount}
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm font-medium">
{client.lastOrderDate ? format(new Date(client.lastOrderDate), 'MMM d, yyyy') : 'No orders'}
</div>
</td>
<td className="px-6 py-4 text-right">
<Button variant="ghost" size="sm" className="rounded-lg h-8 w-8 p-0">
<ExternalLink className="w-4 h-4" />
</Button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={7} className="px-6 py-12 text-center">
<p className="text-muted-foreground">No business clients found matching your search.</p>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,371 @@
import React, { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/common/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/common/components/ui/card";
import { Input } from "@/common/components/ui/input";
import { Label } from "@/common/components/ui/label";
import { Textarea } from "@/common/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/common/components/ui/select";
import { ArrowLeft, Save, Loader2, Building2, User, MapPin, CreditCard, Activity } from "lucide-react";
import DashboardLayout from "@/features/layouts/DashboardLayout";
import {
useGetBusinessById,
useUpdateBusiness
} from "@/dataconnect-generated/react";
import {
BusinessArea,
BusinessSector,
BusinessStatus,
BusinessRateGroup
} from "@/dataconnect-generated";
import { dataConnect } from "@/features/auth/firebase";
export default function EditClient() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { id: businessId } = useParams<{ id: string }>();
const { data: businessData, isLoading: isLoadingBusiness } = useGetBusinessById(dataConnect, { id: businessId || "" });
const { mutateAsync: updateBusiness, isPending: isUpdating } = useUpdateBusiness(dataConnect);
const [formData, setFormData] = useState({
businessName: "",
sector: BusinessSector.OTHER,
address: "",
city: "",
area: BusinessArea.OTHER,
contactName: "",
phone: "",
email: "",
rateGroup: BusinessRateGroup.STANDARD,
status: BusinessStatus.ACTIVE,
notes: ""
});
useEffect(() => {
if (businessData?.business) {
const b = businessData.business;
setFormData({
businessName: b.businessName || "",
sector: b.sector || BusinessSector.OTHER,
address: b.address || "",
city: b.city || "",
area: b.area || BusinessArea.OTHER,
contactName: b.contactName || "",
phone: b.phone || "",
email: b.email || "",
rateGroup: b.rateGroup || BusinessRateGroup.STANDARD,
status: b.status || BusinessStatus.ACTIVE,
notes: b.notes || ""
});
}
}, [businessData]);
const handleChange = (field: string, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!businessId) return;
try {
await updateBusiness({
id: businessId,
businessName: formData.businessName,
contactName: formData.contactName,
phone: formData.phone,
email: formData.email,
address: formData.address,
city: formData.city,
area: formData.area,
sector: formData.sector,
rateGroup: formData.rateGroup,
status: formData.status,
notes: formData.notes
});
queryClient.invalidateQueries({ queryKey: ['businesses'] });
queryClient.invalidateQueries({ queryKey: ['business', businessId] });
navigate("/clients");
} catch (error) {
console.error("Error updating client:", error);
}
};
if (isLoadingBusiness) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
);
}
if (!businessData?.business) {
return (
<DashboardLayout title="Client Not Found">
<div className="text-center py-20">
<h2 className="text-2xl font-bold mb-4">Business record not found.</h2>
<Button onClick={() => navigate("/clients")} variant="outline">
Return to Directory
</Button>
</div>
</DashboardLayout>
);
}
return (
<DashboardLayout
title={`Edit ${formData.businessName}`}
subtitle="Update client partnership details and configuration."
actions={
<Button
variant="outline"
onClick={() => navigate("/clients")}
leadingIcon={<ArrowLeft />}
>
Back to Directory
</Button>
}
>
<div className="max-w-4xl mx-auto pb-20">
<form onSubmit={handleSubmit} className="space-y-6">
{/* General Information */}
<Card className="border-border/50 shadow-sm">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Building2 className="w-5 h-5 text-primary" />
General Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="businessName">Business Name *</Label>
<Input
id="businessName"
value={formData.businessName}
onChange={(e) => handleChange('businessName', e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="sector">Industry / Sector *</Label>
<Select
value={formData.sector}
onValueChange={(value: BusinessSector) => handleChange('sector', value)}
>
<SelectTrigger>
<SelectValue placeholder="Select sector" />
</SelectTrigger>
<SelectContent>
<SelectItem value={BusinessSector.BON_APPETIT}>Bon Appétit</SelectItem>
<SelectItem value={BusinessSector.EUREST}>Eurest</SelectItem>
<SelectItem value={BusinessSector.ARAMARK}>Aramark</SelectItem>
<SelectItem value={BusinessSector.EPICUREAN_GROUP}>Epicurean Group</SelectItem>
<SelectItem value={BusinessSector.CHARTWELLS}>Chartwells</SelectItem>
<SelectItem value={BusinessSector.OTHER}>Other</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Billing Information */}
<Card className="border-border/50 shadow-sm">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<MapPin className="w-5 h-5 text-primary" />
Billing Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="address">Billing Address *</Label>
<Input
id="address"
value={formData.address}
onChange={(e) => handleChange('address', e.target.value)}
required
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="city">City *</Label>
<Input
id="city"
value={formData.city}
onChange={(e) => handleChange('city', e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="area">Area</Label>
<Select
value={formData.area}
onValueChange={(value: BusinessArea) => handleChange('area', value)}
>
<SelectTrigger>
<SelectValue placeholder="Select area" />
</SelectTrigger>
<SelectContent>
<SelectItem value={BusinessArea.BAY_AREA}>Bay Area</SelectItem>
<SelectItem value={BusinessArea.SOUTHERN_CALIFORNIA}>Southern California</SelectItem>
<SelectItem value={BusinessArea.NORTHERN_CALIFORNIA}>Northern California</SelectItem>
<SelectItem value={BusinessArea.CENTRAL_VALLEY}>Central Valley</SelectItem>
<SelectItem value={BusinessArea.OTHER}>Other</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Primary Contact */}
<Card className="border-border/50 shadow-sm">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<User className="w-5 h-5 text-primary" />
Primary Contact
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="contactName">Contact Name *</Label>
<Input
id="contactName"
value={formData.contactName}
onChange={(e) => handleChange('contactName', e.target.value)}
required
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="phone">Phone Number</Label>
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => handleChange('phone', e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email *</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => handleChange('email', e.target.value)}
required
/>
</div>
</div>
</CardContent>
</Card>
{/* Rate Configuration & Status */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card className="border-border/50 shadow-sm">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<CreditCard className="w-5 h-5 text-primary" />
Rate Configuration
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="rateGroup">Rate Group *</Label>
<Select
value={formData.rateGroup}
onValueChange={(value: BusinessRateGroup) => handleChange('rateGroup', value)}
>
<SelectTrigger>
<SelectValue placeholder="Select rate group" />
</SelectTrigger>
<SelectContent>
<SelectItem value={BusinessRateGroup.STANDARD}>Standard</SelectItem>
<SelectItem value={BusinessRateGroup.PREMIUM}>Premium</SelectItem>
<SelectItem value={BusinessRateGroup.ENTERPRISE}>Enterprise</SelectItem>
<SelectItem value={BusinessRateGroup.CUSTOM}>Custom</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
<Card className="border-border/50 shadow-sm">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Activity className="w-5 h-5 text-primary" />
Partnership Status
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="status">Client Status *</Label>
<Select
value={formData.status}
onValueChange={(value: BusinessStatus) => handleChange('status', value)}
>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value={BusinessStatus.ACTIVE}>Active</SelectItem>
<SelectItem value={BusinessStatus.PENDING}>Pending</SelectItem>
<SelectItem value={BusinessStatus.INACTIVE}>Inactive</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
</div>
{/* Notes */}
<Card className="border-border/50 shadow-sm">
<CardHeader>
<CardTitle className="text-lg">Notes</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="notes">Additional Notes</Label>
<Textarea
id="notes"
value={formData.notes}
onChange={(e) => handleChange('notes', e.target.value)}
rows={4}
placeholder="Internal notes about this partnership..."
/>
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={() => navigate("/clients")}
>
Cancel
</Button>
<Button
type="submit"
disabled={isUpdating}
leadingIcon={isUpdating ? <Loader2 className="animate-spin" /> : <Save />}
>
{isUpdating ? "Saving Changes..." : "Save Client Details"}
</Button>
</div>
</form>
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,823 @@
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 { useToast } from "@/common/components/ui/use-toast";
import DashboardLayout from "@/features/layouts/DashboardLayout";
import { useQueryClient } from "@tanstack/react-query";
import {
BarChart3,
Briefcase,
DollarSign,
Download,
FileText,
Filter,
MapPin,
Pencil,
Plus,
Search,
Shield,
Sparkles,
Trash2
} from "lucide-react";
import { useMemo, useState } from "react";
import { useSelector } from "react-redux";
import {
useListVendorRates,
useListCustomRateCards,
useCreateCustomRateCard,
useUpdateCustomRateCard,
useDeleteCustomRateCard,
useCreateVendorRate,
useUpdateVendorRate,
useDeleteVendorRate,
useGetVendorByUserId
} from "@/dataconnect-generated/react";
import RateCardModal from "./components/RateCardModal";
// --- Constants & Helper Functions ---
function fmtCurrency(v: number | undefined | null) {
if (typeof v !== "number" || Number.isNaN(v)) return "—";
return v.toLocaleString(undefined, { style: "currency", currency: "USD" });
}
function downloadCSV(rows: any[], regionName: string, vendorName: string) {
const headers = [
"Role",
"Category",
"Employee Wage",
"Markup %",
"Vendor Fee %",
"Client Rate",
];
const lines = [headers.join(",")];
for (const r of rows) {
const cells = [
r.role_name,
r.category,
r.employee_wage,
r.markup_percentage,
r.vendor_fee_percentage,
r.client_rate,
];
lines.push(cells.join(","));
}
const blob = new Blob([lines.join("\n")], {
type: "text/csv;charset=utf-8;",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${vendorName}_${regionName}_Rates_${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
}
const parseRoleName = (roleName: string) => {
if (!roleName) return { position: "", region: "" };
if (roleName.includes(" - ")) {
const parts = roleName.split(" - ");
return {
position: parts[0].trim(),
region: parts[1].trim(),
};
}
return {
position: roleName,
region: "",
};
};
// --- Sub-Components ---
function VendorCompanyPricebookView({
vendorName,
}: {
vendorName: string;
}) {
const { toast } = useToast();
const queryClient = useQueryClient();
const { data: vendorRatesData } = useListVendorRates();
const vendorRates = vendorRatesData?.vendorRates || [];
const { data: customRateCardsData } = useListCustomRateCards();
const customRateCards = customRateCardsData?.customRateCards || [];
const { mutate: createCustomRateCard } = useCreateCustomRateCard();
const { mutate: updateCustomRateCard } = useUpdateCustomRateCard();
const { mutate: deleteCustomRateCard } = useDeleteCustomRateCard();
const { mutate: createVendorRate } = useCreateVendorRate();
const { mutate: updateVendorRate } = useUpdateVendorRate();
const { mutate: deleteVendorRate } = useDeleteVendorRate();
const handleUpdateVendorRate = (vars: any) => {
updateVendorRate(vars, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["listVendorRates"] });
}
});
};
const [pricebook, setPricebook] = useState("Standard");
const [search, setSearch] = useState("");
const [activeRegion, setActiveRegion] = useState("All");
const [activeCategory, setActiveCategory] = useState("All");
const [editing, setEditing] = useState<string | null>(null);
const [analyzingCompetitiveness, setAnalyzingCompetitiveness] =
useState(false);
const [competitivenessData, setCompetitivenessData] = useState<any[] | null>(
null,
);
const [showRateCardModal, setShowRateCardModal] = useState(false);
const [editingRateCard, setEditingRateCard] = useState<any | null>(null);
const [renamingCard, setRenamingCard] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const RATE_CARDS = customRateCards.map((c: any) => c.name);
const rates = useMemo(() => {
return vendorRates.filter((r) => r.vendor?.companyName === vendorName && r.isActive);
}, [vendorRates, vendorName]);
const CATEGORIES = useMemo(() => {
const cats = new Set(vendorRates.map(r => r.category).filter(Boolean));
return Array.from(cats);
}, [vendorRates]);
const handleSaveRateCard = (cardData: any) => {
if (editingRateCard) {
updateCustomRateCard({
id: editingRateCard.id,
name: cardData.name,
baseBook: cardData.baseBook,
discount: cardData.discount,
isDefault: editingRateCard.isDefault
}, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["listCustomRateCards"] });
toast({ title: "Rate card updated successfully" });
}
});
} else {
createCustomRateCard({
name: cardData.name,
baseBook: cardData.baseBook,
discount: cardData.discount,
isDefault: false
}, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["listCustomRateCards"] });
toast({ title: "Rate card saved successfully" });
}
});
}
setPricebook(cardData.name);
setEditingRateCard(null);
};
const handleDeleteRateCard = (cardId: string, cardName: string) => {
if (customRateCards.length <= 1) {
toast({
title: "Cannot delete the last rate card",
variant: "destructive",
});
return;
}
deleteCustomRateCard({ id: cardId }, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["listCustomRateCards"] });
toast({ title: "Rate card deleted" });
if (pricebook === cardName) {
const nextCard = customRateCards.find((c: any) => c.id !== cardId);
setPricebook(nextCard ? nextCard.name : "Standard");
}
}
});
};
const handleRenameCard = (cardId: string, oldName: string) => {
if (!renameValue.trim() || renameValue === oldName) {
setRenamingCard(null);
return;
}
const currentCard = customRateCards.find((c: any) => c.id === cardId);
if (currentCard) {
updateCustomRateCard({
id: cardId,
name: renameValue.trim(),
baseBook: currentCard.baseBook,
discount: currentCard.discount,
isDefault: currentCard.isDefault
}, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["listCustomRateCards"] });
if (pricebook === oldName) {
setPricebook(renameValue.trim());
}
}
});
}
setRenamingCard(null);
};
const scopedByBook = useMemo(() => {
const isCustomRateCard = customRateCards.find(c => c.name === pricebook);
const discount = isCustomRateCard?.discount || 0;
return rates.map((r: any) => {
const parsed = parseRoleName(r.roleName || "");
const position = parsed.position;
// Apply discount if it's a custom rate card
const proposedRate = r.clientRate * (1 - discount / 100);
const assignedRegion =
r.vendor?.region ||
parsed.region ||
(r.notes?.includes("Bay Area") ? "Bay Area" : "LA");
return {
...r,
client: pricebook,
region: assignedRegion,
approvedCap: r.clientRate,
proposedRate: proposedRate,
position: r.roleName,
markupPct: r.markupPercentage,
volDiscountPct: r.vendorFeePercentage,
overtime8Multiplier: r.overtime8Multiplier || 1.5,
overtime12Multiplier: r.overtime12Multiplier || 2.0,
holidayRate: r.holidayRate || proposedRate * 1.5
};
});
}, [rates, pricebook, customRateCards]);
const APPROVED_RATES = useMemo(() => {
return customRateCards.filter(c => c.isDefault).map(c => c.name);
}, [customRateCards]);
const isApprovedRate = APPROVED_RATES.includes(pricebook) || pricebook === "Standard";
const filtered = useMemo(() => {
return (scopedByBook as any[]).filter((r) => {
const regionMatch =
!isApprovedRate ||
activeRegion === "All" ||
r.region === activeRegion ||
parseRoleName(r.position).region === activeRegion;
const categoryMatch =
activeCategory === "All" || r.category === activeCategory;
const searchMatch =
search.trim() === "" ||
parseRoleName(r.position)
.position.toLowerCase()
.includes(search.toLowerCase());
return regionMatch && categoryMatch && searchMatch;
});
}, [scopedByBook, activeRegion, activeCategory, search, isApprovedRate]);
const kpis = useMemo(() => {
const rateValues = filtered.map((r) => r.proposedRate);
const avg = rateValues.length
? rateValues.reduce((a, b) => a + b, 0) / rateValues.length
: 0;
const min = rateValues.length ? Math.min(...rateValues) : 0;
const max = rateValues.length ? Math.max(...rateValues) : 0;
const total = filtered.length;
return { avg, min, max, total };
}, [filtered]);
async function analyzeCompetitiveness() {
setAnalyzingCompetitiveness(true);
try {
await new Promise((resolve) => setTimeout(resolve, 1500));
const mockAnalysis = filtered.slice(0, 20).map((r) => ({
position: parseRoleName(r.position).position,
marketRate: r.proposedRate * (0.9 + Math.random() * 0.2),
score: Math.floor(60 + Math.random() * 40),
status: ["Highly Competitive", "Competitive", "Average", "Above Market"][
Math.floor(Math.random() * 4)
],
recommendation: "Consider slight adjustment.",
}));
setCompetitivenessData(mockAnalysis);
toast({ title: "Competitive analysis complete" });
} catch (error) {
console.error("Analysis error:", error);
toast({ title: "Analysis failed. Try again.", variant: "destructive" });
} finally {
setAnalyzingCompetitiveness(false);
}
}
return (
<DashboardLayout
title="Service Rate Management"
subtitle={`20252028 Pricing Structure for ${vendorName}`}
actions={
<>
<Button
onClick={analyzeCompetitiveness}
disabled={analyzingCompetitiveness}
className="bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm"
>
{analyzingCompetitiveness ? (
<>
<div className="w-4 h-4 mr-2 border-2 border-white border-t-transparent rounded-full animate-spin" />
Analyzing...
</>
) : (
<>
<Shield className="w-4 h-4 mr-2" />
AI Price Check
</>
)}
</Button>
<Button
onClick={() => downloadCSV(filtered, activeRegion, vendorName)}
variant="outline"
className="border-dashed border-border"
>
<Download className="w-4 h-4 mr-2" />
Export CSV
</Button>
</>
}
>
<div className="space-y-6">
{/* AI Analysis Result Block - Styled like Dispute Alert */}
{competitivenessData && competitivenessData.length > 0 && (
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-4 mb-6">
<div className="flex items-start gap-4">
<div className="w-10 h-10 bg-emerald-100 rounded-full flex items-center justify-center flex-shrink-0 shadow-sm">
<Sparkles className="w-5 h-5 text-emerald-600" />
</div>
<div className="flex-1">
<div className="flex justify-between items-start">
<div>
<h3 className="text-lg font-bold text-emerald-900">Market Intelligence Analysis Complete</h3>
<p className="text-sm text-emerald-700 mt-1">
Results based on current market data for {pricebook}.
</p>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => setCompetitivenessData(null)}
className="text-emerald-700 hover:bg-emerald-100 -mt-1 -mr-2"
>
Dismiss
</Button>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
{[{ "status": "Highly Competitive", "color": "bg-emerald-500", "textColor": "text-emerald-700" }, { "status": "Competitive", "color": "bg-blue-500", "textColor": "text-blue-700" }, { "status": "Average", "color": "bg-yellow-500", "textColor": "text-yellow-700" }, { "status": "Above Market", "color": "bg-red-500", "textColor": "text-red-700" }].map(({ status, color, textColor }) => {
const count = competitivenessData.filter((d) => d.status === status).length;
return (
<div key={status} className="bg-white/60 rounded-lg p-3 border border-emerald-200/50">
<div className="flex items-center gap-2 mb-1">
<div className={`w-2 h-2 rounded-full ${color}`} />
<span className={`text-xs font-bold uppercase ${textColor}`}>{status}</span>
</div>
<p className="text-2xl font-bold text-emerald-900">{count}</p>
</div>
);
})}
</div>
</div>
</div>
</div>
)}
{/* Rate Books Section Header */}
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center">
<FilesIcon className="w-6 h-6 text-primary" />
</div>
<div>
<h2 className="text-xl font-bold text-primary-text">Rate Books</h2>
<p className="text-secondary-text text-sm">Manage standard and custom client pricebooks</p>
</div>
</div>
{/* Book Selector Grid - Using bg-muted/20 like Client Selection */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Approved Enterprise Rates */}
<div className="bg-muted/10 p-4 rounded-xl border border-border/30">
<div className="flex items-center gap-3 mb-4 pb-3 border-b border-border/30">
<div className="w-8 h-8 bg-amber-500/10 rounded-lg flex items-center justify-center">
<Shield className="w-4 h-4 text-amber-600" />
</div>
<div>
<h3 className="text-sm font-bold text-primary-text uppercase tracking-wider">
Enterprise Books
</h3>
</div>
</div>
<div className="flex flex-wrap gap-2">
{APPROVED_RATES.map((tab) => (
<button
key={tab}
onClick={() => {
setPricebook(tab);
setActiveRegion("All");
}}
className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all ${pricebook === tab
? "bg-white shadow-sm text-amber-700 border-amber-200 border"
: "bg-muted/50 text-secondary-text hover:bg-muted"
}`}
>
{tab}
</button>
))}
</div>
</div>
{/* Custom Rate Cards */}
<div className="bg-muted/10 p-4 rounded-xl border border-border/30">
<div className="flex items-center justify-between mb-4 pb-3 border-b border-border/30">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-500/10 rounded-lg flex items-center justify-center">
<Briefcase className="w-4 h-4 text-blue-600" />
</div>
<div>
<h3 className="text-sm font-bold text-primary-text uppercase tracking-wider">
Custom Cards
</h3>
</div>
</div>
<Button
onClick={() => {
setEditingRateCard(null);
setShowRateCardModal(true);
}}
size="sm"
variant="ghost"
className="h-7 text-xs hover:bg-primary/5 text-primary"
>
<Plus className="w-3.5 h-3.5 mr-1" /> New
</Button>
</div>
<div className="flex flex-wrap gap-2">
{RATE_CARDS.map((tab) => {
const cardData = customRateCards.find((c) => c.name === tab);
const isRenaming = renamingCard === tab;
if (isRenaming) {
return (
<div key={tab} className="flex items-center gap-1">
<input
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => handleRenameCard(cardData.id, tab)}
onKeyDown={(e) => {
if (e.key === "Enter") handleRenameCard(cardData.id, tab);
if (e.key === "Escape") setRenamingCard(null);
}}
autoFocus
className="px-3 py-2 rounded-lg text-sm font-semibold border border-primary bg-white focus:outline-none w-40"
/>
</div>
);
}
return (
<div key={tab} className="relative group">
<button
onClick={() => setPricebook(tab)}
onDoubleClick={(e) => {
e.stopPropagation();
setRenamingCard(tab);
setRenameValue(tab);
}}
className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all flex items-center gap-2 ${pricebook === tab
? "bg-white shadow-sm text-blue-700 border-blue-200 border"
: "bg-muted/50 text-secondary-text hover:bg-muted"
}`}
title="Double-click to rename"
>
{tab}
{cardData?.discount && cardData.discount > 0 && (
<span className="text-xs opacity-80 px-1 py-0.5 bg-green-500/20 rounded">
-{cardData.discount}%
</span>
)}
</button>
{/* Hover actions simplified */}
<div className="absolute -top-1 -right-1 hidden group-hover:flex">
<button onClick={(e) => { e.stopPropagation(); handleDeleteRateCard(cardData.id, tab); }} className="p-1 bg-white rounded-full border border-red-200 text-red-500 hover:bg-red-50 shadow-sm"><Trash2 className="w-2 h-2" /></button>
</div>
</div>
);
})}
</div>
</div>
</div>
{/* KPIs Section */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card className="border-border/50 shadow-sm">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<DollarSign className="w-4 h-4 text-emerald-600" />
<h3 className="font-semibold text-primary-text text-sm">Average Rate</h3>
</div>
<div className="space-y-1">
<p className="text-2xl font-bold text-primary-text">{fmtCurrency(kpis.avg)}</p>
<p className="text-xs text-secondary-text">Across {kpis.total} positions active in book</p>
</div>
</CardContent>
</Card>
<Card className="border-border/50 shadow-sm">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<BarChart3 className="w-4 h-4 text-blue-600" />
<h3 className="font-semibold text-primary-text text-sm">Coverage</h3>
</div>
<div className="space-y-1">
<p className="text-2xl font-bold text-primary-text">{kpis.total} <span className="text-sm font-normal text-secondary-text">roles</span></p>
<p className="text-xs text-secondary-text">Total defined positions</p>
</div>
</CardContent>
</Card>
<Card className="border-border/50 shadow-sm">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<MapPin className="w-4 h-4 text-purple-600" />
<h3 className="font-semibold text-primary-text text-sm">Spread</h3>
</div>
<div className="space-y-1">
<p className="text-2xl font-bold text-primary-text">{fmtCurrency(kpis.min)} {fmtCurrency(kpis.max)}</p>
<p className="text-xs text-secondary-text">Rate range (Min - Max)</p>
</div>
</CardContent>
</Card>
</div>
{/* Filters - styled like the Search/Filter blocks */}
<div className="bg-muted/5 p-4 rounded-xl border border-border/50 flex flex-col md:flex-row gap-4 items-center justify-between">
<div className="flex items-center gap-4 flex-1 w-full overflow-x-auto">
<div className="flex items-center gap-2 text-secondary-text min-w-fit">
<Filter className="w-4 h-4" />
<span className="text-sm font-semibold uppercase tracking-wider">Filters</span>
</div>
{/* Categories */}
<div className="flex gap-2">
{["All", ...CATEGORIES].map((cat) => (
<button
key={cat}
onClick={() => setActiveCategory(cat)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all whitespace-nowrap ${activeCategory === cat
? "bg-primary text-primary-foreground shadow-sm"
: "bg-white border border-border/50 text-secondary-text hover:bg-muted"
}`}
>
{cat}
</button>
))}
</div>
</div>
<div className="relative w-full md:w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search positions..."
className="pl-9 h-9 bg-white border-border/50"
/>
</div>
</div>
{/* Main Rates Table */}
<div>
{/* Section Header */}
<div className="flex items-center justify-between mb-4 px-1">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center border border-border/30 shadow-sm">
<span className="text-lg">💰</span>
</div>
<div>
<h3 className="font-bold text-primary-text">Rate Breakdown</h3>
<p className="text-xs text-muted-text">Detailed pricing for {pricebook}</p>
</div>
</div>
</div>
<Card className="border-border/50 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-muted/30 border-b border-border/50">
<tr className="text-muted-text text-[10px] font-bold uppercase tracking-wider">
<th className="px-6 py-4">Position</th>
<th className="px-6 py-4">Category</th>
<th className="px-6 py-4">Region</th>
<th className="px-6 py-4">Base Wage</th>
<th className="px-6 py-4">{pricebook} Rate</th>
<th className="px-6 py-4">OT 8h</th>
<th className="px-6 py-4">OT 12h</th>
<th className="px-6 py-4">Holiday</th>
<th className="px-6 py-4">Market Rate</th>
<th className="px-6 py-4">Comp. Score</th>
<th className="px-6 py-4 text-center">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border/20">
{filtered.map((r: any, idx) => {
const parsed = parseRoleName(r.position);
const competitive = competitivenessData?.find(
(c) => c.position === parsed.position,
);
const isEditing = editing === r.id;
return (
<tr
key={r.id || idx}
className={`transition-all hover:bg-muted/10 bg-card`}
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
{/* Icon for position */}
<div className="w-8 h-8 rounded-lg bg-primary/5 flex items-center justify-center text-primary font-bold text-xs border border-primary/10">
{parsed.position.substring(0, 2).toUpperCase()}
</div>
<p className="font-bold text-primary-text text-sm">{parsed.position}</p>
</div>
</td>
<td className="px-6 py-4">
<Badge
variant="outline"
className="bg-muted/30 text-secondary-text border-border/50 text-[10px] font-bold px-2 py-0.5"
>
{r.category}
</Badge>
</td>
<td className="px-6 py-4">
<span className="text-xs text-secondary-text flex items-center gap-1 font-medium">
{r.region !== '—' && <MapPin className="w-3 h-3 text-muted-text" />}
{r.region}
</span>
</td>
<td className="px-6 py-4">
<span className="text-sm font-medium text-secondary-text">
{fmtCurrency(r.employee_wage)}
</span>
</td>
<td className="px-6 py-4">
{isEditing ? (
<input
type="number"
step="0.01"
defaultValue={r.proposedRate}
className="w-20 px-2 py-1 border border-primary rounded-md font-bold text-primary focus:outline-none text-xs"
onBlur={(e) => {
handleUpdateVendorRate({
id: r.id,
clientRate: parseFloat(e.target.value)
});
setEditing(null);
toast({ title: "Rate updated successfully" });
}}
autoFocus
/>
) : (
<span className="text-sm font-bold text-primary-text font-mono">
{fmtCurrency(r.proposedRate)}
</span>
)}
</td>
<td className="px-6 py-4">
{isEditing ? (
<input
type="number"
step="0.1"
defaultValue={r.overtime8Multiplier}
className="w-16 px-2 py-1 border border-primary rounded-md font-medium text-primary focus:outline-none text-xs"
onBlur={(e) => {
handleUpdateVendorRate({
id: r.id,
overtime8Multiplier: parseFloat(e.target.value)
});
}}
/>
) : (
<span className="text-xs font-medium text-secondary-text">{r.overtime8Multiplier}x</span>
)}
</td>
<td className="px-6 py-4">
{isEditing ? (
<input
type="number"
step="0.1"
defaultValue={r.overtime12Multiplier}
className="w-16 px-2 py-1 border border-primary rounded-md font-medium text-primary focus:outline-none text-xs"
onBlur={(e) => {
handleUpdateVendorRate({
id: r.id,
overtime12Multiplier: parseFloat(e.target.value)
});
}}
/>
) : (
<span className="text-xs font-medium text-secondary-text">{r.overtime12Multiplier}x</span>
)}
</td>
<td className="px-6 py-4">
{isEditing ? (
<input
type="number"
step="0.01"
defaultValue={r.holidayRate}
className="w-20 px-2 py-1 border border-primary rounded-md font-medium text-primary focus:outline-none text-xs"
onBlur={(e) => {
handleUpdateVendorRate({
id: r.id,
holidayRate: parseFloat(e.target.value)
});
}}
/>
) : (
<span className="text-xs font-medium text-secondary-text">{fmtCurrency(r.holidayRate)}</span>
)}
</td>
<td className="px-6 py-4">
{competitive ? (
<div className="flex flex-col">
<span className="text-xs font-medium text-primary-text">{fmtCurrency(competitive.marketRate)}</span>
<span className={`text-[10px] font-bold ${r.proposedRate < competitive.marketRate ? "text-emerald-600" : "text-red-600"}`}>
{r.proposedRate < competitive.marketRate ? "▼ Below" : "▲ Above"}
</span>
</div>
) : <span className="text-muted-text text-sm"></span>}
</td>
<td className="px-6 py-4">
{competitive ? (
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${competitive.status === "Highly Competitive" ? "bg-emerald-500" : "bg-blue-500"}`} />
<span className="text-xs font-medium text-primary-text">{competitive.score}/100</span>
</div>
) : <span className="text-muted-text text-sm"></span>}
</td>
<td className="px-6 py-4 text-center">
<Button
variant="ghost"
size="sm"
onClick={() => setEditing(isEditing ? null : r.id)}
className={`${isEditing
? "bg-emerald-50 text-emerald-600 hover:bg-emerald-100"
: "text-muted-text hover:text-primary hover:bg-primary/5"
} h-8 w-8 p-0`}
>
<Pencil className="w-3.5 h-3.5" />
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
</div>
<RateCardModal
isOpen={showRateCardModal}
onClose={() => {
setShowRateCardModal(false);
setEditingRateCard(null);
}}
onSave={handleSaveRateCard}
editingCard={editingRateCard}
/>
</div>
</DashboardLayout>
);
}
// NOTE: Creating a dummy FilesIcon component to suppress errors if it's missing, or assumelucide import
const FilesIcon = FileText;
// The user asked to match InvoiceEditor design. InvoiceEditor is primarily a form/management view.
// I will ensure the VendorCompanyPricebookView is the primary export and fully fleshed out.
export default function ServiceRates() {
const { user } = useSelector((state: any) => state.auth);
const { data: vendorData } = useGetVendorByUserId({ userId: user?.uid || "" }, { enabled: !!user?.uid });
const vendorName = vendorData?.vendors[0]?.companyName || "Vendor";
return <VendorCompanyPricebookView vendorName={vendorName} />;
}

View File

@@ -0,0 +1,143 @@
import { Badge } from "@/common/components/ui/badge";
import { Button } from "@/common/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/common/components/ui/dialog";
import { Input } from "@/common/components/ui/input";
import { Label } from "@/common/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/common/components/ui/select";
import { useEffect, useState } from "react";
import { Briefcase } from "lucide-react";
interface RateCardModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (cardData: any) => void;
editingCard?: any;
}
export default function RateCardModal({
isOpen,
onClose,
onSave,
editingCard,
}: RateCardModalProps) {
const [formData, setFormData] = useState({
name: "",
baseBook: "FoodBuy",
discount: 0,
});
useEffect(() => {
if (editingCard) {
setFormData({
name: editingCard.name,
baseBook: editingCard.baseBook,
discount: editingCard.discount,
});
} else {
setFormData({
name: "",
baseBook: "FoodBuy",
discount: 0,
});
}
}, [editingCard, isOpen]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave(formData);
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[425px] border-border/50 shadow-lg bg-card text-primary-text">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-primary-text">
<Briefcase className="w-5 h-5 text-primary" />
{editingCard ? "Edit Rate Card" : "New Rate Card"}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-5 py-4">
<div className="space-y-2">
<Label htmlFor="name" className="text-secondary-text text-xs uppercase font-bold tracking-wider">Rate Card Name</Label>
<Input
id="name"
placeholder="e.g. VIP Client 2025"
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
required
className="bg-card border-border/50 focus:border-primary"
/>
</div>
<div className="space-y-2">
<Label htmlFor="baseBook" className="text-secondary-text text-xs uppercase font-bold tracking-wider">Base Pricebook</Label>
<Select
value={formData.baseBook}
onValueChange={(value: string) =>
setFormData({ ...formData, baseBook: value })
}
>
<SelectTrigger className="bg-card border-border/50 focus:border-primary">
<SelectValue placeholder="Select base book" />
</SelectTrigger>
<SelectContent>
<SelectItem value="FoodBuy">FoodBuy Enterprise</SelectItem>
<SelectItem value="Aramark">Aramark Standard</SelectItem>
</SelectContent>
</Select>
<p className="text-[10px] text-muted-text">
Starting rates will be pulled from this master book
</p>
</div>
<div className="space-y-2">
<Label htmlFor="discount" className="text-secondary-text text-xs uppercase font-bold tracking-wider">Volume Discount (%)</Label>
<div className="flex items-center gap-3">
<Input
id="discount"
type="number"
min="0"
max="100"
step="0.1"
value={formData.discount}
onChange={(e) =>
setFormData({
...formData,
discount: parseFloat(e.target.value),
})
}
className="w-24 bg-card border-border/50 focus:border-primary font-mono text-right"
/>
<Badge variant="outline" className="text-[10px] font-bold text-emerald-600 bg-emerald-50 border-emerald-200">
Applied to all rates
</Badge>
</div>
</div>
</form>
<DialogFooter>
<Button variant="outline" onClick={onClose} className="border-border/50 text-secondary-text hover:text-primary-text">
Cancel
</Button>
<Button onClick={handleSubmit} type="submit" className="bg-primary text-primary-foreground hover:bg-primary/90">
{editingCard ? "Save Changes" : "Create Card"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,320 @@
import { useState, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { format, parseISO, isValid } from "date-fns";
import {
Search, MapPin, FileText,
Clock, Package, CheckCircle, Check, ChevronsUpDown,
Plus
} from "lucide-react";
import { Card, CardContent } from "@/common/components/ui/card";
import { Badge } from "@/common/components/ui/badge";
import { Button } from "@/common/components/ui/button";
import { Input } from "@/common/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/common/components/ui/table";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/common/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/common/components/ui/command";
import DashboardLayout from "@/features/layouts/DashboardLayout";
import { useSelector } from "react-redux";
import type { RootState } from "@/store/store";
import { useGetBusinessesByUserId, useGetOrdersByBusinessId } from "@/dataconnect-generated/react";
import { OrderStatus } from "@/dataconnect-generated";
import { dataConnect } from "@/features/auth/firebase";
const safeParseDate = (dateString: any): Date | null => {
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 ClientOrderList() {
const navigate = useNavigate();
const { user } = useSelector((state: RootState) => state.auth);
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [locationFilter, setLocationFilter] = useState("all");
const [locationOpen, setLocationOpen] = useState(false);
// 1. Get businesses for the logged in user
const { data: businessData } = useGetBusinessesByUserId(dataConnect, { userId: user?.uid || "" });
const businesses = businessData?.businesses || [];
const primaryBusinessId = businesses[0]?.id;
// 2. Get orders for the primary business
const { data: orderData, isLoading } = useGetOrdersByBusinessId(dataConnect, {
businessId: primaryBusinessId || ""
}, {
enabled: !!primaryBusinessId
});
const orders = orderData?.orders || [];
const filteredOrders = useMemo(() => {
let filtered = [...orders];
if (searchTerm) {
const lower = searchTerm.toLowerCase();
filtered = filtered.filter(o =>
o.eventName?.toLowerCase().includes(lower) ||
o.business.businessName.toLowerCase().includes(lower)
);
}
if (statusFilter !== "all") {
filtered = filtered.filter(o => o.status === statusFilter);
}
if (locationFilter !== "all") {
filtered = filtered.filter(o => o.business.businessName === locationFilter);
}
return filtered;
}, [orders, searchTerm, statusFilter, locationFilter]);
const uniqueLocations = useMemo(() => {
const locations = new Set<string>();
orders.forEach(o => {
const businessName = o.business.businessName;
if (businessName) locations.add(businessName);
});
return Array.from(locations).sort();
}, [orders]);
const stats = useMemo(() => {
const total = orders.length;
const active = orders.filter(o => o.status !== OrderStatus.COMPLETED && o.status !== OrderStatus.CANCELLED).length;
const completed = orders.filter(o => o.status === OrderStatus.COMPLETED).length;
const filled = orders.filter(o => o.status === OrderStatus.FILLED || o.status === OrderStatus.FULLY_STAFFED).length;
return { total, active, completed, filled };
}, [orders]);
const getFillRate = (order: any) => {
const requested = order.requested || 0;
const assigned = Array.isArray(order.assignedStaff) ? order.assignedStaff.length : 0;
if (requested === 0) return 0;
return Math.round((assigned / requested) * 100);
};
const getStatusBadge = (status: OrderStatus) => {
switch (status) {
case OrderStatus.COMPLETED:
return <Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 border-none">Completed</Badge>;
case OrderStatus.CANCELLED:
return <Badge className="bg-red-100 text-red-700 hover:bg-red-100 border-none">Cancelled</Badge>;
case OrderStatus.FULLY_STAFFED:
case OrderStatus.FILLED:
return <Badge className="bg-blue-100 text-blue-700 hover:bg-blue-100 border-none">Filled</Badge>;
case OrderStatus.PARTIAL_STAFFED:
return <Badge className="bg-amber-100 text-amber-700 hover:bg-amber-100 border-none">Partial</Badge>;
case OrderStatus.POSTED:
return <Badge className="bg-purple-100 text-purple-700 hover:bg-purple-100 border-none">Posted</Badge>;
default:
return <Badge className="bg-slate-100 text-slate-700 hover:bg-slate-100 border-none">{status}</Badge>;
}
};
return (
<DashboardLayout title="My Orders" subtitle="Manage and track your staffing requests">
<div className="space-y-6">
{/* Stats Section */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="bg-card border-border/50 shadow-sm">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-muted-foreground text-xs font-medium mb-1 uppercase tracking-wider">Total Orders</p>
<p className="text-2xl font-bold">{stats.total}</p>
</div>
<div className="w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center">
<Package className="w-5 h-5 text-primary" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-card border-border/50 shadow-sm">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-muted-foreground text-xs font-medium mb-1 uppercase tracking-wider">Active</p>
<p className="text-2xl font-bold">{stats.active}</p>
</div>
<div className="w-10 h-10 bg-amber-500/10 rounded-lg flex items-center justify-center">
<Clock className="w-5 h-5 text-amber-600" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-card border-border/50 shadow-sm">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-muted-foreground text-xs font-medium mb-1 uppercase tracking-wider">Filled</p>
<p className="text-2xl font-bold">{stats.filled}</p>
</div>
<div className="w-10 h-10 bg-blue-500/10 rounded-lg flex items-center justify-center">
<CheckCircle className="w-5 h-5 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-card border-border/50 shadow-sm">
<CardContent className="p-6 text-center flex flex-col items-center justify-center bg-primary text-primary-foreground hover:bg-primary/90 cursor-pointer transition-colors" onClick={() => navigate('/orders/create')}>
<Plus className="w-6 h-6 mb-1" />
<p className="font-bold">Create New Order</p>
</CardContent>
</Card>
</div>
{/* Filters and Search */}
<div className="flex flex-col md:flex-row items-center gap-4">
<div className="relative flex-1 w-full">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search orders..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9"
/>
</div>
<div className="flex items-center gap-2 w-full md:w-auto">
<Popover open={locationOpen} onOpenChange={setLocationOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full md:w-[200px] justify-between">
{locationFilter === "all" ? "All Locations" : locationFilter}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search location..." />
<CommandEmpty>No location found.</CommandEmpty>
<CommandGroup>
<CommandItem onSelect={() => { setLocationFilter("all"); setLocationOpen(false); }}>
<Check className={`mr-2 h-4 w-4 ${locationFilter === "all" ? "opacity-100" : "opacity-0"}`} />
All Locations
</CommandItem>
{uniqueLocations.map((loc) => (
<CommandItem key={loc} onSelect={() => { setLocationFilter(loc); setLocationOpen(false); }}>
<Check className={`mr-2 h-4 w-4 ${locationFilter === loc ? "opacity-100" : "opacity-0"}`} />
{loc}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<Button
variant="outline"
onClick={() => {
setSearchTerm("");
setStatusFilter("all");
setLocationFilter("all");
}}
>
Reset
</Button>
</div>
</div>
{/* Orders Table */}
<div className="bg-card rounded-xl shadow-sm border border-border/50 overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="w-[120px]">Order #</TableHead>
<TableHead>Event Name</TableHead>
<TableHead>Event Date</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-center">Positions</TableHead>
<TableHead className="text-center">Fill Rate</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-10 text-muted-foreground">
Loading orders...
</TableCell>
</TableRow>
) : filteredOrders.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-10 text-muted-foreground">
No orders found.
</TableCell>
</TableRow>
) : (
filteredOrders.map((order) => {
const eventDate = safeParseDate(order.date);
const fillRate = getFillRate(order);
return (
<TableRow key={order.id} className="cursor-pointer hover:bg-muted/30" onClick={() => navigate(`/orders/${order.id}`)}>
<TableCell className="font-mono text-xs text-muted-foreground uppercase">
{order.id.substring(0, 8)}
</TableCell>
<TableCell className="font-medium">
{order.eventName}
<div className="flex items-center gap-1 text-[10px] text-muted-foreground mt-0.5">
<MapPin className="w-3 h-3" />
{order.business.businessName || "No location"}
</div>
</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="text-sm">{eventDate ? format(eventDate, 'MMM dd, yyyy') : 'No date'}</span>
<span className="text-[10px] text-muted-foreground uppercase">{eventDate ? format(eventDate, 'EEEE') : ''}</span>
</div>
</TableCell>
<TableCell>
{getStatusBadge(order.status)}
</TableCell>
<TableCell className="text-center font-semibold">
{order.requested || 0}
</TableCell>
<TableCell className="text-center">
<div className="flex flex-col items-center gap-1">
<div className="w-16 bg-slate-100 rounded-full h-1.5 overflow-hidden">
<div
className={`h-full rounded-full ${fillRate === 100 ? 'bg-emerald-500' : fillRate > 0 ? 'bg-blue-500' : 'bg-slate-300'}`}
style={{ width: `${fillRate}%` }}
/>
</div>
<span className="text-[10px] font-bold text-muted-foreground">{fillRate}%</span>
</div>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); navigate(`/orders/${order.id}`); }}>
<FileText className="w-4 h-4 mr-1" />
Details
</Button>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,451 @@
import React, { useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { format } from "date-fns";
import { useSelector } from "react-redux";
import { Calendar, MapPin, Users, DollarSign, Edit3, X, Copy, Clock } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/common/components/ui/card";
import { Button } from "@/common/components/ui/button";
import { Badge } from "@/common/components/ui/badge";
import DashboardLayout from "@/features/layouts/DashboardLayout";
import { useGetOrderById, useUpdateOrder } from "@/dataconnect-generated/react";
import { OrderStatus } from "@/dataconnect-generated";
import { dataConnect } from "@/features/auth/firebase";
import { useToast } from "@/common/components/ui/use-toast";
import type { RootState } from "@/store/store";
const safeFormatDate = (value?: string | null): string => {
if (!value) return "—";
try {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return "—";
return format(d, "MMM d, yyyy");
} catch {
return "—";
}
};
const safeFormatDateTime = (value?: string | null): string => {
if (!value) return "—";
try {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return "—";
return format(d, "MMM d, yyyy • h:mm a");
} catch {
return "—";
}
};
const getStatusBadge = (status: OrderStatus) => {
switch (status) {
case OrderStatus.FULLY_STAFFED:
case OrderStatus.FILLED:
return (
<Badge className="bg-emerald-500 hover:bg-emerald-600 text-white border-none font-bold uppercase text-[10px]">
Fully Staffed
</Badge>
);
case OrderStatus.PARTIAL_STAFFED:
return (
<Badge className="bg-orange-500 hover:bg-orange-600 text-white border-none font-bold uppercase text-[10px]">
Partial Staffed
</Badge>
);
case OrderStatus.PENDING:
case OrderStatus.POSTED:
return (
<Badge className="bg-blue-500 hover:bg-blue-600 text-white border-none font-bold uppercase text-[10px]">
{status}
</Badge>
);
case OrderStatus.CANCELLED:
return (
<Badge className="bg-red-500 hover:bg-red-600 text-white border-none font-bold uppercase text-[10px]">
Cancelled
</Badge>
);
case OrderStatus.COMPLETED:
return (
<Badge className="bg-slate-700 hover:bg-slate-800 text-white border-none font-bold uppercase text-[10px]">
Completed
</Badge>
);
case OrderStatus.DRAFT:
default:
return (
<Badge variant="outline" className="font-bold uppercase text-[10px]">
{status}
</Badge>
);
}
};
export default function OrderDetail() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const { toast } = useToast();
const { user } = useSelector((state: RootState) => state.auth);
const {
data,
isLoading,
} = useGetOrderById(
dataConnect,
{ id: id || "" },
{
enabled: !!id,
},
);
const order = data?.order;
const cancelMutation = useUpdateOrder(dataConnect, {
onSuccess: () => {
toast({
title: "Order cancelled",
description: "The order status has been updated to Cancelled.",
});
},
onError: () => {
toast({
title: "Failed to cancel order",
description: "Please try again or contact support.",
variant: "destructive",
});
},
});
const canModify = useMemo(() => {
if (!order) return false;
const status = order.status as OrderStatus;
return status !== OrderStatus.CANCELLED && status !== OrderStatus.COMPLETED;
}, [order]);
const handleCancel = () => {
if (!order || !id || !canModify) return;
cancelMutation.mutate({
id,
status: OrderStatus.CANCELLED,
});
};
const handleEdit = () => {
if (!order || !id) return;
// Placeholder: route can later be wired to an edit form
navigate(`/orders/create?edit=${id}`);
};
const handleDuplicate = () => {
if (!order || !id) return;
// Placeholder: route can later pre-fill a new order from this one
navigate(`/orders/create?duplicate=${id}`);
};
const shifts: any[] = Array.isArray(order?.shifts) ? (order!.shifts as any[]) : [];
const totalRequested = order?.requested ?? 0;
const totalAssigned = Array.isArray(order?.assignedStaff) ? order!.assignedStaff.length : 0;
if (isLoading) {
return (
<DashboardLayout title="Order Detail" subtitle="Loading order details">
<div className="flex items-center justify-center min-h-[40vh]">
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
</div>
</DashboardLayout>
);
}
if (!order) {
return (
<DashboardLayout title="Order Not Found" subtitle="The requested order could not be located">
<div className="flex flex-col items-center justify-center min-h-[40vh] space-y-4">
<p className="text-muted-foreground">This order may have been deleted or the link is invalid.</p>
<Button variant="outline" onClick={() => navigate("/orders")}>
Back to Orders
</Button>
</div>
</DashboardLayout>
);
}
const isClient = user?.userRole === "client";
const clientName = order.business?.businessName || "Unknown client";
const eventDateLabel = safeFormatDate(order.date as string | null);
const locationLabel = order.business?.businessName || "—";
const timelineItems = [
{
label: "Order created",
value: safeFormatDateTime(order.createdAt as string | null),
},
{
label: "Event date",
value: eventDateLabel,
},
{
label: "Current status",
value: (order.status as string) || "—",
},
];
return (
<DashboardLayout
title={order.eventName || "Order Detail"}
subtitle="Detailed view of this order and its shifts"
actions={
<div className="flex items-center gap-3">
{getStatusBadge(order.status as OrderStatus)}
<Button
variant="outline"
size="sm"
onClick={handleEdit}
disabled={!canModify || !isClient}
className="rounded-xl"
>
<Edit3 className="w-4 h-4 mr-2" />
Edit
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDuplicate}
className="rounded-xl"
>
<Copy className="w-4 h-4 mr-2" />
Duplicate
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleCancel}
disabled={!canModify || cancelMutation.isPending}
className="rounded-xl"
>
<X className="w-4 h-4 mr-2" />
{cancelMutation.isPending ? "Cancelling..." : "Cancel Order"}
</Button>
</div>
}
>
<div className="space-y-6">
{/* Header / Key Info */}
<Card className="bg-card border-border/50 shadow-sm">
<CardHeader className="border-b border-border/40">
<CardTitle className="text-lg font-bold text-primary-text">Order Overview</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center border border-primary/20">
<FileTextIcon />
</div>
<div>
<p className="text-xs text-secondary-text font-medium uppercase tracking-wider">
Client Name
</p>
<p className="font-bold text-primary-text">{clientName}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-blue-500/10 rounded-xl flex items-center justify-center border border-blue-500/20">
<Calendar className="w-6 h-6 text-blue-600" />
</div>
<div>
<p className="text-xs text-secondary-text font-medium uppercase tracking-wider">
Event Date
</p>
<p className="font-bold text-primary-text">{eventDateLabel}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-purple-500/10 rounded-xl flex items-center justify-center border border-purple-500/20">
<MapPin className="w-6 h-6 text-purple-600" />
</div>
<div>
<p className="text-xs text-secondary-text font-medium uppercase tracking-wider">
Location
</p>
<p className="font-bold text-primary-text">{locationLabel}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-emerald-500/10 rounded-xl flex items-center justify-center border border-emerald-500/20">
<Users className="w-6 h-6 text-emerald-600" />
</div>
<div>
<p className="text-xs text-secondary-text font-medium uppercase tracking-wider">
Staffed / Requested
</p>
<p className="font-bold text-primary-text">
{totalAssigned} / {totalRequested}
</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Shifts Section */}
<Card className="bg-card border-border/50 shadow-sm">
<CardHeader className="border-b border-border/40">
<CardTitle className="text-lg font-bold text-primary-text">Shifts</CardTitle>
</CardHeader>
<CardContent className="p-6 space-y-4">
{shifts.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-center text-muted-foreground">
<Users className="w-10 h-10 mb-3 text-muted-foreground/40" />
<p className="font-medium">No shifts defined for this order.</p>
<p className="text-sm">
Add shifts when creating or editing the order to see them here.
</p>
</div>
) : (
<div className="space-y-3">
{shifts.map((shift: any, index: number) => {
const start = safeFormatDateTime(shift.startTime || shift.start || shift.date);
const end = safeFormatDateTime(shift.endTime || shift.end);
const title = shift.title || shift.positionName || shift.roleName || `Shift #${index + 1}`;
const workersNeeded = shift.workersNeeded ?? shift.requested ?? 0;
const filled =
typeof shift.filled === "number"
? shift.filled
: Array.isArray(shift.assignedStaff)
? shift.assignedStaff.length
: 0;
const vacancies = Math.max(workersNeeded - filled, 0);
return (
<div
key={index}
className="flex flex-col md:flex-row md:items-center justify-between gap-3 border border-border/60 rounded-xl px-4 py-3 bg-background/40"
>
<div className="space-y-1">
<p className="font-semibold text-primary-text">{title}</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="w-3.5 h-3.5" />
<span>
{start} {end !== "—" && `${end}`}
</span>
</div>
</div>
<div className="flex items-center gap-4 text-sm">
<div className="flex flex-col items-start">
<span className="text-xs text-muted-foreground uppercase tracking-wide">
Required
</span>
<span className="font-semibold">{workersNeeded || "—"}</span>
</div>
<div className="flex flex-col items-start">
<span className="text-xs text-muted-foreground uppercase tracking-wide">
Assigned
</span>
<span className="font-semibold">{filled}</span>
</div>
<div className="flex flex-col items-start">
<span className="text-xs text-muted-foreground uppercase tracking-wide">
Vacancies
</span>
<span className="font-semibold">{vacancies}</span>
</div>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* Timeline Section */}
<Card className="bg-card border-border/50 shadow-sm">
<CardHeader className="border-b border-border/40">
<CardTitle className="text-lg font-bold text-primary-text">
Order Status Timeline
</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="relative pl-4 border-l border-border/70 space-y-5">
{timelineItems.map((item, idx) => (
<div key={idx} className="relative flex flex-col gap-1">
<div className="absolute -left-[9px] top-1 w-4 h-4 rounded-full border-2 border-primary bg-background" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{item.label}
</p>
<p className="text-sm font-medium text-primary-text">{item.value}</p>
</div>
))}
</div>
</CardContent>
</Card>
{/* Financial Summary (optional helper, derived from existing fields) */}
<Card className="bg-card border-border/50 shadow-sm">
<CardHeader className="border-b border-border/40">
<CardTitle className="text-lg font-bold text-primary-text">Summary</CardTitle>
</CardHeader>
<CardContent className="p-6 grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-amber-500/10 rounded-xl flex items-center justify-center border border-amber-500/20">
<DollarSign className="w-6 h-6 text-amber-600" />
</div>
<div>
<p className="text-xs text-secondary-text font-medium uppercase tracking-wider">
Estimated Total
</p>
<p className="font-bold text-primary-text">
{typeof order.total === "number" ? `$${order.total.toLocaleString()}` : "—"}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-slate-500/10 rounded-xl flex items-center justify-center border border-slate-500/20">
<Users className="w-6 h-6 text-slate-700" />
</div>
<div>
<p className="text-xs text-secondary-text font-medium uppercase tracking-wider">
Total Positions
</p>
<p className="font-bold text-primary-text">{totalRequested}</p>
</div>
</div>
<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">
<Clock className="w-6 h-6 text-primary" />
</div>
<div>
<p className="text-xs text-secondary-text font-medium uppercase tracking-wider">
Order Type
</p>
<p className="font-bold text-primary-text">
{(order.orderType as string)?.replace("_", " ") || "—"}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
</DashboardLayout>
);
}
const FileTextIcon: React.FC = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
className="w-6 h-6 text-primary"
aria-hidden="true"
>
<path
fill="currentColor"
d="M7 2a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8.414a2 2 0 0 0-.586-1.414l-4.414-4.414A2 2 0 0 0 13.586 2H7zm6 2.414L17.586 9H15a2 2 0 0 1-2-2V4.414zM9 11h6v2H9v-2zm0 4h4v2H9v-2z"
/>
</svg>
);

View File

@@ -0,0 +1,343 @@
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 DashboardLayout from "@/features/layouts/DashboardLayout";
import {
Search,
Calendar,
Filter,
ArrowRight,
Clock,
CheckCircle,
AlertTriangle,
XCircle,
FileText
} from "lucide-react";
import React, { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useSelector } from "react-redux";
import type { RootState } from "@/store/store";
import { useListOrders, useListBusinesses } from "@/dataconnect-generated/react";
import { dataConnect } from "@/features/auth/firebase";
import { format, isWithinInterval, parseISO, startOfDay, endOfDay } from "date-fns";
import { OrderStatus } from "@/dataconnect-generated";
export default function OrderList() {
const navigate = useNavigate();
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [clientFilter, setClientFilter] = useState("all");
const [dateRange, setDateRange] = useState<{ start: string; end: string }>({ start: "", end: "" });
const { user } = useSelector((state: RootState) => state.auth);
const isAdmin = user?.userRole === 'admin' || user?.userRole === 'ADMIN';
const { data: orderData, isLoading: loadingOrders } = useListOrders(dataConnect);
const { data: businessData, isLoading: loadingBusinesses } = useListBusinesses(dataConnect);
const isLoading = loadingOrders || loadingBusinesses;
const orders = orderData?.orders || [];
const businesses = businessData?.businesses || [];
const filteredOrders = useMemo(() => {
return orders.filter(order => {
// Search by Order # (ID) or Event Name
const matchesSearch =
order.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
(order.eventName?.toLowerCase().includes(searchTerm.toLowerCase()) ?? false);
// Filter by Status
const matchesStatus = statusFilter === "all" || order.status === statusFilter;
// Filter by Client
const matchesClient = clientFilter === "all" || order.businessId === clientFilter;
// Filter by Date Range
let matchesDate = true;
if (order.date) {
const orderDate = new Date(order.date);
if (dateRange.start && dateRange.end) {
matchesDate = isWithinInterval(orderDate, {
start: startOfDay(new Date(dateRange.start)),
end: endOfDay(new Date(dateRange.end))
});
} else if (dateRange.start) {
matchesDate = orderDate >= startOfDay(new Date(dateRange.start));
} else if (dateRange.end) {
matchesDate = orderDate <= endOfDay(new Date(dateRange.end));
}
}
return matchesSearch && matchesStatus && matchesClient && matchesDate;
});
}, [orders, searchTerm, statusFilter, clientFilter, dateRange]);
const getStatusBadge = (status: OrderStatus) => {
switch (status) {
case OrderStatus.FULLY_STAFFED:
case OrderStatus.FILLED:
return <Badge className="bg-emerald-500 hover:bg-emerald-600 text-white border-none font-bold uppercase text-[10px]">Fully Staffed</Badge>;
case OrderStatus.PARTIAL_STAFFED:
return <Badge className="bg-orange-500 hover:bg-orange-600 text-white border-none font-bold uppercase text-[10px]">Partial Staffed</Badge>;
case OrderStatus.PENDING:
case OrderStatus.POSTED:
return <Badge className="bg-blue-500 hover:bg-blue-600 text-white border-none font-bold uppercase text-[10px]">{status}</Badge>;
case OrderStatus.CANCELLED:
return <Badge className="bg-red-500 hover:bg-red-600 text-white border-none font-bold uppercase text-[10px]">Cancelled</Badge>;
case OrderStatus.COMPLETED:
return <Badge className="bg-green-500 hover:bg-slate-600 text-white border-none font-bold uppercase text-[10px]">Completed</Badge>;
case OrderStatus.DRAFT:
return <Badge variant="outline" className="text-muted-foreground font-bold uppercase text-[10px]">Draft</Badge>;
default:
return <Badge variant="secondary" className="font-bold uppercase text-[10px]">{status}</Badge>;
}
};
const calculateFillRate = (order: any) => {
const requested = order.requested || 0;
const assigned = Array.isArray(order.assignedStaff) ? order.assignedStaff.length : 0;
if (requested === 0) return 0;
return Math.round((assigned / requested) * 100);
};
if (!isAdmin) {
return (
<DashboardLayout title="Access Denied" subtitle="Unauthorized Access">
<div className="flex flex-col items-center justify-center min-h-[40vh] text-center">
<div className="w-16 h-16 bg-destructive/10 rounded-full flex items-center justify-center text-destructive mb-4">
<AlertTriangle className="w-8 h-8" />
</div>
<h2 className="text-2xl font-bold">Restricted Access</h2>
<p className="text-muted-foreground mt-2 max-w-sm">Only administrators are authorized to view the master order list.</p>
<Button onClick={() => navigate("/")} variant="outline" className="mt-6 rounded-xl font-bold">
Return to Dashboard
</Button>
</div>
</DashboardLayout>
);
}
return (
<DashboardLayout
title="Master Order List"
subtitle="Monitor and manage all client orders across the platform"
>
<div className="space-y-6">
{/* KPI Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="bg-card/50 border-border/50 shadow-sm">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground font-bold uppercase tracking-wider mb-1">Total Orders</p>
<p className="text-2xl font-bold">{orders.length}</p>
</div>
<div className="p-2 bg-primary/10 rounded-lg text-primary">
<FileText className="w-5 h-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-card/50 border-border/50 shadow-sm">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground font-bold uppercase tracking-wider mb-1">Pending/Posted</p>
<p className="text-2xl font-bold text-blue-600">
{orders.filter(o => o.status === OrderStatus.PENDING || o.status === OrderStatus.POSTED).length}
</p>
</div>
<div className="p-2 bg-blue-500/10 rounded-lg text-blue-600">
<Clock className="w-5 h-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-card/50 border-border/50 shadow-sm">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground font-bold uppercase tracking-wider mb-1">Partial</p>
<p className="text-2xl font-bold text-orange-600">
{orders.filter(o => o.status === OrderStatus.PARTIAL_STAFFED).length}
</p>
</div>
<div className="p-2 bg-orange-500/10 rounded-lg text-orange-600">
<AlertTriangle className="w-5 h-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-card/50 border-border/50 shadow-sm">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground font-bold uppercase tracking-wider mb-1">Filled</p>
<p className="text-2xl font-bold text-emerald-600">
{orders.filter(o => o.status === OrderStatus.FULLY_STAFFED || o.status === OrderStatus.FILLED).length}
</p>
</div>
<div className="p-2 bg-emerald-500/10 rounded-lg text-emerald-600">
<CheckCircle className="w-5 h-5" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters Section */}
<div className="bg-card/30 p-4 rounded-2xl border border-border/50 flex flex-col lg:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search by Order # or Event Name..."
className="pl-10 h-11"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="h-11 min-w-[140px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
{Object.values(OrderStatus).map(status => (
<SelectItem key={status} value={status}>{status.replace('_', ' ')}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={clientFilter} onValueChange={setClientFilter}>
<SelectTrigger className="h-11 min-w-[160px]">
<SelectValue placeholder="Client" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Clients</SelectItem>
{businesses.map(business => (
<SelectItem key={business.id} value={business.id}>{business.businessName}</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex gap-2 col-span-2">
<Input
type="date"
className="h-11"
value={dateRange.start}
onChange={(e) => setDateRange(prev => ({ ...prev, start: e.target.value }))}
/>
<div className="flex items-center text-muted-foreground">to</div>
<Input
type="date"
className="h-11"
value={dateRange.end}
onChange={(e) => setDateRange(prev => ({ ...prev, end: e.target.value }))}
/>
</div>
</div>
</div>
{/* Master Table */}
<div className="bg-card border border-border rounded-2xl overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-border bg-muted/30">
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-muted-foreground">Order #</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-muted-foreground">Client Name</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-muted-foreground">Event Date</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-muted-foreground">Status</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-muted-foreground text-center">Positions</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-muted-foreground">Fill Rate</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-muted-foreground text-right"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{isLoading ? (
<tr>
<td colSpan={7} className="px-6 py-12 text-center">
<div className="flex flex-col items-center gap-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 font-medium">Fetching orders...</p>
</div>
</td>
</tr>
) : filteredOrders.length > 0 ? (
filteredOrders.map((order) => {
const fillRate = calculateFillRate(order);
return (
<tr
key={order.id}
className="hover:bg-muted/40 cursor-pointer transition-colors group"
onClick={() => navigate(`/orders/${order.id}`)}
>
<td className="px-6 py-4">
<div className="font-bold text-foreground group-hover:text-primary transition-colors truncate max-w-[120px]">
#{order.id.split('-')[0].toUpperCase()}
</div>
<div className="text-[10px] text-muted-foreground uppercase">{order.eventName || 'Unnamed Event'}</div>
</td>
<td className="px-6 py-4">
<div className="text-sm font-medium">{order.business.businessName}</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2 text-sm">
<Calendar className="w-3.5 h-3.5 text-muted-foreground" />
{order.date ? format(new Date(order.date), 'MMM d, yyyy') : 'TBD'}
</div>
</td>
<td className="px-6 py-4">
{getStatusBadge(order.status)}
</td>
<td className="px-6 py-4 text-center">
<div className="text-sm font-bold bg-muted/50 px-2 py-1 rounded border border-border inline-block min-w-[30px]">
{order.requested || 0}
</div>
</td>
<td className="px-6 py-4">
<div className="flex flex-col gap-1.5 w-full max-w-[100px]">
<div className="flex justify-between text-[10px] font-bold">
<span>{fillRate}%</span>
</div>
<div className="w-full bg-muted rounded-full h-1.5 overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
fillRate === 100 ? 'bg-emerald-500' : fillRate > 0 ? 'bg-orange-500' : 'bg-slate-300'
}`}
style={{ width: `${fillRate}%` }}
/>
</div>
</div>
</td>
<td className="px-6 py-4 text-right">
<ArrowRight className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
</td>
</tr>
);
})
) : (
<tr>
<td colSpan={7} className="px-6 py-12 text-center text-muted-foreground">
No orders found matching your criteria.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,248 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { format, parseISO, isValid } from "date-fns";
import { Search, MapPin } from "lucide-react";
import { Card, CardContent } from "@/common/components/ui/card";
import { Input } from "@/common/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/common/components/ui/table";
import DashboardLayout from "@/features/layouts/DashboardLayout";
import { useSelector } from "react-redux";
import type { RootState } from "@/store/store";
import { useListOrders, useGetVendorByUserId } from "@/dataconnect-generated/react";
import { dataConnect } from "@/features/auth/firebase";
const safeParseDate = (dateString: any): Date | null => {
if (!dateString) return null;
try {
const date = typeof dateString === "string" ? parseISO(dateString) : new Date(dateString);
return isValid(date) ? date : null;
} catch {
return null;
}
};
const hasVendorAssignments = (order: any, vendorId?: string | null) => {
if (!vendorId) return false;
const assignedStaff = order?.assignedStaff;
if (!Array.isArray(assignedStaff)) return false;
return assignedStaff.some((assignment: any) => {
const assignmentVendorId =
assignment?.vendorId ||
assignment?.vendor_id ||
assignment?.vendor?.id ||
null;
return assignmentVendorId === vendorId;
});
};
const getVendorPositionsSummary = (order: any, vendorId?: string | null) => {
if (!vendorId) return "—";
const assignedStaff = order?.assignedStaff;
if (!Array.isArray(assignedStaff)) return "—";
const vendorAssignments = assignedStaff.filter((assignment: any) => {
const assignmentVendorId =
assignment?.vendorId ||
assignment?.vendor_id ||
assignment?.vendor?.id ||
null;
return assignmentVendorId === vendorId;
});
if (vendorAssignments.length === 0) return "—";
const positions = Array.from(
new Set(
vendorAssignments
.map((assignment: any) => assignment.positionName || assignment.roleName || assignment.position || null)
.filter(Boolean),
),
) as string[];
if (positions.length === 0) return `${vendorAssignments.length} staff`;
return positions.join(", ");
};
const getEstimatedRevenue = (order: any, vendorId?: string | null) => {
if (!vendorId) return "—";
const assignedStaff = order?.assignedStaff;
if (!Array.isArray(assignedStaff)) return "—";
const vendorAssignments = assignedStaff.filter((assignment: any) => {
const assignmentVendorId =
assignment?.vendorId ||
assignment?.vendor_id ||
assignment?.vendor?.id ||
null;
return assignmentVendorId === vendorId;
});
if (vendorAssignments.length === 0) return "—";
const total = vendorAssignments.reduce((sum: number, assignment: any) => {
const hours = Number(
assignment?.estimatedHours ?? assignment?.hours ?? assignment?.shiftHours ?? 0,
);
const rate = Number(
assignment?.vendorBillRate ?? assignment?.billRate ?? assignment?.rate ?? 0,
);
if (!Number.isFinite(hours) || !Number.isFinite(rate)) return sum;
return sum + hours * rate;
}, 0);
if (!Number.isFinite(total) || total <= 0) return "—";
return `$${Math.round(total).toLocaleString()}`;
};
export default function VendorOrderList() {
const navigate = useNavigate();
const { user } = useSelector((state: RootState) => state.auth);
const [searchTerm, setSearchTerm] = useState("");
// 1. Resolve the logged-in vendor from the current user
const { data: vendorData } = useGetVendorByUserId(
{ userId: user?.uid || "" },
{ enabled: !!user?.uid },
);
const vendor = vendorData?.vendors?.[0];
const vendorId: string | null = vendor?.id ?? null;
// 2. Load all orders from Data Connect
const { data: orderData, isLoading } = useListOrders(dataConnect);
const orders = orderData?.orders || [];
// 3. Filter to only orders where this vendor has assigned staff
const vendorOrders = useMemo(() => {
if (!vendorId) return [];
return orders.filter((order: any) => hasVendorAssignments(order, vendorId));
}, [orders, vendorId]);
// 4. Apply search filter (Order #, Client, Event)
const filteredOrders = useMemo(() => {
const lowerSearch = searchTerm.trim().toLowerCase();
if (!lowerSearch) return vendorOrders;
return vendorOrders.filter((order: any) => {
const orderId = order.id?.toString().toLowerCase() ?? "";
const eventName = order.eventName?.toLowerCase?.() ?? "";
const clientName = order.business?.businessName?.toLowerCase?.() ?? "";
return (
orderId.includes(lowerSearch) ||
eventName.includes(lowerSearch) ||
clientName.includes(lowerSearch)
);
});
}, [vendorOrders, searchTerm]);
return (
<DashboardLayout
title="Vendor Orders"
subtitle="Orders where your team is assigned"
>
<div className="space-y-6">
{/* Search */}
<Card className="bg-card border-border/50 shadow-sm">
<CardContent className="p-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 by order #, client, or event name..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9"
/>
</div>
</CardContent>
</Card>
{/* Orders Table */}
<div className="bg-card rounded-xl shadow-sm border border-border/50 overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="w-[120px]">Order #</TableHead>
<TableHead>Client</TableHead>
<TableHead>Event Date</TableHead>
<TableHead>Your Positions</TableHead>
<TableHead className="text-right">Estimated Revenue</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-10 text-muted-foreground">
Loading vendor orders...
</TableCell>
</TableRow>
) : filteredOrders.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-10 text-muted-foreground">
{vendorId
? "No orders found where your team is assigned."
: "No vendor profile found for this user."}
</TableCell>
</TableRow>
) : (
filteredOrders.map((order: any) => {
const eventDate = safeParseDate(order.date);
const positionsSummary = getVendorPositionsSummary(order, vendorId);
const estimatedRevenue = getEstimatedRevenue(order, vendorId);
return (
<TableRow
key={order.id}
className="cursor-pointer hover:bg-muted/30"
onClick={() => navigate(`/orders/${order.id}`)}
>
<TableCell className="font-mono text-xs text-muted-foreground uppercase">
{order.id?.toString().substring(0, 8)}
</TableCell>
<TableCell className="font-medium">
{order.business?.businessName || "Unknown client"}
<div className="flex items-center gap-1 text-[10px] text-muted-foreground mt-0.5">
<MapPin className="w-3 h-3" />
{order.business?.businessName || "No location"}
</div>
</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="text-sm">
{eventDate ? format(eventDate, "MMM dd, yyyy") : "No date"}
</span>
<span className="text-[10px] text-muted-foreground uppercase">
{eventDate ? format(eventDate, "EEEE") : ""}
</span>
</div>
</TableCell>
<TableCell className="text-sm">
{positionsSummary}
</TableCell>
<TableCell className="text-right font-semibold">
{estimatedRevenue}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</div>
</DashboardLayout>
);
}

17
apps/web/src/lib/index.ts Normal file
View File

@@ -0,0 +1,17 @@
export function createPageUrl(pageName: string) {
// Basic implementation based on MVP usage: navigate(createPageUrl('Events'))
// Assuming mapping based on pageName
if (pageName === 'Events') return '/orders';
if (pageName === 'ClientOrders') return '/orders'; // Assuming same route for now
if (pageName === 'Invoices') return '/invoices'; // Assuming route exists
if (pageName.startsWith('EventDetail?id=')) {
const id = pageName.split('=')[1];
return `/orders/${id}`;
}
if (pageName.startsWith('EditEvent?id=')) {
const id = pageName.split('=')[1];
return `/orders/${id}/edit`;
}
return '/' + pageName.toLowerCase().replace(/ /g, '-');
}

View File

@@ -12,6 +12,14 @@ import PublicLayout from './features/layouts/PublicLayout';
import StaffList from './features/workforce/directory/StaffList'; import StaffList from './features/workforce/directory/StaffList';
import EditStaff from './features/workforce/directory/EditStaff'; import EditStaff from './features/workforce/directory/EditStaff';
import AddStaff from './features/workforce/directory/AddStaff'; import AddStaff from './features/workforce/directory/AddStaff';
import ClientList from './features/business/clients/ClientList';
import EditClient from './features/business/clients/EditClient';
import AddClient from './features/business/clients/AddClient';
import ServiceRates from './features/business/rates/ServiceRates';
import OrderList from './features/operations/orders/OrderList';
import OrderDetail from './features/operations/orders/OrderDetail';
import ClientOrderList from './features/operations/orders/ClientOrderList';
import VendorOrderList from './features/operations/orders/VendorOrderList';
/** /**
* AppRoutes Component * AppRoutes Component
@@ -81,6 +89,17 @@ const AppRoutes: React.FC = () => {
<Route path="/staff" element={<StaffList />} /> <Route path="/staff" element={<StaffList />} />
<Route path="/staff/add" element={<AddStaff />} /> <Route path="/staff/add" element={<AddStaff />} />
<Route path="/staff/:id/edit" element={<EditStaff />} /> <Route path="/staff/:id/edit" element={<EditStaff />} />
{/* Business Routes */}
<Route path="/clients" element={<ClientList />} />
<Route path="/clients/:id/edit" element={<EditClient />} />
<Route path="/clients/add" element={<AddClient />} />
<Route path="/rates" element={<ServiceRates />} />
{/* Operations Routes */}
<Route path="/orders" element={<OrderList />} />
<Route path="/orders/client" element={<ClientOrderList />} />
<Route path="/orders/:id" element={<OrderDetail />} />
<Route path="/orders/vendor" element={<VendorOrderList />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/login" replace />} /> <Route path="*" element={<Navigate to="/login" replace />} />
</Routes> </Routes>