@@ -10,11 +10,14 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dataconnect/generated": "link:src/dataconnect-generated",
|
||||
"@firebase/analytics": "^0.10.19",
|
||||
"@firebase/data-connect": "^0.3.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@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",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
@@ -31,6 +34,7 @@
|
||||
"lucide-react": "^0.563.0",
|
||||
"react": "^19.2.0",
|
||||
"react-datepicker": "^9.1.0",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-redux": "^9.2.0",
|
||||
|
||||
64
apps/web/pnpm-lock.yaml
generated
64
apps/web/pnpm-lock.yaml
generated
@@ -5,29 +5,36 @@ settings:
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
'@firebasegen/example-connector': link:src/dataconnect-generated
|
||||
dataconnect-generated: link:../../../../../AppData/Local/pnpm/global/5/node_modules/src/dataconnect-generated
|
||||
'@dataconnect/generated': link:src/dataconnect-generated
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@dataconnect/generated':
|
||||
specifier: link:src/dataconnect-generated
|
||||
version: link:src/dataconnect-generated
|
||||
'@firebase/analytics':
|
||||
specifier: ^0.10.19
|
||||
version: 0.10.19(@firebase/app@0.14.7)
|
||||
'@firebase/data-connect':
|
||||
specifier: ^0.3.12
|
||||
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':
|
||||
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)
|
||||
'@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':
|
||||
specifier: ^1.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':
|
||||
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)
|
||||
@@ -76,6 +83,9 @@ importers:
|
||||
react-datepicker:
|
||||
specifier: ^9.1.0
|
||||
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:
|
||||
specifier: ^19.2.0
|
||||
version: 19.2.4(react@19.2.4)
|
||||
@@ -232,6 +242,9 @@ packages:
|
||||
resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@date-fns/tz@1.4.1':
|
||||
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.2':
|
||||
resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1504,66 +1517,79 @@ packages:
|
||||
resolution: {integrity: sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.57.0':
|
||||
resolution: {integrity: sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.57.0':
|
||||
resolution: {integrity: sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.57.0':
|
||||
resolution: {integrity: sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.57.0':
|
||||
resolution: {integrity: sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.57.0':
|
||||
resolution: {integrity: sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.57.0':
|
||||
resolution: {integrity: sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.57.0':
|
||||
resolution: {integrity: sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.57.0':
|
||||
resolution: {integrity: sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.57.0':
|
||||
resolution: {integrity: sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.57.0':
|
||||
resolution: {integrity: sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.57.0':
|
||||
resolution: {integrity: sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.57.0':
|
||||
resolution: {integrity: sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.57.0':
|
||||
resolution: {integrity: sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==}
|
||||
@@ -1639,24 +1665,28 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
|
||||
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
|
||||
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
|
||||
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
|
||||
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
|
||||
@@ -1995,6 +2025,9 @@ packages:
|
||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
|
||||
|
||||
@@ -2384,24 +2417,28 @@ packages:
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.30.2:
|
||||
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.30.2:
|
||||
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.30.2:
|
||||
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.30.2:
|
||||
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
|
||||
@@ -2558,6 +2595,12 @@ packages:
|
||||
date-fns-tz:
|
||||
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:
|
||||
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
|
||||
peerDependencies:
|
||||
@@ -3017,6 +3060,8 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@date-fns/tz@1.4.1': {}
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.2':
|
||||
optional: true
|
||||
|
||||
@@ -4832,6 +4877,8 @@ snapshots:
|
||||
|
||||
d3-timer@3.0.1: {}
|
||||
|
||||
date-fns-jalali@4.1.0-0: {}
|
||||
|
||||
date-fns@4.1.0: {}
|
||||
|
||||
debug@4.4.3:
|
||||
@@ -5429,6 +5476,13 @@ snapshots:
|
||||
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):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
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
|
||||
|
||||
59
apps/web/src/common/components/ui/alert.tsx
Normal file
59
apps/web/src/common/components/ui/alert.tsx
Normal 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 }
|
||||
@@ -13,6 +13,8 @@ const badgeVariants = cva(
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
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]",
|
||||
{
|
||||
variants: {
|
||||
|
||||
73
apps/web/src/common/components/ui/calendar.tsx
Normal file
73
apps/web/src/common/components/ui/calendar.tsx
Normal 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 }
|
||||
152
apps/web/src/common/components/ui/command.tsx
Normal file
152
apps/web/src/common/components/ui/command.tsx
Normal 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,
|
||||
}
|
||||
119
apps/web/src/common/components/ui/dialog.tsx
Normal file
119
apps/web/src/common/components/ui/dialog.tsx
Normal 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,
|
||||
};
|
||||
29
apps/web/src/common/components/ui/popover.tsx
Normal file
29
apps/web/src/common/components/ui/popover.tsx
Normal 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 }
|
||||
25
apps/web/src/common/components/ui/switch.tsx
Normal file
25
apps/web/src/common/components/ui/switch.tsx
Normal 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 }
|
||||
120
apps/web/src/common/components/ui/table.tsx
Normal file
120
apps/web/src/common/components/ui/table.tsx
Normal 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,
|
||||
}
|
||||
52
apps/web/src/common/components/ui/tabs.tsx
Normal file
52
apps/web/src/common/components/ui/tabs.tsx
Normal 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 };
|
||||
25
apps/web/src/common/components/ui/use-toast.tsx
Normal file
25
apps/web/src/common/components/ui/use-toast.tsx
Normal 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 };
|
||||
};
|
||||
405
apps/web/src/features/business/clients/AddClient.tsx
Normal file
405
apps/web/src/features/business/clients/AddClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
280
apps/web/src/features/business/clients/ClientList.tsx
Normal file
280
apps/web/src/features/business/clients/ClientList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
371
apps/web/src/features/business/clients/EditClient.tsx
Normal file
371
apps/web/src/features/business/clients/EditClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
823
apps/web/src/features/business/rates/ServiceRates.tsx
Normal file
823
apps/web/src/features/business/rates/ServiceRates.tsx
Normal 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={`2025–2028 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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
320
apps/web/src/features/operations/orders/ClientOrderList.tsx
Normal file
320
apps/web/src/features/operations/orders/ClientOrderList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
451
apps/web/src/features/operations/orders/OrderDetail.tsx
Normal file
451
apps/web/src/features/operations/orders/OrderDetail.tsx
Normal 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>
|
||||
);
|
||||
343
apps/web/src/features/operations/orders/OrderList.tsx
Normal file
343
apps/web/src/features/operations/orders/OrderList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
248
apps/web/src/features/operations/orders/VendorOrderList.tsx
Normal file
248
apps/web/src/features/operations/orders/VendorOrderList.tsx
Normal 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
17
apps/web/src/lib/index.ts
Normal 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, '-');
|
||||
}
|
||||
@@ -12,6 +12,14 @@ import PublicLayout from './features/layouts/PublicLayout';
|
||||
import StaffList from './features/workforce/directory/StaffList';
|
||||
import EditStaff from './features/workforce/directory/EditStaff';
|
||||
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
|
||||
@@ -81,6 +89,17 @@ const AppRoutes: React.FC = () => {
|
||||
<Route path="/staff" element={<StaffList />} />
|
||||
<Route path="/staff/add" element={<AddStaff />} />
|
||||
<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 path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
|
||||
Reference in New Issue
Block a user