diff --git a/.env b/.env new file mode 100644 index 0000000..979796c --- /dev/null +++ b/.env @@ -0,0 +1 @@ +NEXT_PUBLIC_API_URL=https://newgarage.yslootahtech.com \ No newline at end of file diff --git a/.gitignore b/.gitignore index e6ce5a4..8655a4c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ # testing /coverage +/cypress/videos +/cypress/screenshots +/cypress/downloads # next.js /.next/ diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 0000000..6716ff9 --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,11 @@ +{ + "servers": { + "shadcn": { + "command": "npx", + "args": [ + "shadcn@latest", + "mcp" + ] + } + } +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..1817e2f --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -0,0 +1,12 @@ +import { LoginForm } from "@/modules/auth/login-form"; + + +export default function Page() { + return ( +
+
+ +
+
+ ) +} diff --git a/app/(authenticated)/layout.tsx b/app/(authenticated)/layout.tsx new file mode 100644 index 0000000..e19769f --- /dev/null +++ b/app/(authenticated)/layout.tsx @@ -0,0 +1,218 @@ +"use client" + +import type { NavGroup } from "@/base/types/navigation" +import { + AlarmClockIcon, + AwardIcon, + BanknoteArrowDownIcon, + BarChart3Icon, + BellRingIcon, + BookIcon, + BriefcaseBusinessIcon, + Building2Icon, + CalendarCheck2Icon, + CalendarDaysIcon, + LayoutDashboardIcon, + ClipboardListIcon, + UsersIcon, + CalendarIcon, + CarIcon, + ClipboardCheckIcon, + Clock3Icon, + ClockIcon, + GemIcon, + GitBranchIcon, + HandCoinsIcon, + ListIcon, + ListTodoIcon, + MegaphoneIcon, + PackageIcon, + PhoneCallIcon, + PlugZapIcon, + ReceiptIcon, + ReceiptTextIcon, + SettingsIcon, + ShoppingBasketIcon, + CircleDollarSign, + StarIcon, + StoreIcon, + TimerIcon, + UserCogIcon, + WalletIcon, + WrenchIcon, + ShoppingCartIcon, +} from "lucide-react" +import Image from "next/image" +import { DashboardLayout } from "@/base/components/layout/dashboard" +import { useAuth } from "@/shared/hooks/use-auth" + +const navGroups: NavGroup[] = [ + { + items: [ + { + title: "Dashboard", + href: "/", + icon: , + }, + { + title: "Job Cards", + href: "/sales/workorder/list", + icon: , + }, + { + title: "Customer & Vehicles", + href: "/customer_vehicles", + icon: , + }, + { + title: "Reports", + href: "/reports", + icon: , + }, + ], + }, + { + label: "Management", + items: [ + { + title: "Calendars", + href: "/calendars", + icon: , + items: [ + { title: "Work Schedule", href: "/calendar/work_schedule/list", icon: }, + { title: "Appointments", href: "/calendar/appointment/list", icon: }, + ], + }, + { + title: "Sales", + href: "/sales", + icon: , + items: [ + { title: "Customers", href: "/sales/customers", icon: }, + { title: "Vehicles", href: "/sales/vehicles", icon: }, + { title: "Inspections", href: "/sales/inspections", icon: }, + { title: "Estimates", href: "/sales/estimate", icon: }, + { title: "Job Cards", href: "/sales/workorder/list", icon: }, + { title: "Invoices", href: "/sales/invoice", icon: }, + { title: "Payments Received", href: "/sales/payment_received", icon: }, + { title: "Credit Notes", href: "/sales/credit_notes", icon: }, + ], + }, + { + title: "Purchases", + href: "/purchases", + icon: , + items: [ + { title: "Vendors", href: "/purchase/vendor", icon: }, + { title: "Expenses", href: "/purchase/expense", icon: }, + { title: "Purchase Orders", href: "/purchase/purchase_order", icon: }, + { title: "Bills", href: "/purchase/bill", icon: }, + { title: "Payments Made", href: "/purchase/payments_made", icon: }, + { title: "Vendor Credits", href: "/purchase/vendor_credit", icon: }, + ], + }, + { + title: "CRM", + href: "/crm", + icon: , + items: [ + { title: "Leads", href: "/crm/leads/list", icon: }, + { title: "Calls", href: "/crm/calls_follow_up/list", icon: }, + { title: "Tasks", href: "/crm/tasks/list", icon: }, + ], + }, + { + title: "Marketing", + href: "/marketing", + icon: , + items: [ + { title: "Service Reminders", href: "/marketing/service_reminder/list", icon: }, + { title: "Rating & Reviews", href: "/marketing/rating_review", icon: }, + { title: "Google Business Reviews", href: "/marketing/google_rating_review", icon: }, + ], + }, + { + title: "Accountants", + href: "/accountants", + icon: , + items: [ + { title: "Manual Journals", href: "/accountants/manual_journal", icon: }, + { title: "Chart of Accounts", href: "/accountants/chart_of_account", icon: }, + ], + }, + { + title: "Employees", + href: "/productivity", + icon: , + items: [ + { title: "Employees", href: "/productivity/employees", icon: }, + { title: "Time Clocks", href: "/productivity/time_clocks", icon: }, + { title: "Time Sheets", href: "/productivity/timesheet", icon: }, + { title: "Payroll", href: "/productivity/payroll", icon: }, + { title: "Payments Made", href: "/productivity/employee_payments_made", icon: }, + { title: "Shop Calendars", href: "/productivity/shop_calendars", icon: }, + { title: "Shop Timing", href: "/productivity/shop_timings", icon: }, + { title: "Holidays", href: "/productivity/holidays", icon: }, + ], + }, + { + title: "Items", + href: "/items", + icon: , + items: [ + { title: "Services", href: "/items/services", icon: }, + { title: "Parts", href: "/items/parts", icon: }, + { title: "Expense Item", href: "/items/expense_item", icon: }, + { title: "Service Group", href: "/items/service_group", icon: }, + { title: "Inspections", href: "/items/inspection", icon: }, + { title: "Inventory Adjustments", href: "/items/adjustment", icon: }, + ], + }, + { + title: "Settings", + href: "/setting", + icon: , + items: [ + { title: "Company", href: "/setting/company", icon: }, + { title: "Shop Types", href: "/setting/shop_type", icon: }, + { title: "Tax & Rates", href: "/setting/tax_rates", icon: }, + { title: "Configurations", href: "/setting/configurations/preferences/sales", icon: }, + { title: "Templates", href: "/setting/templates", icon: }, + { title: "Integrations", href: "/setting/integrations/providers", icon: }, + { title: "Master", href: "/setting/master/body_type", icon: }, + ], + }, + ], + }, +] + +function Logo() { + return ( +
+ Logo +
+ ) +} + +export default function AuthenticatedLayout({ + children, +}: { + children: React.ReactNode +}) { + const { user } = useAuth() + + const userInfo = user + ? { + name: user.name, + email: user.email, + initials: user.name.charAt(0).toUpperCase(), + } + : undefined + + return ( + } user={userInfo}> + {children} + + ) +} + diff --git a/app/(authenticated)/page.tsx b/app/(authenticated)/page.tsx new file mode 100644 index 0000000..69396b0 --- /dev/null +++ b/app/(authenticated)/page.tsx @@ -0,0 +1,14 @@ +import { DashboardHeader } from "@/base/components/layout/dashboard"; +import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"; +export default function page() { + return ( + } > +
+

Dashboard

+

+ Welcome to your dashboard. Select an item from the sidebar to get started. +

+
+
+ ) +} diff --git a/app/(authenticated)/sales/customers/page.tsx b/app/(authenticated)/sales/customers/page.tsx new file mode 100644 index 0000000..637fdd5 --- /dev/null +++ b/app/(authenticated)/sales/customers/page.tsx @@ -0,0 +1,54 @@ +"use client" + +import { ResourcePage } from '@/shared/data-view/resource-page' +import { ColumnHeader } from '@/shared/data-view/table-view' +import { CustomerForm } from '@/modules/customers/customer-form' +import { CUSTOMER_ROUTES } from '@repo/api' +import type { CustomersClient } from '@repo/api' +import { Building2Icon, UserIcon } from 'lucide-react' + +export default function CustomersPage() { + return ( + + pageTitle='Customers' + title="Customer" + routeKey={CUSTOMER_ROUTES.INDEX} + getClient={(api) => api.customers} + columns={({ actionsColumn }) => [ + + { + accessorKey: "first_name", + header: ({ column }) => , + cell: ({ row }) => { + const customerName = row.original.first_name + const isCompany = row.original.customer_type?.name?.toLocaleLowerCase() === "company"; + const companyName = row.original.company_name + const name = isCompany && companyName ? `${customerName} (${row.original.last_name})` : customerName + + return (
+ {isCompany ? : } + {name} +
+ ) + }, + }, + { + accessorKey: "email", + header: ({ column }) => , + }, + { + accessorKey: "phone", + header: ({ column }) => , + }, + actionsColumn(), + ]} + renderForm={({ resourceId, initialData, onSuccess }) => ( + + )} + /> + ) +} \ No newline at end of file diff --git a/app/(authenticated)/sales/vehicles/page.tsx b/app/(authenticated)/sales/vehicles/page.tsx new file mode 100644 index 0000000..e7e16d8 --- /dev/null +++ b/app/(authenticated)/sales/vehicles/page.tsx @@ -0,0 +1,58 @@ +"use client" + +import { ResourcePage } from '@/shared/data-view/resource-page' +import { ColumnHeader } from '@/shared/data-view/table-view' +import { VehicleForm } from '@/modules/vehicles/vehicle-form' +import { VEHICLE_ROUTES } from '@repo/api' +import type { VehiclesClient } from '@repo/api' +import { CarIcon } from 'lucide-react' + +export default function VehiclesPage() { + return ( + + pageTitle="Vehicles" + title="Vehicle" + routeKey={VEHICLE_ROUTES.INDEX} + getClient={(api) => api.vehicles} + columns={({ actionsColumn }) => [ + { + accessorKey: "name", + header: ({ column }) => , + cell: ({ row }) => { + const r = row.original as any + const display = r.name || `${r.make ?? ""} ${r.model ?? ""}`.trim() || "—" + return ( +
+ + {display} +
+ ) + }, + }, + { + accessorKey: "year", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).year ?? "—", + }, + { + accessorKey: "license_plate", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).license_plate ?? "—", + }, + { + accessorKey: "mileage", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).mileage ?? "—", + }, + actionsColumn(), + ]} + renderForm={({ resourceId, initialData, onSuccess }) => ( + + )} + /> + ) +} \ No newline at end of file diff --git a/app/(authenticated)/setting/shop_type/page.tsx b/app/(authenticated)/setting/shop_type/page.tsx new file mode 100644 index 0000000..4e4c958 --- /dev/null +++ b/app/(authenticated)/setting/shop_type/page.tsx @@ -0,0 +1,54 @@ +"use client" + +import { ResourcePage } from "@/shared/data-view/resource-page" +import { ColumnHeader } from "@/shared/data-view/table-view" +import { ShopTypeForm } from "@/modules/settings/shop-type/shop-type-form" +import { SHOP_TYPE_ROUTES } from "@repo/api" +import type { ShopTypesClient } from "@repo/api" +import { CheckIcon, XIcon } from "lucide-react" + +export default function ShopTypesPage() { + return ( + + pageTitle="Shop Types" + title="Shop Type" + routeKey={SHOP_TYPE_ROUTES.INDEX} + getClient={(api) => api.shopTypes} + columns={({ actionsColumn }) => [ + { + accessorKey: "title", + header: ({ column }) => , + }, + { + accessorKey: "shop_type", + header: ({ column }) => , + }, + { + accessorKey: "note", + header: ({ column }) => , + cell: ({ row }) => ( + + {(row.original as any).note ?? "—"} + + ), + }, + { + accessorKey: "is_default", + header: ({ column }) => , + cell: ({ row }) => + (row.original as any).is_default + ? + : , + }, + actionsColumn(), + ]} + renderForm={({ resourceId, initialData, onSuccess }) => ( + + )} + /> + ) +} diff --git a/app/globals.css b/app/globals.css index 446b465..d4cf055 100644 --- a/app/globals.css +++ b/app/globals.css @@ -5,125 +5,175 @@ @custom-variant dark (&:is(.dark *)); @theme inline { - --font-heading: var(--font-sans); - --font-sans: var(--font-sans); - --color-sidebar-ring: var(--sidebar-ring); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar: var(--sidebar); - --color-chart-5: var(--chart-5); - --color-chart-4: var(--chart-4); - --color-chart-3: var(--chart-3); - --color-chart-2: var(--chart-2); - --color-chart-1: var(--chart-1); - --color-ring: var(--ring); - --color-input: var(--input); - --color-border: var(--border); - --color-destructive: var(--destructive); - --color-accent-foreground: var(--accent-foreground); - --color-accent: var(--accent); - --color-muted-foreground: var(--muted-foreground); - --color-muted: var(--muted); - --color-secondary-foreground: var(--secondary-foreground); - --color-secondary: var(--secondary); - --color-primary-foreground: var(--primary-foreground); - --color-primary: var(--primary); - --color-popover-foreground: var(--popover-foreground); - --color-popover: var(--popover); - --color-card-foreground: var(--card-foreground); - --color-card: var(--card); - --color-foreground: var(--foreground); - --color-background: var(--background); - --radius-sm: calc(var(--radius) * 0.6); - --radius-md: calc(var(--radius) * 0.8); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) * 1.4); - --radius-2xl: calc(var(--radius) * 1.8); - --radius-3xl: calc(var(--radius) * 2.2); - --radius-4xl: calc(var(--radius) * 2.6); + --font-heading: var(--font-sans); + --font-sans: var(--font-sans); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-foreground: var(--foreground); + --color-background: var(--background); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); + + --shadow-glow : 0 0 10px var(--primary); } :root { - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --radius: 0.625rem; - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --background: oklch(96.416% 0.00011 271.152); + --foreground: oklch(0.062 0 0); + --card: oklch(0.975 0 0); + --card-foreground: oklch(0.281 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.577 0.245 27.325); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.949 0 0); + --muted-foreground: oklch(0.6 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); } .dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(75.417% 0.14818 18.15); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); } @layer base { * { @apply border-border outline-ring/50; - } + } + body { @apply bg-background text-foreground; - } + } + html { @apply font-sans; - } + } +} + + +@layer utilities { + .dashboard-nav-item { + @apply + relative + overflow-hidden + data-active:bg-primary/10 + data-active:text-primary + data-active:hover:text-primary + data-active:hover:bg-primary/15 + transition-all + duration-300; + } + + /* Accent bar — only in expanded mode */ + /* .dashboard-nav-item:not([data-collapsed="true"])::after { + content: ""; + position: absolute; + inset-inline-end: 0.25rem; + height: 80%; + border-radius: var(--radius-md); + z-index: 10; + box-shadow: 0 0 6px var(--primary); + } + + .dashboard-nav-item:not([data-collapsed="true"])[data-active="true"]::after { + width: 0.25rem; + background-color: var(--primary); + } */ + + /* Collapsed mode: icon centered, no bar */ + .dashboard-nav-item[data-collapsed="true"] { + @apply justify-center; + } + + .dashboard-nav-sub-item { + @apply + transition-colors + duration-200 + data-active:text-primary + data-active:font-medium + data-active:bg-primary/5 + hover:text-primary/80; + } } \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index a4c7321..326b949 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,10 +1,14 @@ -import { Geist, Geist_Mono, Inter } from "next/font/google" +import { Geist_Mono, Inter } from "next/font/google" import "./globals.css" -import { ThemeProvider } from "@/components/theme-provider" -import { cn } from "@/lib/utils"; +import { QueryProvider } from "@/shared/components/query-provider" +import { ThemeProvider } from "@/shared/components/theme-provider" +import { Toaster } from "@/shared/components/ui/sonner" +import { ConfirmDialog } from "@/shared/components/confirm-dialog" +import { NuqsAdapter } from "nuqs/adapters/next/app" +import { cn } from "@/shared/lib/utils" -const inter = Inter({subsets:['latin'],variable:'--font-sans'}) +const inter = Inter({ subsets: ["latin"], variable: "--font-sans" }) const fontMono = Geist_Mono({ subsets: ["latin"], @@ -23,7 +27,13 @@ export default function RootLayout({ className={cn("antialiased", fontMono.variable, "font-sans", inter.variable)} > - {children} + + + {children} + + + + ) diff --git a/app/page.tsx b/app/page.tsx deleted file mode 100644 index 98ff035..0000000 --- a/app/page.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { Button } from "@/components/ui/button" - -export default function Page() { - return ( -
-
-
-

Project ready!

-

You may now add components and start building.

-

We've already added the button component for you.

- -
-
- (Press d to toggle dark mode) -
-
-
- ) -} diff --git a/base/components/auth-store-initializer.tsx b/base/components/auth-store-initializer.tsx new file mode 100644 index 0000000..c6df945 --- /dev/null +++ b/base/components/auth-store-initializer.tsx @@ -0,0 +1,19 @@ +"use client" + +import { useRef } from "react" +import { useAuthStore } from "@/shared/stores/auth-store" +import type { AuthUser } from "@repo/api" + +/** + * Synchronously initializes the auth store from server-side token/user before + * any child component renders. This avoids the first-render race condition where + * useEffect-based hydration hasn't fired yet and API requests go out without a token. + */ +export function AuthStoreInitializer({ token, user }: { token: string; user: AuthUser }) { + const initialized = useRef(false) + if (!initialized.current) { + initialized.current = true + useAuthStore.setState({ token, user, isAuthenticated: true }) + } + return null +} diff --git a/base/components/layout/dashboard/app-sidebar.tsx b/base/components/layout/dashboard/app-sidebar.tsx new file mode 100644 index 0000000..427afa6 --- /dev/null +++ b/base/components/layout/dashboard/app-sidebar.tsx @@ -0,0 +1,240 @@ +"use client" + +import Link from "next/link" +import { usePathname } from "next/navigation" +import { ChevronRight, Circle } from "lucide-react" + +import type { NavGroup, NavItem } from "@/base/types/navigation" +import { cn } from "@/shared/lib/utils" +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/shared/components/ui/collapsible" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/components/ui/dropdown-menu" +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + SidebarRail, + useSidebar, +} from "@/shared/components/ui/sidebar" + +type AppSidebarProps = React.ComponentProps & { + navGroups: NavGroup[] + logo?: React.ReactNode +} + +export function AppSidebar({ navGroups, logo, ...props }: AppSidebarProps) { + const { state, isMobile } = useSidebar() + const isCollapsed = state === "collapsed" && !isMobile + + return ( + + {logo && ( + + {logo} + + )} + + {navGroups.map((group, groupIndex) => ( + + {group.label && ( + + {group.label} + + )} + + {group.items.map((item) => + item.items && item.items.length > 0 ? ( + + ) : ( + + ) + )} + + + ))} + + + + ) +} + +function SimpleNavItem({ item, isCollapsed }: { item: NavItem; isCollapsed: boolean }) { + const pathname = usePathname() + const isActive = item.isActive ?? pathname === item.href + + return ( + + + + {item.icon} + { + !isCollapsed && + {item.title} + } + + + + ) +} + +function CollapsibleNavItem({ item, isCollapsed }: { item: NavItem; isCollapsed: boolean }) { + const pathname = usePathname() + const isChildActive = item.items?.some((sub) => pathname === sub.href) + const isActive = item.isActive ?? (pathname === item.href || isChildActive === true) + + // Collapsed sidebar → flyout dropdown with sub-items + if (isCollapsed) { + return ( + + + + + + {item.icon} + + { + !isCollapsed && + {item.title} + } + + + + + {item.title} + + + {item.items?.map((sub) => { + const isSubActive = sub.isActive ?? pathname === sub.href + return ( + + + {sub.icon ? ( + svg]:size-4", isSubActive ? "text-primary" : "text-muted-foreground/70")}> + {sub.icon} + + ) : ( + + )} + {sub.title} + + + ) + })} + + + + ) + } + + // Expanded sidebar → collapsible/accordion sub-menu + return ( + + + + + + {item.icon} + + + + {item.title} + + + + + + + {item.items?.map((sub) => { + const isSubActive = sub.isActive ?? pathname === sub.href + return ( + + + + {sub.icon ? ( + svg]:size-4", isSubActive ? "text-primary" : "text-muted-foreground/70 group-hover/menu-sub-item:text-foreground")}> + {sub.icon} + + ) : ( + + )} + {sub.title} + + + + ) + })} + + + + + ) +} diff --git a/base/components/layout/dashboard/dashboard-header.tsx b/base/components/layout/dashboard/dashboard-header.tsx new file mode 100644 index 0000000..17ab906 --- /dev/null +++ b/base/components/layout/dashboard/dashboard-header.tsx @@ -0,0 +1,210 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { useTheme } from "next-themes" +import { + BellIcon, + LogOutIcon, + MoonIcon, + SearchIcon, + SunIcon, + UserIcon, +} from "lucide-react" + +import type { UserInfo } from "@/base/types/navigation" +import { useAuthStore } from "@/shared/stores/auth-store" +import { cn } from "@/shared/lib/utils" +import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar" +import { Button } from "@/shared/components/ui/button" +import { SidebarTrigger } from "@/shared/components/ui/sidebar" +import { + CommandDialog, + Command, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, +} from "@/shared/components/ui/command" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/components/ui/dropdown-menu" +import { Separator } from "@/shared/components/ui/separator" + +type DashboardHeaderProps = { + user?: UserInfo + actions?: React.ReactNode + className?: string +} + +export function DashboardHeader({ actions, className }: DashboardHeaderProps) { + const { resolvedTheme, setTheme } = useTheme() + const [searchOpen, setSearchOpen] = useState(false) + const { logout, user } = useAuthStore((s) => s) + const router = useRouter() + + const handleLogout = useCallback(async () => { + await logout() + router.push("/login") + }, [logout, router]) + + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault() + setSearchOpen((prev) => !prev) + } + } + window.addEventListener("keydown", onKeyDown) + return () => window.removeEventListener("keydown", onKeyDown) + }, []) + + const toggleTheme = useCallback(() => { + setTheme(resolvedTheme === "dark" ? "light" : "dark") + }, [resolvedTheme, setTheme]) + + return ( +
+ {/* Sidebar toggle — mobile: hamburger, desktop: collapse */} + + + + {/* Left side — default actions */} +
+ {/* User dropdown */} + {/* {user && ( */} + + + + + + + {/* User info header */} + +
+ + {user?.avatar && } + + {user?.initials ?? user?.name.charAt(0).toUpperCase()} + + +
+ {user?.name} + {user?.email && ( + {user?.email} + )} + {user?.role && ( + {user?.role} + )} +
+
+
+ + + + + + + + Profile + + + + + + + + + Logout + +
+
+ {/* )} */} + + + {/* Search trigger */} + + + {/* Mobile search icon */} + + + {/* Theme toggle */} + + + {/* Notifications */} + +
+ + {/* Search command dialog */} + + + + + No results found. + + Dashboard + Job Cards + Customers + + + + + + {/* Right side — custom actions */} + {actions && ( +
{actions}
+ )} +
+ ) +} diff --git a/base/components/layout/dashboard/dashboard-layout.tsx b/base/components/layout/dashboard/dashboard-layout.tsx new file mode 100644 index 0000000..4152104 --- /dev/null +++ b/base/components/layout/dashboard/dashboard-layout.tsx @@ -0,0 +1,41 @@ +"use client" + +import type { NavGroup, UserInfo } from "@/base/types/navigation" +import { SidebarInset, SidebarProvider } from "@/shared/components/ui/sidebar" +import { TooltipProvider } from "@/shared/components/ui/tooltip" +import { AppSidebar } from "./app-sidebar" +import { DashboardHeader } from "./dashboard-header" + +type DashboardLayoutProps = { + children: React.ReactNode + /** Navigation groups rendered in the sidebar */ + navGroups: NavGroup[] + /** Logo element displayed at the top of the sidebar */ + logo?: React.ReactNode + /** Current user info shown in the header */ + user?: UserInfo + /** Custom actions rendered in the header (e.g. session timer, clock-in button) */ + headerActions?: React.ReactNode + /** Default sidebar open state */ + defaultOpen?: boolean +} + +export function DashboardLayout({ + children, + navGroups, + logo, + user, + headerActions, + defaultOpen = true, +}: DashboardLayoutProps) { + return ( + + + + + {children} + + + + ) +} diff --git a/base/components/layout/dashboard/dashboard-page.tsx b/base/components/layout/dashboard/dashboard-page.tsx new file mode 100644 index 0000000..08325ae --- /dev/null +++ b/base/components/layout/dashboard/dashboard-page.tsx @@ -0,0 +1,20 @@ +import { cn } from '@/shared/lib/utils' +import { title } from 'process' +import React from 'react' + +export default function DashboardPage({ children, header, title, fullscreen }: { children: React.ReactNode, header: React.ReactNode, title?: string, fullscreen?: boolean }) { + return ( +
+
+ {header} +
+
+ { + title && +

{title}

+ } + {children} +
+
+ ) +} diff --git a/base/components/layout/dashboard/index.ts b/base/components/layout/dashboard/index.ts new file mode 100644 index 0000000..21d6b95 --- /dev/null +++ b/base/components/layout/dashboard/index.ts @@ -0,0 +1,3 @@ +export { DashboardLayout } from "./dashboard-layout" +export { AppSidebar } from "./app-sidebar" +export { DashboardHeader } from "./dashboard-header" diff --git a/base/types/navigation.ts b/base/types/navigation.ts new file mode 100644 index 0000000..93fd3c0 --- /dev/null +++ b/base/types/navigation.ts @@ -0,0 +1,31 @@ +import { ReactNode } from "react" + + +export type NavItem = { + title: string + href: string + icon?: ReactNode + isActive?: boolean + badge?: string | number + items?: NavSubItem[] +} + +export type NavSubItem = { + title: string + href: string + icon?: ReactNode + isActive?: boolean +} + +export type NavGroup = { + label?: string + items: NavItem[] +} + +export type UserInfo = { + name: string + email?: string + avatar?: string + initials?: string + role?: string +} diff --git a/components.json b/components.json index f4ec6d1..900b31e 100644 --- a/components.json +++ b/components.json @@ -13,11 +13,11 @@ "iconLibrary": "lucide", "rtl": true, "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" + "components": "@/shared/components", + "utils": "@/shared/lib/utils", + "ui": "@/shared/components/ui", + "lib": "@/shared/lib", + "hooks": "@/shared/hooks" }, "menuColor": "default", "menuAccent": "subtle", diff --git a/cypress.config.ts b/cypress.config.ts new file mode 100644 index 0000000..830bcbc --- /dev/null +++ b/cypress.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "cypress" + +export default defineConfig({ + e2e: { + baseUrl: "http://localhost:3000", + env: { + NEXT_PUBLIC_API_URL: "https://newgarage.yslootahtech.com" + }, + specPattern: "cypress/e2e/**/*.cy.{ts,tsx}", + supportFile: "cypress/support/e2e.ts", + viewportWidth: 1280, + viewportHeight: 720, + defaultCommandTimeout: 10000, + requestTimeout: 10000, + }, +}) diff --git a/cypress/e2e/customers/customer-form-integration.cy.ts b/cypress/e2e/customers/customer-form-integration.cy.ts new file mode 100644 index 0000000..5141b99 --- /dev/null +++ b/cypress/e2e/customers/customer-form-integration.cy.ts @@ -0,0 +1,293 @@ +describe("Customer Form – Integration Tests", () => { + beforeEach(() => { + cy.login() + + cy.fixture("customers").then((data) => { + cy.intercept("GET", "**/api/referral-sources", { + statusCode: 200, + body: data.referral_sources, + }).as("getReferralSources") + + cy.intercept("GET", "**/api/payment-terms", { + statusCode: 200, + body: data.payment_terms, + }).as("getPaymentTerms") + + cy.intercept("GET", "**/api/countries", { + statusCode: 200, + body: data.countries, + }).as("getCountries") + + cy.intercept("GET", "**/api/states", { + statusCode: 200, + body: data.states, + }).as("getStates") + + cy.intercept("GET", "**/api/customers*", { + statusCode: 200, + body: { success: true, data: { data: [], pagination: { total: 0 } } }, + }).as("getCustomers") + }) + + cy.visit("/sales/customers") + cy.contains("button", "Create Customer").click() + cy.get("[role='dialog']").should("be.visible") + }) + + // ── Form interaction flow ── + + describe("Field interactions", () => { + it("should clear a text field after typing", () => { + cy.get("[role='dialog']").within(() => { + cy.get("input[name='first_name']") + .type("John") + .should("have.value", "John") + .clear() + .should("have.value", "") + }) + }) + + it("should handle special characters in text inputs", () => { + cy.get("[role='dialog']").within(() => { + cy.get("input[name='first_name']").type("José-María").should("have.value", "José-María") + cy.get("input[name='last_name']").type("O'Brien").should("have.value", "O'Brien") + cy.get("input[name='company_name']").type("Smith & Co.").should("have.value", "Smith & Co.") + }) + }) + + it("should accept various email formats", () => { + cy.get("[role='dialog']").within(() => { + cy.get("input[name='first_name']").type("Test") + cy.get("input[name='last_name']").type("User") + + // Valid email should not show error + cy.get("input[name='email']").type("user+tag@sub.domain.com") + cy.contains("button", "Create Customer").click() + cy.contains("Enter a valid email address").should("not.exist") + }) + }) + + it("should handle phone number input", () => { + cy.get("[role='dialog']").within(() => { + cy.get("input[name='phone']") + .type("0501234567") + .should("have.value", "0501234567") + + cy.get("input[name='alternate_phone']") + .type("+971501234567") + .should("have.value", "+971501234567") + }) + }) + }) + + // ── Async select integration ── + + describe("Async select fields", () => { + it("should show loading state while fetching referral sources", () => { + cy.intercept("GET", "**/api/referral-sources", { + statusCode: 200, + body: { success: true, data: { data: [{ id: 1, name: "Google" }] } }, + delay: 2000, + }).as("slowReferralSources") + + // Reload to get the delayed intercept + cy.visit("/sales/customers") + cy.contains("button", "Create Customer").click() + cy.get("[role='dialog']").should("be.visible") + + cy.get("[role='dialog']").within(() => { + cy.contains("label", "Referral Source").parent().find("input").click() + }) + + // The component should show a loading spinner + cy.get("[role='listbox']").should("be.visible") + }) + + it("should filter options by text input in combobox", () => { + cy.wait("@getReferralSources") + + cy.get("[role='dialog']").within(() => { + cy.contains("label", "Referral Source").parent().find("input").click().type("Goo") + }) + + // Should show Google, shouldn't show Friend Referral + cy.get("[role='option']").contains("Google").should("exist") + }) + + it("should show empty state when no options match", () => { + cy.wait("@getCountries") + + cy.get("[role='dialog']").within(() => { + cy.contains("label", "Country").parent().find("input").click().type("zzzzz") + }) + + cy.contains("No results found").should("be.visible") + }) + + it("should select a payment term from the combobox", () => { + cy.wait("@getPaymentTerms") + + cy.get("[role='dialog']").within(() => { + cy.contains("label", "Payment Terms").parent().find("input").click() + }) + + cy.get("[role='option']").contains("Net 30").click() + }) + + it("should select a state from the combobox", () => { + cy.wait("@getStates") + + cy.get("[role='dialog']").within(() => { + cy.contains("label", "State").parent().find("input").click() + }) + + cy.get("[role='option']").contains("Dubai").click() + }) + }) + + // ── Validation edge cases ── + + describe("Validation edge cases", () => { + it("should validate only on submit (not on blur)", () => { + cy.get("[role='dialog']").within(() => { + // Focus and blur first_name without typing + cy.get("input[name='first_name']").focus().blur() + + // Error should NOT appear yet (react-hook-form validates on submit by default) + cy.contains("First name is required").should("not.exist") + }) + }) + + it("should clear validation errors when user corrects input", () => { + cy.get("[role='dialog']").within(() => { + // Trigger validation + cy.contains("button", "Create Customer").click() + cy.contains("First name is required").should("be.visible") + + // Fix the error + cy.get("input[name='first_name']").type("John") + cy.contains("First name is required").should("not.exist") + }) + }) + + it("should trim whitespace-only inputs and still require first_name", () => { + cy.get("[role='dialog']").within(() => { + cy.get("input[name='first_name']").type(" ") + cy.get("input[name='last_name']").type("Doe") + cy.contains("button", "Create Customer").click() + }) + }) + + it("should allow submission with only required fields", () => { + cy.fixture("customers").then((data) => { + cy.intercept("POST", "**/api/customers", { + statusCode: 201, + body: data.customer_created, + }).as("createCustomer") + }) + + cy.get("[role='dialog']").within(() => { + cy.get("input[name='first_name']").type("Jane") + cy.get("input[name='last_name']").type("Smith") + cy.contains("button", "Create Customer").click() + }) + + cy.wait("@createCustomer").its("request.body").should((body) => { + expect(body.first_name).to.eq("Jane") + expect(body.last_name).to.eq("Smith") + // Optional fields should be empty or undefined + expect(body.company_name).to.satisfy( + (v: unknown) => v === "" || v === undefined || v === null, + ) + }) + }) + }) + + // ── API error scenarios ── + + describe("API error handling", () => { + it("should handle network error gracefully", () => { + cy.intercept("POST", "**/api/customers", { forceNetworkError: true }).as( + "networkError", + ) + + cy.get("[role='dialog']").within(() => { + cy.get("input[name='first_name']").type("John") + cy.get("input[name='last_name']").type("Doe") + cy.contains("button", "Create Customer").click() + }) + + cy.wait("@networkError") + + cy.get("[role='dialog']").within(() => { + cy.contains("Failed to create customer").should("be.visible") + }) + }) + + it("should handle 500 server error", () => { + cy.intercept("POST", "**/api/customers", { + statusCode: 500, + body: { success: false, message: "Internal server error" }, + }).as("serverError") + + cy.get("[role='dialog']").within(() => { + cy.get("input[name='first_name']").type("John") + cy.get("input[name='last_name']").type("Doe") + cy.contains("button", "Create Customer").click() + }) + + cy.wait("@serverError") + + cy.get("[role='dialog']").within(() => { + cy.contains("Failed to create customer").should("be.visible") + }) + }) + + it("should handle 422 validation error from server", () => { + cy.intercept("POST", "**/api/customers", { + statusCode: 422, + body: { + success: false, + message: "The email has already been taken.", + errors: { email: ["The email has already been taken."] }, + }, + }).as("validationError") + + cy.get("[role='dialog']").within(() => { + cy.get("input[name='first_name']").type("John") + cy.get("input[name='last_name']").type("Doe") + cy.get("input[name='email']").type("existing@example.com") + cy.contains("button", "Create Customer").click() + }) + + cy.wait("@validationError") + + cy.get("[role='dialog']").within(() => { + cy.contains("Failed to create customer").should("be.visible") + }) + }) + + it("should re-enable submit button after a failed request", () => { + cy.intercept("POST", "**/api/customers", { + statusCode: 422, + body: { + success: false, + message: "Validation failed", + errors: {}, + }, + }).as("failedRequest") + + cy.get("[role='dialog']").within(() => { + cy.get("input[name='first_name']").type("John") + cy.get("input[name='last_name']").type("Doe") + cy.contains("button", "Create Customer").click() + }) + + cy.wait("@failedRequest") + + cy.get("[role='dialog']").within(() => { + cy.contains("button", "Create Customer").should("not.be.disabled") + }) + }) + }) +}) diff --git a/cypress/e2e/customers/customer-form.cy.ts b/cypress/e2e/customers/customer-form.cy.ts new file mode 100644 index 0000000..7779ca5 --- /dev/null +++ b/cypress/e2e/customers/customer-form.cy.ts @@ -0,0 +1,347 @@ +describe("Customer Form", () => { + beforeEach(() => { + // Authenticate via API and set cookies + cy.login() + + // Intercept lookup APIs with fixture data + cy.fixture("customers").then((data) => { + cy.intercept("GET", "**/api/referral-sources", { + statusCode: 200, + body: data.referral_sources, + }).as("getReferralSources") + + cy.intercept("GET", "**/api/payment-terms", { + statusCode: 200, + body: data.payment_terms, + }).as("getPaymentTerms") + + cy.intercept("GET", "**/api/countries", { + statusCode: 200, + body: data.countries, + }).as("getCountries") + + cy.intercept("GET", "**/api/states", { + statusCode: 200, + body: data.states, + }).as("getStates") + + // Intercept customer list (GET) for the data table + cy.intercept("GET", "**/api/customers*", { + statusCode: 200, + body: { success: true, data: { data: [], pagination: { total: 0 } } }, + }).as("getCustomers") + }) + + cy.visit("/sales/customers") + }) + + function openCustomerDialog() { + cy.contains("button", "Create Customer").click() + cy.get("[role='dialog']").should("be.visible") + } + + // ── Rendering ── + + it("should open the create customer dialog", () => { + openCustomerDialog() + + cy.get("[role='dialog']").within(() => { + cy.contains("Create Customer").should("exist") + }) + }) + + it("should display all form fields", () => { + openCustomerDialog() + + cy.get("[role='dialog']").within(() => { + // Text fields + cy.get("input[name='first_name']").should("exist") + cy.get("input[name='last_name']").should("exist") + cy.get("input[name='company_name']").should("exist") + cy.get("input[name='email']").should("exist") + cy.get("input[name='phone']").should("exist") + cy.get("input[name='alternate_phone']").should("exist") + cy.get("input[name='address_line_1']").should("exist") + cy.get("input[name='address_line_2']").should("exist") + cy.get("input[name='city']").should("exist") + cy.get("input[name='zip_code']").should("exist") + + // Labels + cy.contains("label", "First Name").should("exist") + cy.contains("label", "Last Name").should("exist") + cy.contains("label", "Email").should("exist") + cy.contains("label", "Salutation").should("exist") + cy.contains("label", "Customer Type").should("exist") + cy.contains("label", "Referral Source").should("exist") + cy.contains("label", "Payment Terms").should("exist") + cy.contains("label", "Country").should("exist") + cy.contains("label", "State").should("exist") + }) + }) + + // ── Validation ── + + it("should show validation errors for required fields", () => { + openCustomerDialog() + + cy.get("[role='dialog']").within(() => { + cy.contains("button", "Create Customer").click() + + // first_name and last_name are required + cy.contains("First name is required").should("be.visible") + cy.contains("Last name is required").should("be.visible") + }) + }) + + it("should show email validation error for invalid email", () => { + openCustomerDialog() + + cy.get("[role='dialog']").within(() => { + cy.get("input[name='email']").type("not-an-email") + cy.contains("button", "Create Customer").click() + + cy.contains("Enter a valid email address").should("be.visible") + }) + }) + + it("should not show email error when email is empty", () => { + openCustomerDialog() + + cy.get("[role='dialog']").within(() => { + cy.get("input[name='first_name']").type("John") + cy.get("input[name='last_name']").type("Doe") + cy.contains("button", "Create Customer").click() + + cy.contains("Enter a valid email address").should("not.exist") + }) + }) + + // ── Text input ── + + it("should fill in text fields", () => { + openCustomerDialog() + + cy.get("[role='dialog']").within(() => { + cy.get("input[name='first_name']").type("John").should("have.value", "John") + cy.get("input[name='last_name']").type("Doe").should("have.value", "Doe") + cy.get("input[name='company_name']").type("Acme Corp").should("have.value", "Acme Corp") + cy.get("input[name='email']").type("john@example.com").should("have.value", "john@example.com") + cy.get("input[name='phone']").type("0501234567").should("have.value", "0501234567") + cy.get("input[name='address_line_1']").type("123 Main St").should("have.value", "123 Main St") + cy.get("input[name='city']").type("Dubai").should("have.value", "Dubai") + cy.get("input[name='zip_code']").type("00000").should("have.value", "00000") + }) + }) + + // ── Select fields ── + + it("should select a salutation from the dropdown", () => { + openCustomerDialog() + + cy.get("[role='dialog']").within(() => { + // Click the Salutation select trigger + cy.contains("label", "Salutation") + .parent() + .find("[role='combobox'], button[data-slot='select-trigger']") + .click() + }) + + // Select option from the popover (may render outside the dialog) + cy.get("[role='option'], [role='listbox'] [data-value='Mr']") + .contains("Mr") + .click() + }) + + it("should select a customer type", () => { + openCustomerDialog() + + cy.get("[role='dialog']").within(() => { + cy.contains("label", "Customer Type") + .parent() + .find("[role='combobox'], button[data-slot='select-trigger']") + .click() + }) + + cy.get("[role='option']").contains("Individual").click() + }) + + // ── Async select (Combobox) fields ── + + it("should load and select a referral source", () => { + openCustomerDialog() + + cy.wait("@getReferralSources") + + cy.get("[role='dialog']").within(() => { + cy.contains("label", "Referral Source") + .parent() + .find("input") + .click() + .type("Google") + }) + + cy.get("[role='option']").contains("Google").click() + }) + + it("should load and select a country", () => { + openCustomerDialog() + + cy.wait("@getCountries") + + cy.get("[role='dialog']").within(() => { + cy.contains("label", "Country") + .parent() + .find("input") + .click() + .type("United") + }) + + cy.get("[role='option']").contains("United Arab Emirates").click() + }) + + // ── Successful submission ── + + it("should submit the form successfully with required fields", () => { + cy.fixture("customers").then((data) => { + cy.intercept("POST", "**/api/customers", { + statusCode: 201, + body: data.customer_created, + }).as("createCustomer") + }) + + openCustomerDialog() + + cy.get("[role='dialog']").within(() => { + cy.get("input[name='first_name']").type("John") + cy.get("input[name='last_name']").type("Doe") + + cy.contains("button", "Create Customer").click() + }) + + cy.wait("@createCustomer").its("request.body").should((body) => { + expect(body.first_name).to.eq("John") + expect(body.last_name).to.eq("Doe") + }) + }) + + it("should submit a fully filled form", () => { + cy.fixture("customers").then((data) => { + cy.intercept("POST", "**/api/customers", { + statusCode: 201, + body: data.customer_created, + }).as("createCustomer") + }) + + openCustomerDialog() + + // Wait for async data + cy.wait("@getReferralSources") + cy.wait("@getPaymentTerms") + cy.wait("@getCountries") + cy.wait("@getStates") + + cy.get("[role='dialog']").within(() => { + // Text fields + cy.get("input[name='first_name']").type("John") + cy.get("input[name='last_name']").type("Doe") + cy.get("input[name='company_name']").type("Doe Holdings") + cy.get("input[name='email']").type("john@example.com") + cy.get("input[name='phone']").type("0501234567") + cy.get("input[name='alternate_phone']").type("0551234567") + cy.get("input[name='address_line_1']").type("Street 10") + cy.get("input[name='address_line_2']").type("Near Central Plaza") + cy.get("input[name='city']").type("Dubai") + cy.get("input[name='zip_code']").type("00000") + + // Submit + cy.contains("button", "Create Customer").click() + }) + + cy.wait("@createCustomer").its("request.body").should((body) => { + expect(body.first_name).to.eq("John") + expect(body.last_name).to.eq("Doe") + expect(body.company_name).to.eq("Doe Holdings") + expect(body.email).to.eq("john@example.com") + expect(body.phone).to.eq("0501234567") + }) + }) + + // ── Error handling ── + + it("should display API error on submission failure", () => { + cy.intercept("POST", "**/api/customers", { + statusCode: 422, + body: { + success: false, + message: "The given data was invalid.", + errors: { email: ["The email has already been taken."] }, + }, + }).as("createCustomerFail") + + openCustomerDialog() + + cy.get("[role='dialog']").within(() => { + cy.get("input[name='first_name']").type("John") + cy.get("input[name='last_name']").type("Doe") + cy.get("input[name='email']").type("john@example.com") + + cy.contains("button", "Create Customer").click() + }) + + cy.wait("@createCustomerFail") + + cy.get("[role='dialog']").within(() => { + cy.contains("Failed to create customer").should("be.visible") + }) + }) + + it("should show loading state while submitting", () => { + cy.intercept("POST", "**/api/customers", { + statusCode: 201, + body: { success: true, data: { id: 1 } }, + delay: 1000, + }).as("createCustomerSlow") + + openCustomerDialog() + + cy.get("[role='dialog']").within(() => { + cy.get("input[name='first_name']").type("John") + cy.get("input[name='last_name']").type("Doe") + + cy.contains("button", "Create Customer").click() + + // Button should show loading text and be disabled + cy.contains("button", "Creating...").should("be.visible").and("be.disabled") + }) + }) + + // ── Form reset after success ── + + it("should reset the form after successful submission", () => { + cy.fixture("customers").then((data) => { + cy.intercept("POST", "**/api/customers", { + statusCode: 201, + body: data.customer_created, + }).as("createCustomer") + }) + + openCustomerDialog() + + cy.get("[role='dialog']").within(() => { + cy.get("input[name='first_name']").type("John") + cy.get("input[name='last_name']").type("Doe") + + cy.contains("button", "Create Customer").click() + }) + + cy.wait("@createCustomer") + + // After success, re-open the dialog and verify fields are empty + openCustomerDialog() + + cy.get("[role='dialog']").within(() => { + cy.get("input[name='first_name']").should("have.value", "") + cy.get("input[name='last_name']").should("have.value", "") + }) + }) +}) diff --git a/cypress/fixtures/customers.json b/cypress/fixtures/customers.json new file mode 100644 index 0000000..b232d94 --- /dev/null +++ b/cypress/fixtures/customers.json @@ -0,0 +1,47 @@ +{ + "referral_sources": { + "success": true, + "data": { + "data": [ + { "id": 1, "name": "Google" }, + { "id": 2, "name": "Friend Referral" }, + { "id": 3, "name": "Social Media" } + ] + } + }, + "payment_terms": { + "success": true, + "data": { + "data": [ + { "id": 1, "name": "Net 30" }, + { "id": 2, "name": "Net 60" }, + { "id": 3, "name": "Due on Receipt" } + ] + } + }, + "countries": { + "success": true, + "data": [ + { "id": 1, "name": "United Arab Emirates" }, + { "id": 2, "name": "Saudi Arabia" }, + { "id": 3, "name": "United States" } + ] + }, + "states": { + "success": true, + "data": [ + { "id": 1, "name": "Dubai" }, + { "id": 2, "name": "Abu Dhabi" }, + { "id": 3, "name": "Sharjah" } + ] + }, + "customer_created": { + "success": true, + "data": { + "id": 101, + "first_name": "John", + "last_name": "Doe", + "email": "john@example.com" + } + } +} diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts new file mode 100644 index 0000000..c1dd11e --- /dev/null +++ b/cypress/support/commands.ts @@ -0,0 +1,33 @@ +/// + +declare global { + namespace Cypress { + interface Chainable { + /** + * Log in via the API and set auth cookies so the app + * recognises the user as authenticated. + */ + login(email?: string, password?: string): Chainable + } + } +} + +Cypress.Commands.add("login", (email?: string, password?: string) => { + const userEmail = email ?? Cypress.env("TEST_USER_EMAIL") ?? "admin@admin.com" + const userPassword = password ?? Cypress.env("TEST_USER_PASSWORD") ?? "12345678" + + cy.request({ + method: "POST", + url: `${Cypress.env("API_URL") ?? "http://localhost:8000"}/api/login`, + body: { email: userEmail, password: userPassword }, + }).then((response) => { + const { token, user } = response.body + + cy.setCookie("auth_token", token, { path: "/" }) + cy.setCookie("auth_user", encodeURIComponent(JSON.stringify(user)), { + path: "/", + }) + }) +}) + +export {} diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts new file mode 100644 index 0000000..b7cb303 --- /dev/null +++ b/cypress/support/e2e.ts @@ -0,0 +1 @@ +import "./commands" diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json new file mode 100644 index 0000000..3b29d25 --- /dev/null +++ b/cypress/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["ES2017", "DOM"], + "types": ["cypress"], + "moduleResolution": "bundler", + "module": "ESNext", + "strict": true, + "baseUrl": ".", + "paths": { + "@/*": ["../../*"] + } + }, + "include": ["**/*.ts", "../support/**/*.ts"] +} diff --git a/lib/utils.ts b/lib/utils.ts deleted file mode 100644 index bd0c391..0000000 --- a/lib/utils.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) -} diff --git a/modules/auth/auth.actions.ts b/modules/auth/auth.actions.ts new file mode 100644 index 0000000..c240332 --- /dev/null +++ b/modules/auth/auth.actions.ts @@ -0,0 +1,55 @@ +"use server" + +import { cookies } from "next/headers" +import type { AuthUser } from "@repo/api" + +const TOKEN_COOKIE = "auth_token" +const USER_COOKIE = "auth_user" +const DEFAULT_EXPIRES_IN = 60 * 60 * 24 * 7 // 7 days in seconds + +export async function setAuthCookies( + token: string, + user: AuthUser, + expiresIn: number = DEFAULT_EXPIRES_IN, +) { + const cookieStore = await cookies() + const expires = new Date(Date.now() + expiresIn * 1000) + + cookieStore.set(TOKEN_COOKIE, token, { + expires, + path: "/", + sameSite: "strict", + }) + + cookieStore.set(USER_COOKIE, JSON.stringify(user), { + expires, + path: "/", + sameSite: "strict", + }) +} + +export async function clearAuthCookies() { + const cookieStore = await cookies() + cookieStore.delete(TOKEN_COOKIE) + cookieStore.delete(USER_COOKIE) +} + +export async function getAuthCookies(): Promise<{ + token: string | undefined + user: AuthUser | undefined +}> { + const cookieStore = await cookies() + const token = cookieStore.get(TOKEN_COOKIE)?.value + const rawUser = cookieStore.get(USER_COOKIE)?.value + + let user: AuthUser | undefined + if (rawUser) { + try { + user = JSON.parse(rawUser) as AuthUser + } catch { + user = undefined + } + } + + return { token, user } +} diff --git a/modules/auth/login-form.schema.ts b/modules/auth/login-form.schema.ts new file mode 100644 index 0000000..dbe2b80 --- /dev/null +++ b/modules/auth/login-form.schema.ts @@ -0,0 +1,11 @@ +import { z } from "zod" + +const loginFormSchema = z.object({ + email: z.string().trim().email("Enter a valid email address"), + password: z.string().min(8, "Password must be at least 8 characters"), +}) + +type LoginFormValues = z.infer + +export { loginFormSchema } +export type { LoginFormValues } \ No newline at end of file diff --git a/modules/auth/login-form.tsx b/modules/auth/login-form.tsx new file mode 100644 index 0000000..ace4b97 --- /dev/null +++ b/modules/auth/login-form.tsx @@ -0,0 +1,148 @@ +"use client" + +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { Button } from "@/shared/components/ui/button" +import { api } from '@repo/api' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/shared/components/ui/card" +import { + Field, + FieldDescription, + FieldError, + FieldGroup, + FieldLabel, +} from "@/shared/components/ui/field" +import { Input } from "@/shared/components/ui/input" +import { useAppStore } from "@/shared/stores/app-store" +import { useAuthStore } from "@/shared/stores/auth-store" +import { cn } from "@/shared/lib/utils" +import Image from "next/image" +import { useRouter } from "next/navigation" + +import { loginFormSchema, type LoginFormValues } from "./login-form.schema" +import { useMutation } from "@tanstack/react-query" +import { Alert, AlertTitle } from "@/shared/components/ui/alert" +import { AlertTriangle } from "lucide-react" + +export function LoginForm({ + className, + ...props +}: React.ComponentProps<"div">) { + const lastLoginEmail = useAppStore((state) => state.lastLoginEmail) + const setLastLoginEmail = useAppStore((state) => state.setLastLoginEmail) + const login = useAuthStore((state) => state.login) + const router = useRouter() + const { + handleSubmit, + register, + formState: { errors, }, + } = useForm({ + resolver: zodResolver(loginFormSchema), + defaultValues: process.env.NODE_ENV === "development" ? { + "email": "admin@admin.com", + "password": "12345678" + } : { + email: lastLoginEmail, + password: "", + }, + }) + + const { mutate, error, isPending: isSubmitting } = useMutation({ + mutationFn: (values: LoginFormValues) => api.auth.login(values), + onSuccess: async (data) => { + if (data.token && data.user) { + await login(data.token, data.user as Parameters[1]) + router.push("/") + } + }, + }) + + + async function onSubmit(values: LoginFormValues) { + setLastLoginEmail(values.email) + mutate(values) + } + + return ( +
+ + + Logo + Login to your account + + Enter your email below to login to your account + + + + {error ? ( + + + Login failed + {error.message} + + ) : null} + +
+ + + Email + + + + + + + + + + + + {lastLoginEmail ? ( + + Last email used: {lastLoginEmail} + + ) : null} + {/* + Don't have an account? Sign up + */} + + +
+
+
+
+ ) +} diff --git a/modules/customers/customer-form.tsx b/modules/customers/customer-form.tsx new file mode 100644 index 0000000..2779646 --- /dev/null +++ b/modules/customers/customer-form.tsx @@ -0,0 +1,264 @@ +"use client" + +import { AlertTriangle, Plus, Save } from "lucide-react" + +import { Button } from "@/shared/components/ui/button" +import { Alert, AlertTitle } from "@/shared/components/ui/alert" +import { FieldGroup } from "@/shared/components/ui/field" +import { + Rhform, + RhfTextField, + RhfSelectField, + RhfAsyncSelectField, +} from "@/shared/components/form" +import { toast } from "sonner" +import { useAuthApi } from "@/shared/useApi" +import { useResourceForm } from "@/shared/hooks/use-resource-form" +import { useFormMutation } from "@/shared/hooks/use-form-mutation" +import { toRelation, toId } from "@/shared/lib/utils" + +import { + customerFormSchema, + type CustomerFormValues, +} from "./customer.schema" +import { CUSTOMER_ROUTES } from "@repo/api" + +// ── Constants ── + +const SALUTATION_OPTIONS = [ + { value: "Mr.", label: "Mr." }, + { value: "Mrs.", label: "Mrs." }, + { value: "Ms.", label: "Ms." }, + { value: "Miss", label: "Miss" }, + { value: "Dr.", label: "Dr." }, + { value: "Prof.", label: "Prof." }, +] + +// ── Props ── + +export type CustomerFormProps = { + resourceId?: string | null + initialData?: unknown + onSuccess?: () => void +} + +// ── Default values ── + +const CUSTOMER_DEFAULT_VALUES: CustomerFormValues = { + customer_type: null, + referral_source: null, + payment_terms: null, + country: null, + state: null, + salutation: "", + first_name: "", + last_name: "", + company_name: "", + email: "", + phone: "", + alternate_phone: "", + address_line_1: "", + address_line_2: "", + city: "", + zip_code: "", +} + +// ── Mapping helpers ── + +function mapCustomerToFormValues(data: unknown): CustomerFormValues { + const c = (data as any)?.data ?? data ?? {} + + return { + customer_type: toRelation(c.customer_type_id, c.customer_type_name), + referral_source: toRelation(c.referral_source_id, c.referral_source_name), + payment_terms: toRelation(c.payment_terms_id, c.payment_terms_name), + country: toRelation(c.country_id, c.country_name), + state: toRelation(c.state_id, c.state_name), + salutation: c.salutation || "", + first_name: c.first_name || "", + last_name: c.last_name || "", + company_name: c.company_name || "", + email: c.email || "", + phone: c.phone || "", + alternate_phone: c.alternate_phone || "", + address_line_1: c.address_line_1 || "", + address_line_2: c.address_line_2 || "", + city: c.city || "", + zip_code: c.zip_code || "", + } +} + +function mapFormToPayload(values: CustomerFormValues) { + return { + customer_type_id: toId(values.customer_type), + referral_source_id: toId(values.referral_source), + payment_terms_id: toId(values.payment_terms), + country_id: toId(values.country), + state_id: toId(values.state), + salutation: values.salutation || undefined, + first_name: values.first_name, + last_name: values.last_name, + company_name: values.company_name || undefined, + email: values.email || undefined, + phone: values.phone || undefined, + alternate_phone: values.alternate_phone || undefined, + address_line_1: values.address_line_1 || undefined, + address_line_2: values.address_line_2 || undefined, + city: values.city || undefined, + zip_code: values.zip_code || undefined, + } +} + +// ── Shared mapOption for async selects ── + +const mapLookupOption = (item: any) => ({ + value: String(item.id), + label: item.name, +}) + +const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label } + +// ── Component ── + +export function CustomerForm({ resourceId, initialData, onSuccess }: CustomerFormProps) { + const api = useAuthApi() + + const { form, isEditing } = useResourceForm({ + schema: customerFormSchema, + defaultValues: CUSTOMER_DEFAULT_VALUES, + resourceId, + initialData, + initialize: (id) => api.customers.show(id), + queryKey: [CUSTOMER_ROUTES.BY_ID, resourceId], + mapToFormValues: mapCustomerToFormValues, + }) + + const { mutate, error, isPending } = useFormMutation(form, { + mutationFn: (values: CustomerFormValues) => { + const payload = mapFormToPayload(values) + const promise = isEditing && resourceId + ? api.customers.update(resourceId, payload) + : api.customers.create(payload) + toast.promise(promise, { + loading: isEditing ? "Updating customer..." : "Creating customer...", + success: isEditing ? "Customer updated successfully" : "Customer created successfully", + error: isEditing ? "Failed to update customer" : "Failed to create customer", + }) + return promise + }, + onSuccess: () => { + form.reset() + onSuccess?.() + }, + }) + + return ( + mutate(values)}> + {error && ( + + + {isEditing ? "Failed to update customer" : "Failed to create customer"} + {error.message} + + )} + + + + {/* Basic Info */} +
+ + api.customers.listCustomerTypes()} + mapOption={mapLookupOption} + {...STORE_OBJECT} + /> +
+ + + {/* Name */} +
+ + +
+ + + + {/* Contact */} +
+ + +
+ + + + {/* Relations */} +
+ api.referralSources.list()} + mapOption={mapLookupOption} + {...STORE_OBJECT} + /> + api.paymentTerms.list()} + mapOption={mapLookupOption} + {...STORE_OBJECT} + /> +
+ + {/* Address */} + + + +
+ api.geo.countries()} + mapOption={mapLookupOption} + {...STORE_OBJECT} + /> + api.geo.states()} + mapOption={mapLookupOption} + {...STORE_OBJECT} + /> +
+ +
+ + +
+ + +
+
+ ) +} \ No newline at end of file diff --git a/modules/customers/customer.schema.ts b/modules/customers/customer.schema.ts new file mode 100644 index 0000000..b17b582 --- /dev/null +++ b/modules/customers/customer.schema.ts @@ -0,0 +1,44 @@ +import { z } from "zod" + +/** + * Reusable schema for relation/lookup fields stored as `{ value, label }` objects. + * Use `.nullable()` when the field is optional but explicitly clearable. + */ +const relationFieldSchema = z + .object({ value: z.string(), label: z.string() }) + .nullable() + +type RelationField = z.infer + +const customerFormSchema = z.object({ + // ── Relations (stored as objects, mapped to IDs on submit) ── + customer_type: relationFieldSchema, + referral_source: relationFieldSchema, + payment_terms: relationFieldSchema, + country: relationFieldSchema, + state: relationFieldSchema, + + // ── Basic info ── + salutation: z.string().optional(), + first_name: z.string().min(1, "First name is required"), + last_name: z.string().min(1, "Last name is required"), + company_name: z.string().optional(), + + // ── Contact ── + email: z + .union([z.string().email("Enter a valid email address"), z.literal("")]) + .optional(), + phone: z.string().optional(), + alternate_phone: z.string().optional(), + + // ── Address ── + address_line_1: z.string().optional(), + address_line_2: z.string().optional(), + city: z.string().optional(), + zip_code: z.string().optional(), +}) + +type CustomerFormValues = z.infer + +export { customerFormSchema, relationFieldSchema } +export type { CustomerFormValues, RelationField } \ No newline at end of file diff --git a/modules/settings/shop-type/shop-type-form.tsx b/modules/settings/shop-type/shop-type-form.tsx new file mode 100644 index 0000000..328fc1b --- /dev/null +++ b/modules/settings/shop-type/shop-type-form.tsx @@ -0,0 +1,157 @@ +"use client" + +import { AlertTriangle, Plus, Save } from "lucide-react" + +import { Button } from "@/shared/components/ui/button" +import { Alert, AlertTitle } from "@/shared/components/ui/alert" +import { FieldGroup } from "@/shared/components/ui/field" +import { + Rhform, + RhfTextField, + RhfTextareaField, + RhfCheckboxField, + RhfFileField, +} from "@/shared/components/form" +import { toast } from "sonner" +import { useAuthApi } from "@/shared/useApi" +import { useResourceForm } from "@/shared/hooks/use-resource-form" +import { useFormMutation } from "@/shared/hooks/use-form-mutation" + +import { shopTypeFormSchema, type ShopTypeFormValues } from "./shop-type.schema" +import { SHOP_TYPE_ROUTES } from "@repo/api" + +// ── Props ── + +export type ShopTypeFormProps = { + resourceId?: string | null + initialData?: unknown + onSuccess?: () => void +} + +// ── Default values ── + +const DEFAULT_VALUES: ShopTypeFormValues = { + title: "", + shop_type: "", + note: "", + is_default: false, + inspection: null, + image: null, +} + +// ── Mapping helpers ── + +function mapToFormValues(data: unknown): ShopTypeFormValues { + const d = (data as any)?.data ?? data ?? {} + + return { + title: d.title || "", + shop_type: d.shop_type || "", + note: d.note || "", + is_default: d.is_default ?? false, + // File fields cannot be pre-filled from URL strings + inspection: null, + image: null, + } +} + +function mapFormToPayload(values: ShopTypeFormValues) { + return { + title: values.title, + shop_type: values.shop_type || undefined, + note: values.note || undefined, + is_default: values.is_default, + inspection: values.inspection ?? undefined, + image: values.image ?? undefined, + } +} + +// ── Component ── + +export function ShopTypeForm({ resourceId, initialData, onSuccess }: ShopTypeFormProps) { + const api = useAuthApi() + + const { form, isEditing } = useResourceForm({ + schema: shopTypeFormSchema, + defaultValues: DEFAULT_VALUES, + resourceId, + initialData, + mapToFormValues, + }) + + const { mutate, error, isPending } = useFormMutation(form, { + mutationFn: (values: ShopTypeFormValues) => { + const payload = mapFormToPayload(values) + const promise = isEditing && resourceId + ? api.shopTypes.update(resourceId, payload) + : api.shopTypes.create({ ...payload, title: values.title }) + toast.promise(promise, { + loading: isEditing ? "Updating shop type..." : "Creating shop type...", + success: isEditing ? "Shop type updated successfully" : "Shop type created successfully", + error: isEditing ? "Failed to update shop type" : "Failed to create shop type", + }) + return promise + }, + onSuccess: () => { + form.reset() + onSuccess?.() + }, + }) + + return ( + mutate(values)}> + {error && ( + + + + {isEditing ? "Failed to update shop type" : "Failed to create shop type"} + + {error.message} + + )} + + + + + + +
+ + +
+ + +
+
+ ) +} diff --git a/modules/settings/shop-type/shop-type.schema.ts b/modules/settings/shop-type/shop-type.schema.ts new file mode 100644 index 0000000..19c5690 --- /dev/null +++ b/modules/settings/shop-type/shop-type.schema.ts @@ -0,0 +1,19 @@ +import { z } from "zod" + +export const shopTypeFormSchema = z.object({ + title: z.string().min(1, "Title is required"), + shop_type: z.string().optional(), + note: z.string().optional(), + is_default: z.boolean().optional(), + inspection: z.any().optional(), + image: z.any().optional(), +}) + +export type ShopTypeFormValues = { + title: string + shop_type?: string + note?: string + is_default?: boolean + inspection?: File | null + image?: File | null +} diff --git a/modules/vehicles/inline-forms/body-type-inline-form.tsx b/modules/vehicles/inline-forms/body-type-inline-form.tsx new file mode 100644 index 0000000..4d0507c --- /dev/null +++ b/modules/vehicles/inline-forms/body-type-inline-form.tsx @@ -0,0 +1,55 @@ +"use client" + +import { z } from "zod" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Plus } from "lucide-react" +import { Button } from "@/shared/components/ui/button" +import { FieldGroup } from "@/shared/components/ui/field" +import { Rhform, RhfTextField, type InlineCreateFormProps } from "@/shared/components/form" +import { toast } from "sonner" +import { useAuthApi } from "@/shared/useApi" + +const schema = z.object({ + title: z.string().min(1, "Title is required"), +}) + +type FormValues = z.infer + +export function BodyTypeInlineForm({ onSuccess }: InlineCreateFormProps) { + const api = useAuthApi() + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { title: "" }, + }) + + const handleSubmit = async (values: FormValues) => { + try { + const result = await api.vehicleAttributes.createBodyType({ title: values.title }) + toast.success("Body type created") + form.reset() + const item = (result as any)?.data ?? result + onSuccess({ value: String(item.id), label: item.title ?? String(item.id) }) + } catch { + toast.error("Failed to create body type") + } + } + + return ( + + + + + + + ) +} diff --git a/modules/vehicles/inline-forms/color-inline-form.tsx b/modules/vehicles/inline-forms/color-inline-form.tsx new file mode 100644 index 0000000..1e97e71 --- /dev/null +++ b/modules/vehicles/inline-forms/color-inline-form.tsx @@ -0,0 +1,55 @@ +"use client" + +import { z } from "zod" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Plus } from "lucide-react" +import { Button } from "@/shared/components/ui/button" +import { FieldGroup } from "@/shared/components/ui/field" +import { Rhform, RhfTextField, type InlineCreateFormProps } from "@/shared/components/form" +import { toast } from "sonner" +import { useAuthApi } from "@/shared/useApi" + +const schema = z.object({ + title: z.string().min(1, "Title is required"), +}) + +type FormValues = z.infer + +export function ColorInlineForm({ onSuccess }: InlineCreateFormProps) { + const api = useAuthApi() + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { title: "" }, + }) + + const handleSubmit = async (values: FormValues) => { + try { + const result = await api.vehicleAttributes.createColor({ title: values.title }) + toast.success("Color created") + form.reset() + const item = (result as any)?.data ?? result + onSuccess({ value: String(item.id), label: item.title ?? String(item.id) }) + } catch { + toast.error("Failed to create color") + } + } + + return ( + + + + + + + ) +} diff --git a/modules/vehicles/inline-forms/fuel-type-inline-form.tsx b/modules/vehicles/inline-forms/fuel-type-inline-form.tsx new file mode 100644 index 0000000..0f3cc0c --- /dev/null +++ b/modules/vehicles/inline-forms/fuel-type-inline-form.tsx @@ -0,0 +1,55 @@ +"use client" + +import { z } from "zod" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Plus } from "lucide-react" +import { Button } from "@/shared/components/ui/button" +import { FieldGroup } from "@/shared/components/ui/field" +import { Rhform, RhfTextField, type InlineCreateFormProps } from "@/shared/components/form" +import { toast } from "sonner" +import { useAuthApi } from "@/shared/useApi" + +const schema = z.object({ + title: z.string().min(1, "Title is required"), +}) + +type FormValues = z.infer + +export function FuelTypeInlineForm({ onSuccess }: InlineCreateFormProps) { + const api = useAuthApi() + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { title: "" }, + }) + + const handleSubmit = async (values: FormValues) => { + try { + const result = await api.vehicleAttributes.createFuelType({ title: values.title }) + toast.success("Fuel type created") + form.reset() + const item = (result as any)?.data ?? result + onSuccess({ value: String(item.id), label: item.title ?? String(item.id) }) + } catch { + toast.error("Failed to create fuel type") + } + } + + return ( + + + + + + + ) +} diff --git a/modules/vehicles/inline-forms/shop-type-inline-form.tsx b/modules/vehicles/inline-forms/shop-type-inline-form.tsx new file mode 100644 index 0000000..6cdbac4 --- /dev/null +++ b/modules/vehicles/inline-forms/shop-type-inline-form.tsx @@ -0,0 +1,114 @@ +"use client" + +import { z } from "zod" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Plus } from "lucide-react" +import { Button } from "@/shared/components/ui/button" +import { FieldGroup } from "@/shared/components/ui/field" +import { + Rhform, + RhfTextField, + RhfTextareaField, + RhfCheckboxField, + RhfFileField, + type InlineCreateFormProps, +} from "@/shared/components/form" +import { toast } from "sonner" +import { useAuthApi } from "@/shared/useApi" + +const schema = z.object({ + title: z.string().min(1, "Title is required"), + shop_type: z.string().optional(), + note: z.string().optional(), + is_default: z.boolean().optional(), + inspection: z.any().optional(), + image: z.any().optional(), +}) + +type FormValues = { + title: string + shop_type?: string + note?: string + is_default?: boolean + inspection?: File | null + image?: File | null +} + +export function ShopTypeInlineForm({ onSuccess }: InlineCreateFormProps) { + const api = useAuthApi() + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + title: "", + shop_type: "", + note: "", + is_default: false, + inspection: null, + image: null, + }, + }) + + const handleSubmit = async (values: FormValues) => { + try { + const result = await api.shopTypes.create({ + title: values.title, + shop_type: values.shop_type || undefined, + note: values.note || undefined, + is_default: values.is_default, + inspection: values.inspection ?? undefined, + image: values.image ?? undefined, + }) + toast.success("Shop type created") + form.reset() + const item = (result as any)?.data ?? result + onSuccess({ value: String(item.id), label: item.title ?? String(item.id) }) + } catch { + toast.error("Failed to create shop type") + } + } + + return ( + + + + + + + + + + + + ) +} + diff --git a/modules/vehicles/inline-forms/transmission-inline-form.tsx b/modules/vehicles/inline-forms/transmission-inline-form.tsx new file mode 100644 index 0000000..f21c98b --- /dev/null +++ b/modules/vehicles/inline-forms/transmission-inline-form.tsx @@ -0,0 +1,55 @@ +"use client" + +import { z } from "zod" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Plus } from "lucide-react" +import { Button } from "@/shared/components/ui/button" +import { FieldGroup } from "@/shared/components/ui/field" +import { Rhform, RhfTextField, type InlineCreateFormProps } from "@/shared/components/form" +import { toast } from "sonner" +import { useAuthApi } from "@/shared/useApi" + +const schema = z.object({ + title: z.string().min(1, "Title is required"), +}) + +type FormValues = z.infer + +export function TransmissionInlineForm({ onSuccess }: InlineCreateFormProps) { + const api = useAuthApi() + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { title: "" }, + }) + + const handleSubmit = async (values: FormValues) => { + try { + const result = await api.vehicleAttributes.createTransmission({ title: values.title }) + toast.success("Transmission created") + form.reset() + const item = (result as any)?.data ?? result + onSuccess({ value: String(item.id), label: item.title ?? String(item.id) }) + } catch { + toast.error("Failed to create transmission") + } + } + + return ( + + + + + + + ) +} diff --git a/modules/vehicles/vehicle-form.tsx b/modules/vehicles/vehicle-form.tsx new file mode 100644 index 0000000..607bcc0 --- /dev/null +++ b/modules/vehicles/vehicle-form.tsx @@ -0,0 +1,267 @@ +"use client" + +import { AlertTriangle, Plus, Save } from "lucide-react" + +import { Button } from "@/shared/components/ui/button" +import { Alert, AlertTitle } from "@/shared/components/ui/alert" +import { FieldGroup } from "@/shared/components/ui/field" +import { + Rhform, + RhfTextField, + RhfTextareaField, + RhfAsyncSelectField, +} from "@/shared/components/form" +import { ShopTypeInlineForm } from "./inline-forms/shop-type-inline-form" +import { BodyTypeInlineForm } from "./inline-forms/body-type-inline-form" +import { FuelTypeInlineForm } from "./inline-forms/fuel-type-inline-form" +import { TransmissionInlineForm } from "./inline-forms/transmission-inline-form" +import { ColorInlineForm } from "./inline-forms/color-inline-form" +import { toast } from "sonner" +import { useAuthApi } from "@/shared/useApi" +import { useResourceForm } from "@/shared/hooks/use-resource-form" +import { useFormMutation } from "@/shared/hooks/use-form-mutation" +import { toRelation, toId } from "@/shared/lib/utils" + +import { vehicleFormSchema, type VehicleFormValues } from "./vehicle.schema" +import { VEHICLE_ROUTES } from "@repo/api" + +// ── Props ── + +export type VehicleFormProps = { + resourceId?: string | null + initialData?: unknown + onSuccess?: () => void +} + +// ── Default values ── + +const DEFAULT_VALUES: VehicleFormValues = { + shop_type: null, + vehicle_body_type: null, + vehicle_fuel_type: null, + vehicle_transmission: null, + vehicle_color: null, + make: "", + model: "", + year: "", + sub_model: "", + license_plate: "", + vin_number: "", + engine_size: "", + drivetrain: "", + mileage: "", + owners_number: "", + note: "", +} + +// ── Mapping helpers ── + +const mapLookupOption = (item: any) => ({ + value: String(item.id), + label: item.name ?? item.title ?? String(item.id), +}) + +const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label } + +function mapToFormValues(data: unknown): VehicleFormValues { + const d = (data as any)?.data ?? data ?? {} + + return { + shop_type: toRelation(d.shop_type_id, d.shop_type?.title), + vehicle_body_type: toRelation(d.vehicle_body_type_id, d.vehicle_body_type?.title), + vehicle_fuel_type: toRelation(d.vehicle_fuel_type_id, d.vehicle_fuel_type?.title), + vehicle_transmission: toRelation(d.vehicle_transmission_id, d.vehicle_transmission?.title), + vehicle_color: toRelation(d.vehicle_color_id, d.vehicle_color?.title), + make: d.make || "", + model: d.model || "", + year: d.year || "", + sub_model: d.sub_model || "", + license_plate: d.license_plate || "", + vin_number: d.vin_number || "", + engine_size: d.engine_size || "", + drivetrain: d.drivetrain || "", + mileage: d.mileage || "", + owners_number: d.owners_number || "", + note: d.note || "", + } +} + +function mapCreatePayload(values: VehicleFormValues) { + return { + shop_type_id: toId(values.shop_type), + vehicle_body_type_id: toId(values.vehicle_body_type), + vehicle_fuel_type_id: toId(values.vehicle_fuel_type), + vehicle_transmission_id: toId(values.vehicle_transmission), + vehicle_color_id: toId(values.vehicle_color), + make: values.make, + model: values.model, + year: values.year, + sub_model: values.sub_model || undefined, + license_plate: values.license_plate || undefined, + vin_number: values.vin_number || undefined, + engine_size: values.engine_size || undefined, + drivetrain: values.drivetrain || undefined, + mileage: values.mileage || undefined, + owners_number: values.owners_number || undefined, + note: values.note || undefined, + } +} + +function mapUpdatePayload(values: VehicleFormValues) { + return { + mileage: values.mileage || undefined, + license_plate: values.license_plate || undefined, + } +} + +// ── Component ── + +export function VehicleForm({ resourceId, initialData, onSuccess }: VehicleFormProps) { + const api = useAuthApi() + + const { form, isEditing } = useResourceForm({ + schema: vehicleFormSchema, + defaultValues: DEFAULT_VALUES, + resourceId, + initialData, + mapToFormValues, + }) + + const { mutate, error, isPending } = useFormMutation(form, { + mutationFn: (values: VehicleFormValues) => { + const promise = isEditing && resourceId + ? api.vehicles.update(resourceId, mapUpdatePayload(values)) + : api.vehicles.create(mapCreatePayload(values)) + toast.promise(promise, { + loading: isEditing ? "Updating vehicle..." : "Creating vehicle...", + success: isEditing ? "Vehicle updated successfully" : "Vehicle created successfully", + error: isEditing ? "Failed to update vehicle" : "Failed to create vehicle", + }) + return promise + }, + onSuccess: () => { + form.reset() + onSuccess?.() + }, + }) + + return ( + mutate(values)}> + {error && ( + + + + {isEditing ? "Failed to update vehicle" : "Failed to create vehicle"} + + {error.message} + + )} + + + {!isEditing && ( + <> + {/* Vehicle identity */} +
+ + + +
+ + + + {/* Associations */} +
+ api.shopTypes.list()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Shop Type" + {...STORE_OBJECT} + /> + api.vehicleAttributes.listBodyTypes()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Body Type" + {...STORE_OBJECT} + /> +
+ +
+ api.vehicleAttributes.listFuelTypes()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Fuel Type" + {...STORE_OBJECT} + /> + api.vehicleAttributes.listTransmissions()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Transmission" + {...STORE_OBJECT} + /> +
+ +
+ api.vehicleAttributes.listColors()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Color" + {...STORE_OBJECT} + /> + +
+ + {/* Technical specs */} +
+ + +
+ + + + )} + + {/* Editable in both create and update */} +
+ + +
+ + {!isEditing && ( + + )} + + +
+
+ ) +} diff --git a/modules/vehicles/vehicle.schema.ts b/modules/vehicles/vehicle.schema.ts new file mode 100644 index 0000000..343acda --- /dev/null +++ b/modules/vehicles/vehicle.schema.ts @@ -0,0 +1,35 @@ +import { z } from "zod" + +export const relationFieldSchema = z + .object({ value: z.string(), label: z.string() }) + .nullable() + +export const vehicleFormSchema = z.object({ + // ── Relations ── + shop_type: relationFieldSchema, + vehicle_body_type: relationFieldSchema, + vehicle_fuel_type: relationFieldSchema, + vehicle_transmission: relationFieldSchema, + vehicle_color: relationFieldSchema, + + // ── Vehicle identity ── + make: z.string().optional(), + model: z.string().optional(), + year: z.string().optional(), + sub_model: z.string().optional(), + + // ── License & identifiers ── + license_plate: z.string().optional(), + vin_number: z.string().optional(), + + // ── Technical specs ── + engine_size: z.string().optional(), + drivetrain: z.string().optional(), + mileage: z.string().optional(), + owners_number: z.string().optional(), + + // ── Notes ── + note: z.string().optional(), +}) + +export type VehicleFormValues = z.infer diff --git a/package.json b/package.json index 49f1d52..4b225df 100644 --- a/package.json +++ b/package.json @@ -4,25 +4,47 @@ "type": "module", "private": true, "scripts": { + "predev": "pnpm --filter @repo/api run generate", "dev": "next dev --turbopack", + "prebuild": "pnpm --filter @repo/api run generate", "build": "next build", "start": "next start", "lint": "eslint", "format": "prettier --write \"**/*.{ts,tsx}\"", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "cypress:open": "cypress open", + "cypress:run": "cypress run", + "test:e2e": "cypress run" }, "dependencies": { + "@base-ui/react": "^1.3.0", + "@hookform/resolvers": "^5.2.2", + "@repo/api": "workspace:*", + "@tanstack/react-query": "^5.95.2", + "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", + "embla-carousel-react": "^8.6.0", + "input-otp": "^1.4.2", "lucide-react": "^0.577.0", "next": "16.1.7", "next-themes": "^0.4.6", + "nuqs": "^2.8.9", "radix-ui": "^1.4.3", "react": "^19.2.4", + "react-day-picker": "^9.14.0", "react-dom": "^19.2.4", - "shadcn": "^4.1.0", + "react-hook-form": "^7.72.0", + "react-resizable-panels": "^4.7.5", + "recharts": "3.8.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "vaul": "^1.1.2", + "zod": "^4.3.6", + "zustand": "^5.0.12" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -30,11 +52,13 @@ "@types/node": "^25.5.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "cypress": "^15.13.0", "eslint": "^9.39.4", "eslint-config-next": "16.1.7", "postcss": "^8", "prettier": "^3.8.1", "prettier-plugin-tailwindcss": "^0.7.2", + "shadcn": "^4.1.0", "tailwindcss": "^4.2.1", "typescript": "^5.9.3" } diff --git a/public/assets/logo.png b/public/assets/logo.png new file mode 100644 index 0000000..c20846b Binary files /dev/null and b/public/assets/logo.png differ diff --git a/shared/api.ts b/shared/api.ts new file mode 100644 index 0000000..8b7b2c6 --- /dev/null +++ b/shared/api.ts @@ -0,0 +1,12 @@ +import { createApi, } from "@repo/api"; +import { useAuthStore } from "./stores/auth-store"; +import { getAuthCookies } from "@/modules/auth/auth.actions"; + +export const getAuthApi = async () => { + const { token } = await getAuthCookies(); + console.log(`Auth Token: ${token}`); + const api = createApi({ headers: token ? { Authorization: `Bearer ${token}` } : undefined }); + return api; +} + + diff --git a/components/.gitkeep b/shared/components/.gitkeep similarity index 100% rename from components/.gitkeep rename to shared/components/.gitkeep diff --git a/shared/components/confirm-dialog.tsx b/shared/components/confirm-dialog.tsx new file mode 100644 index 0000000..87b04ea --- /dev/null +++ b/shared/components/confirm-dialog.tsx @@ -0,0 +1,120 @@ +"use client" + +import { create } from "zustand" +import { Trash2 } from "lucide-react" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle, +} from "@/shared/components/ui/alert-dialog" + +// ── Types ── + +export type ConfirmOptions = { + title?: string + description?: string + confirmLabel?: string + cancelLabel?: string + variant?: "destructive" | "default" +} + +type ConfirmStore = { + open: boolean + options: ConfirmOptions + resolve: ((value: boolean) => void) | null + _show: (options: ConfirmOptions) => Promise + _close: (confirmed: boolean) => void +} + +// ── Store ── + +const useConfirmStore = create((set, get) => ({ + open: false, + options: {}, + resolve: null, + _show: (options) => + new Promise((resolve) => { + set({ open: true, options, resolve }) + }), + _close: (confirmed) => { + const { resolve } = get() + resolve?.(confirmed) + set({ open: false, resolve: null }) + }, +})) + +// ── Imperative API (usage: `await confirm({ ... })`) ── + +export function confirm(options: ConfirmOptions = {}) { + if (process.env.NODE_ENV === "development") { + const state = useConfirmStore.getState() + if (state.open) { + console.warn("[ConfirmDialog] A confirm dialog is already open. Nested confirms are not supported.") + } + // Detect missing mount: if `resolve` is never set after a tick, the dialog component is not mounted. + const result = state._show(options) + setTimeout(() => { + const current = useConfirmStore.getState() + if (current.open && current.resolve === null) { + console.warn( + "[ConfirmDialog] confirm() was called but does not appear to be mounted. " + + "Make sure is rendered in your root layout.", + ) + } + }, 100) + return result + } + return useConfirmStore.getState()._show(options) +} + +// ── Dialog component (mount once in the root layout) ── + +export function ConfirmDialog() { + const { open, options, _close } = useConfirmStore() + + const isDestructive = options.variant === "destructive" + + return ( + { + if (!v) _close(false) + }} + > + + + {isDestructive && ( + + + + )} + + {options.title ?? "Are you sure?"} + + {options.description && ( + + {options.description} + + )} + + + _close(false)}> + {options.cancelLabel ?? "Cancel"} + + _close(true)} + > + {options.confirmLabel ?? "Confirm"} + + + + + ) +} diff --git a/shared/components/form-dialog.tsx b/shared/components/form-dialog.tsx new file mode 100644 index 0000000..f62feb5 --- /dev/null +++ b/shared/components/form-dialog.tsx @@ -0,0 +1,87 @@ +import React from 'react' +import { useQueryStates, parseAsBoolean, parseAsString } from 'nuqs' +import { Button } from '@/shared/components/ui/button' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/components/ui/dialog' +import { ScrollArea } from '@/shared/components/ui/scroll-area' +import { Plus } from 'lucide-react' + +export const formDialogParams = { + dialog: parseAsBoolean.withDefault(false), + resourceId: parseAsString, +} + +export function useFormDialog(paramKey?: string) { + // Default (no paramKey) uses the standard `dialog` and `resourceId` params + const defaultState = useQueryStates(formDialogParams) + + // When a paramKey is provided, use prefixed params to avoid URL collisions + const prefixedState = useQueryStates({ + [`${paramKey ?? "_"}_dialog`]: parseAsBoolean.withDefault(false), + [`${paramKey ?? "_"}_resourceId`]: parseAsString, + }) + + if (paramKey) { + const [params, setParams] = prefixedState + const dialogKey = `${paramKey}_dialog` + const resourceIdKey = `${paramKey}_resourceId` + + const open = (resourceId?: string) => { + setParams({ [dialogKey]: true, [resourceIdKey]: resourceId ?? null }) + } + const close = () => { + setParams({ [dialogKey]: false, [resourceIdKey]: null }) + } + + return { + isOpen: (params as Record)[dialogKey] as boolean, + resourceId: (params as Record)[resourceIdKey] as string | null, + open, + close, + } + } + + const [params, setParams] = defaultState + + const open = (resourceId?: string) => { + setParams({ dialog: true, resourceId: resourceId ?? null }) + } + const close = () => { + setParams({ dialog: false, resourceId: null }) + } + + return { + isOpen: params.dialog, + resourceId: params.resourceId, + open, + close, + } +} + +export default function FormDialog(props: { + children: (resourceId: string | null) => React.ReactNode + title: string + paramKey?: string +}) { + const { isOpen, resourceId, open, close } = useFormDialog(props.paramKey) + + return ( + <> + + { if (!v) close() }}> + + + + {props.title} + + + + {props.children(resourceId)} + + + + + ) +} diff --git a/shared/components/form/controls/async-select-field.tsx b/shared/components/form/controls/async-select-field.tsx new file mode 100644 index 0000000..f2bf0ae --- /dev/null +++ b/shared/components/form/controls/async-select-field.tsx @@ -0,0 +1,160 @@ +"use client" + +import { useRef } from "react" +import type { AsyncOption, BaseFieldControlProps } from "../types" +import { Loader2 } from "lucide-react" +import { + Combobox, + ComboboxInput, + ComboboxContent, + ComboboxList, + ComboboxItem, + ComboboxEmpty, +} from "@/shared/components/ui/combobox" + +const defaultGetOptionValue = (opt: any) => opt.value +const defaultGetOptionLabel = (opt: any) => opt.label +function defaultGetOptionKey(opt: any): string { + const v = defaultGetOptionValue(opt) + if (typeof v === "string" || typeof v === "number") return String(v) + return String(opt.id ?? JSON.stringify(v)) +} + +// ── Single-select ── + +export type AsyncSelectFieldProps = BaseFieldControlProps & { + options: TOption[] + loading?: boolean + onInputValueChange?: (value: string) => void + placeholder?: string + getOptionValue?: (option: TOption) => any + getOptionLabel?: (option: TOption) => string + getOptionKey?: (option: TOption) => string +} + +export function AsyncSelectField({ + value, + onChange, + onBlur, + disabled, + invalid, + options, + loading, + onInputValueChange, + placeholder = "Search...", + getOptionValue = defaultGetOptionValue, + getOptionLabel = defaultGetOptionLabel, + getOptionKey = defaultGetOptionKey, +}: AsyncSelectFieldProps) { + const anchorRef = useRef(null) + + return ( +
+ onChange(val)} + disabled={disabled} + onInputValueChange={(val, { reason }) => { + if (reason === "input-change") { + onInputValueChange?.(val) + } + }} + > + + + + {loading && ( +
+ +
+ )} + {!loading && + options.map((opt) => ( + + {getOptionLabel(opt)} + + ))} + {!loading && options.length === 0 && ( + No results found + )} +
+
+
+
+ ) +} + +// ── Multi-select ── + +export type AsyncMultiSelectFieldProps = BaseFieldControlProps & { + options: TOption[] + loading?: boolean + onInputValueChange?: (value: string) => void + placeholder?: string + getOptionValue?: (option: TOption) => any + getOptionLabel?: (option: TOption) => string + getOptionKey?: (option: TOption) => string +} + +export function AsyncMultiSelectField({ + value, + onChange, + onBlur, + disabled, + invalid, + options, + loading, + onInputValueChange, + placeholder = "Search...", + getOptionValue = defaultGetOptionValue, + getOptionLabel = defaultGetOptionLabel, + getOptionKey = defaultGetOptionKey, +}: AsyncMultiSelectFieldProps) { + const anchorRef = useRef(null) + + return ( +
+ onChange(val as any[])} + disabled={disabled} + onInputValueChange={(val, { reason }) => { + if (reason === "input-change") { + onInputValueChange?.(val) + } + }} + > + 0} + onBlur={onBlur} + aria-invalid={invalid || undefined} + /> + + + {loading && ( +
+ +
+ )} + {!loading && + options.map((opt) => ( + + {getOptionLabel(opt)} + + ))} + {!loading && options.length === 0 && ( + No results found + )} +
+
+
+
+ ) +} diff --git a/shared/components/form/controls/checkbox-field.tsx b/shared/components/form/controls/checkbox-field.tsx new file mode 100644 index 0000000..66a9e79 --- /dev/null +++ b/shared/components/form/controls/checkbox-field.tsx @@ -0,0 +1,28 @@ +"use client" + +import type { BaseFieldControlProps } from "../types" +import { Checkbox } from "@/shared/components/ui/checkbox" + +export type CheckboxFieldProps = BaseFieldControlProps & { + label?: string +} + +export function CheckboxField({ + value, + onChange, + onBlur, + name, + disabled, + invalid, +}: CheckboxFieldProps) { + return ( + onChange(checked === true)} + onBlur={onBlur} + name={name} + disabled={disabled} + aria-invalid={invalid || undefined} + /> + ) +} diff --git a/shared/components/form/controls/file-input-field.tsx b/shared/components/form/controls/file-input-field.tsx new file mode 100644 index 0000000..e6f83db --- /dev/null +++ b/shared/components/form/controls/file-input-field.tsx @@ -0,0 +1,28 @@ +import type { BaseFieldControlProps } from "../types" +import { Input } from "@/shared/components/ui/input" + +export type FileInputFieldProps = BaseFieldControlProps & { + accept?: string +} + +export function FileInputField({ + // value intentionally unused — file inputs cannot be controlled + onBlur, + name, + disabled, + invalid, + accept, + onChange, +}: FileInputFieldProps) { + return ( + onChange(e.target.files?.[0] ?? null)} + /> + ) +} diff --git a/shared/components/form/controls/select-field.tsx b/shared/components/form/controls/select-field.tsx new file mode 100644 index 0000000..b8999ee --- /dev/null +++ b/shared/components/form/controls/select-field.tsx @@ -0,0 +1,45 @@ +"use client" + +import type { BaseFieldControlProps, SelectOption } from "../types" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/components/ui/select" + +export type SelectFieldProps = BaseFieldControlProps & { + placeholder?: string + options: SelectOption[] +} + +export function SelectField({ + value, + onChange, + onBlur, + name, + disabled, + invalid, + placeholder, + options, +}: SelectFieldProps) { + return ( + + ) +} diff --git a/shared/components/form/controls/text-input-field.tsx b/shared/components/form/controls/text-input-field.tsx new file mode 100644 index 0000000..371f2e2 --- /dev/null +++ b/shared/components/form/controls/text-input-field.tsx @@ -0,0 +1,31 @@ +import type { BaseFieldControlProps } from "../types" +import { Input } from "@/shared/components/ui/input" + +export type TextInputFieldProps = BaseFieldControlProps & { + placeholder?: string + type?: React.HTMLInputTypeAttribute +} + +export function TextInputField({ + value, + onChange, + onBlur, + name, + disabled, + invalid, + placeholder, + type = "text", +}: TextInputFieldProps) { + return ( + onChange(e.target.value)} + onBlur={onBlur} + name={name} + disabled={disabled} + aria-invalid={invalid || undefined} + placeholder={placeholder} + type={type} + /> + ) +} diff --git a/shared/components/form/controls/textarea-field.tsx b/shared/components/form/controls/textarea-field.tsx new file mode 100644 index 0000000..41a08b2 --- /dev/null +++ b/shared/components/form/controls/textarea-field.tsx @@ -0,0 +1,31 @@ +import type { BaseFieldControlProps } from "../types" +import { Textarea } from "@/shared/components/ui/textarea" + +export type TextareaFieldProps = BaseFieldControlProps & { + placeholder?: string + rows?: number +} + +export function TextareaField({ + value, + onChange, + onBlur, + name, + disabled, + invalid, + placeholder, + rows, +}: TextareaFieldProps) { + return ( +