diff --git a/apps/web/package.json b/apps/web/package.json index 84d2a667..adc206a8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index e677ef78..1f511454 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -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 diff --git a/apps/web/pnpm-workspace.yaml b/apps/web/pnpm-workspace.yaml index 98e4ea6b..62ff1aa4 100644 --- a/apps/web/pnpm-workspace.yaml +++ b/apps/web/pnpm-workspace.yaml @@ -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 diff --git a/apps/web/src/common/components/ui/alert.tsx b/apps/web/src/common/components/ui/alert.tsx new file mode 100644 index 00000000..5afd41d1 --- /dev/null +++ b/apps/web/src/common/components/ui/alert.tsx @@ -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 & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/apps/web/src/common/components/ui/badge.tsx b/apps/web/src/common/components/ui/badge.tsx index fd33e2e7..43ee64f9 100644 --- a/apps/web/src/common/components/ui/badge.tsx +++ b/apps/web/src/common/components/ui/badge.tsx @@ -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", }, }, diff --git a/apps/web/src/common/components/ui/button.tsx b/apps/web/src/common/components/ui/button.tsx index a61355ad..ece11a54 100644 --- a/apps/web/src/common/components/ui/button.tsx +++ b/apps/web/src/common/components/ui/button.tsx @@ -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: { diff --git a/apps/web/src/common/components/ui/calendar.tsx b/apps/web/src/common/components/ui/calendar.tsx new file mode 100644 index 00000000..0e35cbba --- /dev/null +++ b/apps/web/src/common/components/ui/calendar.tsx @@ -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 ( + .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 ; + }, + }} + {...props} + /> + ); +} +Calendar.displayName = "Calendar"; + +export { Calendar } diff --git a/apps/web/src/common/components/ui/command.tsx b/apps/web/src/common/components/ui/command.tsx new file mode 100644 index 00000000..36135312 --- /dev/null +++ b/apps/web/src/common/components/ui/command.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Command.displayName = CommandPrimitive.displayName + +interface CommandDialogProps extends React.ComponentPropsWithoutRef {} + +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + return ( + + + + {children} + + + + ) +} + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)) + +CommandInput.displayName = CommandPrimitive.Input.displayName + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandList.displayName = CommandPrimitive.List.displayName + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)) + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandGroup.displayName = CommandPrimitive.Group.displayName + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +CommandSeparator.displayName = CommandPrimitive.Separator.displayName + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandItem.displayName = CommandPrimitive.Item.displayName + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +CommandShortcut.displayName = "CommandShortcut" + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/apps/web/src/common/components/ui/dialog.tsx b/apps/web/src/common/components/ui/dialog.tsx new file mode 100644 index 00000000..b718cca1 --- /dev/null +++ b/apps/web/src/common/components/ui/dialog.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = 'DialogHeader'; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = 'DialogFooter'; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/apps/web/src/common/components/ui/popover.tsx b/apps/web/src/common/components/ui/popover.tsx new file mode 100644 index 00000000..9d172edb --- /dev/null +++ b/apps/web/src/common/components/ui/popover.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent } diff --git a/apps/web/src/common/components/ui/switch.tsx b/apps/web/src/common/components/ui/switch.tsx new file mode 100644 index 00000000..726f9788 --- /dev/null +++ b/apps/web/src/common/components/ui/switch.tsx @@ -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.ComponentPropsWithoutRef>( + ({ className, ...props }, ref) => ( + + + + ) +) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } diff --git a/apps/web/src/common/components/ui/table.tsx b/apps/web/src/common/components/ui/table.tsx new file mode 100644 index 00000000..a82b1f48 --- /dev/null +++ b/apps/web/src/common/components/ui/table.tsx @@ -0,0 +1,120 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+ + +)) +Table.displayName = "Table" + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = "TableHeader" + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = "TableBody" + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", + className + )} + {...props} + /> +)) +TableFooter.displayName = "TableFooter" + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableRow.displayName = "TableRow" + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableHead.displayName = "TableHead" + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableCell.displayName = "TableCell" + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableCaption.displayName = "TableCaption" + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/apps/web/src/common/components/ui/tabs.tsx b/apps/web/src/common/components/ui/tabs.tsx new file mode 100644 index 00000000..abadf3d8 --- /dev/null +++ b/apps/web/src/common/components/ui/tabs.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/apps/web/src/common/components/ui/use-toast.tsx b/apps/web/src/common/components/ui/use-toast.tsx new file mode 100644 index 00000000..dc710d79 --- /dev/null +++ b/apps/web/src/common/components/ui/use-toast.tsx @@ -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([]); + + 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 }; +}; diff --git a/apps/web/src/features/business/clients/AddClient.tsx b/apps/web/src/features/business/clients/AddClient.tsx new file mode 100644 index 00000000..9313b9f6 --- /dev/null +++ b/apps/web/src/features/business/clients/AddClient.tsx @@ -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 ( + navigate("/clients")} + leadingIcon={} + > + Back to Directory + + } + > +
+
+
+ {/* Business Name & Company Logo */} +
+
+ + handleChange('businessName', e.target.value)} + placeholder="Enter business name" + required + /> +
+ +
+ + handleChange('companyLogoUrl', e.target.value)} + placeholder="https://example.com/logo.png" + /> +

Optional: URL to company logo image

+
+
+ + {/* Primary Contact */} +
+ + handleChange('contactName', e.target.value)} + placeholder="Contact name" + required + /> +
+ + {/* Contact Number & Email */} +
+
+ + handleChange('phone', e.target.value)} + placeholder="(555) 123-4567" + /> +
+ +
+ + handleChange('email', e.target.value)} + placeholder="business@example.com" + required + /> +
+
+ + {/* Hub / Building */} +
+ + handleChange('hubBuilding', e.target.value)} + placeholder="Building name or location" + /> +
+ + {/* Billing Address */} +
+ + handleChange('address', e.target.value)} + placeholder="Street address" + required + /> +
+ + {/* City & Area */} +
+
+ + handleChange('city', e.target.value)} + placeholder="City" + required + /> +
+ +
+ + +
+
+ + {/* Sector & Rate Group */} +
+
+ + +
+ +
+ + +
+
+ + {/* Status */} +
+ + +
+ + {/* Notes */} +
+ +