feat: Implement Client Order List
This commit is contained in:
@@ -14,6 +14,7 @@
|
|||||||
"@firebase/data-connect": "^0.3.12",
|
"@firebase/data-connect": "^0.3.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-datepicker": "^9.1.0",
|
"react-datepicker": "^9.1.0",
|
||||||
|
"react-day-picker": "^9.13.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hook-form": "^7.71.1",
|
"react-hook-form": "^7.71.1",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
|
|||||||
29
apps/web/pnpm-lock.yaml
generated
29
apps/web/pnpm-lock.yaml
generated
@@ -23,6 +23,9 @@ importers:
|
|||||||
'@radix-ui/react-label':
|
'@radix-ui/react-label':
|
||||||
specifier: ^2.1.7
|
specifier: ^2.1.7
|
||||||
version: 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
'@radix-ui/react-popover':
|
||||||
|
specifier: ^1.1.15
|
||||||
|
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
'@radix-ui/react-slot':
|
'@radix-ui/react-slot':
|
||||||
specifier: ^1.2.4
|
specifier: ^1.2.4
|
||||||
version: 1.2.4(@types/react@19.2.10)(react@19.2.4)
|
version: 1.2.4(@types/react@19.2.10)(react@19.2.4)
|
||||||
@@ -80,6 +83,9 @@ importers:
|
|||||||
react-datepicker:
|
react-datepicker:
|
||||||
specifier: ^9.1.0
|
specifier: ^9.1.0
|
||||||
version: 9.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 9.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
react-day-picker:
|
||||||
|
specifier: ^9.13.0
|
||||||
|
version: 9.13.0(react@19.2.4)
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.2.0
|
specifier: ^19.2.0
|
||||||
version: 19.2.4(react@19.2.4)
|
version: 19.2.4(react@19.2.4)
|
||||||
@@ -236,6 +242,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==}
|
resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@date-fns/tz@1.4.1':
|
||||||
|
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.27.2':
|
'@esbuild/aix-ppc64@0.27.2':
|
||||||
resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==}
|
resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -2016,6 +2025,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
date-fns-jalali@4.1.0-0:
|
||||||
|
resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==}
|
||||||
|
|
||||||
date-fns@4.1.0:
|
date-fns@4.1.0:
|
||||||
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
|
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
|
||||||
|
|
||||||
@@ -2583,6 +2595,12 @@ packages:
|
|||||||
date-fns-tz:
|
date-fns-tz:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
react-day-picker@9.13.0:
|
||||||
|
resolution: {integrity: sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
react-dom@19.2.4:
|
react-dom@19.2.4:
|
||||||
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
|
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3042,6 +3060,8 @@ snapshots:
|
|||||||
'@babel/helper-string-parser': 7.27.1
|
'@babel/helper-string-parser': 7.27.1
|
||||||
'@babel/helper-validator-identifier': 7.28.5
|
'@babel/helper-validator-identifier': 7.28.5
|
||||||
|
|
||||||
|
'@date-fns/tz@1.4.1': {}
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.27.2':
|
'@esbuild/aix-ppc64@0.27.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -4857,6 +4877,8 @@ snapshots:
|
|||||||
|
|
||||||
d3-timer@3.0.1: {}
|
d3-timer@3.0.1: {}
|
||||||
|
|
||||||
|
date-fns-jalali@4.1.0-0: {}
|
||||||
|
|
||||||
date-fns@4.1.0: {}
|
date-fns@4.1.0: {}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
@@ -5454,6 +5476,13 @@ snapshots:
|
|||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
react-dom: 19.2.4(react@19.2.4)
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
|
||||||
|
react-day-picker@9.13.0(react@19.2.4):
|
||||||
|
dependencies:
|
||||||
|
'@date-fns/tz': 1.4.1
|
||||||
|
date-fns: 4.1.0
|
||||||
|
date-fns-jalali: 4.1.0-0
|
||||||
|
react: 19.2.4
|
||||||
|
|
||||||
react-dom@19.2.4(react@19.2.4):
|
react-dom@19.2.4(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
export const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center transition-premium gap-2 whitespace-nowrap rounded-xl text-base font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 active:scale-[0.98]",
|
"inline-flex items-center justify-center transition-premium gap-2 whitespace-nowrap rounded-xl text-base font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 active:scale-[0.98]",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
|
|||||||
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,
|
||||||
|
}
|
||||||
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 }
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/common/components/ui/dailog";
|
} from "@/common/components/ui/dialog";
|
||||||
import { Input } from "@/common/components/ui/input";
|
import { Input } from "@/common/components/ui/input";
|
||||||
import { Label } from "@/common/components/ui/label";
|
import { Label } from "@/common/components/ui/label";
|
||||||
import {
|
import {
|
||||||
|
|||||||
319
apps/web/src/features/operations/orders/ClientOrderList.tsx
Normal file
319
apps/web/src/features/operations/orders/ClientOrderList.tsx
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
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.teamHub?.hubName?.toLowerCase().includes(lower)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusFilter !== "all") {
|
||||||
|
filtered = filtered.filter(o => o.status === statusFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locationFilter !== "all") {
|
||||||
|
filtered = filtered.filter(o => o.teamHub?.hubName === locationFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}, [orders, searchTerm, statusFilter, locationFilter]);
|
||||||
|
|
||||||
|
const uniqueLocations = useMemo(() => {
|
||||||
|
const locations = new Set<string>();
|
||||||
|
orders.forEach(o => {
|
||||||
|
if (o.teamHub?.hubName) locations.add(o.teamHub.hubName);
|
||||||
|
});
|
||||||
|
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.teamHub?.hubName || "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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ import AddClient from './features/business/clients/AddClient';
|
|||||||
import ServiceRates from './features/business/rates/ServiceRates';
|
import ServiceRates from './features/business/rates/ServiceRates';
|
||||||
import OrderList from './features/operations/orders/OrderList';
|
import OrderList from './features/operations/orders/OrderList';
|
||||||
import OrderDetail from './features/operations/orders/OrderDetail';
|
import OrderDetail from './features/operations/orders/OrderDetail';
|
||||||
|
import ClientOrderList from './features/operations/orders/ClientOrderList';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AppRoutes Component
|
* AppRoutes Component
|
||||||
@@ -95,6 +95,7 @@ const AppRoutes: React.FC = () => {
|
|||||||
<Route path="/rates" element={<ServiceRates />} />
|
<Route path="/rates" element={<ServiceRates />} />
|
||||||
{/* Operations Routes */}
|
{/* Operations Routes */}
|
||||||
<Route path="/orders" element={<OrderList />} />
|
<Route path="/orders" element={<OrderList />} />
|
||||||
|
<Route path="/orders/client" element={<ClientOrderList />} />
|
||||||
<Route path="/orders/:id" element={<OrderDetail />} />
|
<Route path="/orders/:id" element={<OrderDetail />} />
|
||||||
|
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
Reference in New Issue
Block a user