diff --git a/client/package.json b/client/package.json
index 48b7fc2..d2cc7fc 100644
--- a/client/package.json
+++ b/client/package.json
@@ -38,12 +38,14 @@
"autoprefixer": "^10.4.22",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "date-fns": "^4.1.0",
"lucide-react": "^0.553.0",
"next-themes": "^0.4.6",
"postcss": "^8.5.6",
"react": "^18.3.1",
+ "react-day-picker": "^9.11.3",
"react-dom": "^18.3.1",
- "react-router-dom": "^7.9.6",
+ "react-router-dom": "^7.10.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17",
diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml
index 0e4666a..9570c47 100644
--- a/client/pnpm-lock.yaml
+++ b/client/pnpm-lock.yaml
@@ -104,6 +104,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
+ date-fns:
+ specifier: ^4.1.0
+ version: 4.1.0
lucide-react:
specifier: ^0.553.0
version: 0.553.0(react@18.3.1)
@@ -116,12 +119,15 @@ importers:
react:
specifier: ^18.3.1
version: 18.3.1
+ react-day-picker:
+ specifier: ^9.11.3
+ version: 9.11.3(react@18.3.1)
react-dom:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
react-router-dom:
- specifier: ^7.9.6
- version: 7.9.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ specifier: ^7.10.0
+ version: 7.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -134,9 +140,6 @@ importers:
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@4.1.17)
- tw-animate-css:
- specifier: ^1.4.0
- version: 1.4.0
devDependencies:
'@types/node':
specifier: ^24.10.1
@@ -246,6 +249,9 @@ packages:
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
engines: {node: '>=6.9.0'}
+ '@date-fns/tz@1.4.1':
+ resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
+
'@esbuild/aix-ppc64@0.25.12':
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
engines: {node: '>=18'}
@@ -1379,6 +1385,12 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+ 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==}
+
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -1576,6 +1588,12 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
+ react-day-picker@9.11.3:
+ resolution: {integrity: sha512-7lD12UvGbkyXqgzbYIGQTbl+x29B9bAf+k0pP5Dcs1evfpKk6zv4EdH/edNc8NxcmCiTNXr2HIYPrSZ3XvmVBg==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ react: '>=16.8.0'
+
react-dom@18.3.1:
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
peerDependencies:
@@ -1605,15 +1623,15 @@ packages:
'@types/react':
optional: true
- react-router-dom@7.9.6:
- resolution: {integrity: sha512-2MkC2XSXq6HjGcihnx1s0DBWQETI4mlis4Ux7YTLvP67xnGxCvq+BcCQSO81qQHVUTM1V53tl4iVVaY5sReCOA==}
+ react-router-dom@7.10.0:
+ resolution: {integrity: sha512-Q4haR150pN/5N75O30iIsRJcr3ef7p7opFaKpcaREy0GQit6uCRu1NEiIFIwnHJQy0bsziRFBweR/5EkmHgVUQ==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: '>=18'
react-dom: '>=18'
- react-router@7.9.6:
- resolution: {integrity: sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==}
+ react-router@7.10.0:
+ resolution: {integrity: sha512-FVyCOH4IZ0eDDRycODfUqoN8ZSR2LbTvtx6RPsBgzvJ8xAXlMZNCrOFpu+jb8QbtZnpAd/cEki2pwE848pNGxw==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: '>=18'
@@ -1695,9 +1713,6 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
- tw-animate-css@1.4.0:
- resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
-
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@@ -1896,6 +1911,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.25.12':
optional: true
@@ -2936,6 +2953,10 @@ snapshots:
csstype@3.2.3: {}
+ date-fns-jalali@4.1.0-0: {}
+
+ date-fns@4.1.0: {}
+
debug@4.4.3:
dependencies:
ms: 2.1.3
@@ -3095,6 +3116,13 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
+ react-day-picker@9.11.3(react@18.3.1):
+ dependencies:
+ '@date-fns/tz': 1.4.1
+ date-fns: 4.1.0
+ date-fns-jalali: 4.1.0-0
+ react: 18.3.1
+
react-dom@18.3.1(react@18.3.1):
dependencies:
loose-envify: 1.4.0
@@ -3122,13 +3150,13 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.27
- react-router-dom@7.9.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ react-router-dom@7.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
- react-router: 7.9.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react-router: 7.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-router@7.9.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ react-router@7.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
cookie: 1.1.1
react: 18.3.1
@@ -3225,8 +3253,6 @@ snapshots:
tslib@2.8.1: {}
- tw-animate-css@1.4.0: {}
-
typescript@5.9.3: {}
undici-types@7.16.0: {}
diff --git a/client/src/components/index.ts b/client/src/components/index.ts
index 47869ba..cfe63f6 100644
--- a/client/src/components/index.ts
+++ b/client/src/components/index.ts
@@ -1,2 +1,3 @@
export * from "./base";
export * from "./examples";
+export * from "./library";
diff --git a/client/src/components/library/ConfirmationDialog.tsx b/client/src/components/library/ConfirmationDialog.tsx
new file mode 100644
index 0000000..3b3fb29
--- /dev/null
+++ b/client/src/components/library/ConfirmationDialog.tsx
@@ -0,0 +1,39 @@
+import {
+ AlertDialog,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+
+export interface ConfirmationDialogProps {
+ open: boolean;
+ title: string;
+ text: string;
+ buttons: React.ReactNode;
+}
+
+/**
+ * Reusable confirmation dialog component
+ *
+ * @component
+ */
+export const ConfirmationDialog = ({
+ open,
+ title,
+ text,
+ buttons,
+}: ConfirmationDialogProps) => {
+ return (
+
+
+
+ {title}
+ {text}
+
+ {buttons}
+
+
+ );
+};
diff --git a/client/src/components/library/DatePicker.tsx b/client/src/components/library/DatePicker.tsx
new file mode 100644
index 0000000..b639e91
--- /dev/null
+++ b/client/src/components/library/DatePicker.tsx
@@ -0,0 +1,81 @@
+import { format } from "date-fns";
+import { Calendar as CalendarIcon } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Calendar } from "@/components/ui/calendar";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+
+export interface DatePickerProps {
+ value: string | null; // YYYY-MM-DD
+ onChange: (value: string | null) => void;
+ label?: string;
+ placeholder?: string;
+ maxDate?: Date;
+ minDate?: Date;
+ disabled?: boolean;
+}
+
+/**
+ * Date picker component using shadcn calendar
+ *
+ * @component
+ */
+export const DatePicker = ({
+ value,
+ onChange,
+ placeholder = "Pick a date",
+ maxDate,
+ minDate,
+ disabled = false,
+}: DatePickerProps) => {
+ const dateValue = value
+ ? (() => {
+ const [year, month, day] = value.split("-").map(Number);
+ return new Date(year, month - 1, day);
+ })()
+ : undefined;
+
+ const handleSelect = (date: Date | undefined) => {
+ if (date) {
+ // Format as YYYY-MM-DD for consistency
+ const formattedDate = format(date, "yyyy-MM-dd");
+ onChange(formattedDate);
+ } else {
+ onChange(null);
+ }
+ };
+
+ return (
+
+
+
+
+
+ {
+ if (maxDate && date > maxDate) return true;
+ if (minDate && date < minDate) return true;
+ return false;
+ }}
+ />
+
+
+ );
+};
diff --git a/client/src/components/library/index.ts b/client/src/components/library/index.ts
new file mode 100644
index 0000000..0965b8a
--- /dev/null
+++ b/client/src/components/library/index.ts
@@ -0,0 +1,2 @@
+export * from "./ConfirmationDialog";
+export * from "./DatePicker";
diff --git a/client/src/components/ui/alert-dialog.tsx b/client/src/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..1d22728
--- /dev/null
+++ b/client/src/components/ui/alert-dialog.tsx
@@ -0,0 +1,138 @@
+import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
+import * as React from "react";
+import { buttonVariants } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+const AlertDialog = AlertDialogPrimitive.Root;
+
+const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
+
+const AlertDialogPortal = AlertDialogPrimitive.Portal;
+
+const AlertDialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
+
+const AlertDialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+));
+AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
+
+const AlertDialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+AlertDialogHeader.displayName = "AlertDialogHeader";
+
+const AlertDialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+AlertDialogFooter.displayName = "AlertDialogFooter";
+
+const AlertDialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
+
+const AlertDialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogDescription.displayName =
+ AlertDialogPrimitive.Description.displayName;
+
+const AlertDialogAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
+
+const AlertDialogCancel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+};
diff --git a/client/src/components/ui/calendar.tsx b/client/src/components/ui/calendar.tsx
new file mode 100644
index 0000000..f057197
--- /dev/null
+++ b/client/src/components/ui/calendar.tsx
@@ -0,0 +1,229 @@
+import {
+ ChevronDownIcon,
+ ChevronLeftIcon,
+ ChevronRightIcon,
+} from "lucide-react";
+import * as React from "react";
+import {
+ type DayButton,
+ DayPicker,
+ getDefaultClassNames,
+} from "react-day-picker";
+import { Button, buttonVariants } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ captionLayout = "label",
+ buttonVariant = "ghost",
+ formatters,
+ components,
+ ...props
+}: React.ComponentProps & {
+ buttonVariant?: React.ComponentProps["variant"];
+}) {
+ const defaultClassNames = getDefaultClassNames();
+
+ return (
+ svg]:rotate-180`,
+ String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
+ className,
+ )}
+ captionLayout={captionLayout}
+ formatters={{
+ formatMonthDropdown: (date) =>
+ date.toLocaleString("default", { month: "short" }),
+ ...formatters,
+ }}
+ classNames={{
+ root: cn("w-fit min-w-[250px]", defaultClassNames.root),
+ months: cn(
+ "relative flex flex-col gap-4 md:flex-row",
+ defaultClassNames.months,
+ ),
+ month: cn(
+ "flex w-full flex-col gap-4",
+ defaultClassNames.month,
+ ),
+ nav: cn(
+ "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
+ defaultClassNames.nav,
+ ),
+ button_previous: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
+ defaultClassNames.button_previous,
+ ),
+ button_next: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
+ defaultClassNames.button_next,
+ ),
+ month_caption: cn(
+ "flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
+ defaultClassNames.month_caption,
+ ),
+ dropdowns: cn(
+ "flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
+ defaultClassNames.dropdowns,
+ ),
+ dropdown_root: cn(
+ "has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
+ defaultClassNames.dropdown_root,
+ ),
+ dropdown: cn(
+ "bg-popover absolute inset-0 opacity-0",
+ defaultClassNames.dropdown,
+ ),
+ caption_label: cn(
+ "select-none font-medium",
+ captionLayout === "label"
+ ? "text-sm"
+ : "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
+ defaultClassNames.caption_label,
+ ),
+ table: "w-full border-collapse",
+ weekdays: cn("flex", defaultClassNames.weekdays),
+ weekday: cn(
+ "text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
+ defaultClassNames.weekday,
+ ),
+ week: cn("mt-2 flex w-full", defaultClassNames.week),
+ week_number_header: cn(
+ "w-[--cell-size] select-none",
+ defaultClassNames.week_number_header,
+ ),
+ week_number: cn(
+ "text-muted-foreground select-none text-[0.8rem]",
+ defaultClassNames.week_number,
+ ),
+ day: cn(
+ "group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
+ defaultClassNames.day,
+ ),
+ range_start: cn(
+ "bg-accent rounded-l-md",
+ defaultClassNames.range_start,
+ ),
+ range_middle: cn(
+ "rounded-none",
+ defaultClassNames.range_middle,
+ ),
+ range_end: cn(
+ "bg-accent rounded-r-md",
+ defaultClassNames.range_end,
+ ),
+ today: cn(
+ "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
+ defaultClassNames.today,
+ ),
+ outside: cn(
+ "text-muted-foreground aria-selected:text-muted-foreground",
+ defaultClassNames.outside,
+ ),
+ disabled: cn(
+ "text-muted-foreground opacity-50",
+ defaultClassNames.disabled,
+ ),
+ hidden: cn("invisible", defaultClassNames.hidden),
+ ...classNames,
+ }}
+ components={{
+ Root: ({ className, rootRef, ...props }) => {
+ return (
+
+ );
+ },
+ Chevron: ({ className, orientation, ...props }) => {
+ if (orientation === "left") {
+ return (
+
+ );
+ }
+
+ if (orientation === "right") {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ },
+ DayButton: CalendarDayButton,
+ WeekNumber: ({ children, ...props }) => {
+ return (
+ |
+
+ {children}
+
+ |
+ );
+ },
+ ...components,
+ }}
+ {...props}
+ />
+ );
+}
+
+function CalendarDayButton({
+ className,
+ day,
+ modifiers,
+ ...props
+}: React.ComponentProps) {
+ const defaultClassNames = getDefaultClassNames();
+
+ const ref = React.useRef(null);
+ React.useEffect(() => {
+ if (modifiers.focused) ref.current?.focus();
+ }, [modifiers.focused]);
+
+ return (
+