From 5dc54ffd001a80d0b30a81a912fb6f2a0e04354b Mon Sep 17 00:00:00 2001 From: Mohammad Khyata Date: Thu, 26 Mar 2026 03:49:05 +0300 Subject: [PATCH] initialize --- .env | 1 + .gitignore | 3 + .vscode/mcp.json | 11 + app/(auth)/login/page.tsx | 12 + app/(authenticated)/layout.tsx | 218 ++++++ app/(authenticated)/page.tsx | 14 + app/(authenticated)/sales/customers/page.tsx | 54 ++ app/(authenticated)/sales/vehicles/page.tsx | 58 ++ .../setting/shop_type/page.tsx | 54 ++ app/globals.css | 262 ++++--- app/layout.tsx | 20 +- app/page.tsx | 19 - base/components/auth-store-initializer.tsx | 19 + .../layout/dashboard/app-sidebar.tsx | 240 ++++++ .../layout/dashboard/dashboard-header.tsx | 210 ++++++ .../layout/dashboard/dashboard-layout.tsx | 41 + .../layout/dashboard/dashboard-page.tsx | 20 + base/components/layout/dashboard/index.ts | 3 + base/types/navigation.ts | 31 + components.json | 10 +- cypress.config.ts | 16 + .../customers/customer-form-integration.cy.ts | 293 ++++++++ cypress/e2e/customers/customer-form.cy.ts | 347 +++++++++ cypress/fixtures/customers.json | 47 ++ cypress/support/commands.ts | 33 + cypress/support/e2e.ts | 1 + cypress/tsconfig.json | 15 + lib/utils.ts | 6 - modules/auth/auth.actions.ts | 55 ++ modules/auth/login-form.schema.ts | 11 + modules/auth/login-form.tsx | 148 ++++ modules/customers/customer-form.tsx | 264 +++++++ modules/customers/customer.schema.ts | 44 ++ modules/settings/shop-type/shop-type-form.tsx | 157 ++++ .../settings/shop-type/shop-type.schema.ts | 19 + .../inline-forms/body-type-inline-form.tsx | 55 ++ .../inline-forms/color-inline-form.tsx | 55 ++ .../inline-forms/fuel-type-inline-form.tsx | 55 ++ .../inline-forms/shop-type-inline-form.tsx | 114 +++ .../inline-forms/transmission-inline-form.tsx | 55 ++ modules/vehicles/vehicle-form.tsx | 267 +++++++ modules/vehicles/vehicle.schema.ts | 35 + package.json | 30 +- public/assets/logo.png | Bin 0 -> 59735 bytes shared/api.ts | 12 + {components => shared/components}/.gitkeep | 0 shared/components/confirm-dialog.tsx | 120 +++ shared/components/form-dialog.tsx | 87 +++ .../form/controls/async-select-field.tsx | 160 ++++ .../form/controls/checkbox-field.tsx | 28 + .../form/controls/file-input-field.tsx | 28 + .../components/form/controls/select-field.tsx | 45 ++ .../form/controls/text-input-field.tsx | 31 + .../form/controls/textarea-field.tsx | 31 + shared/components/form/field-shell.tsx | 29 + .../form/fields/rhf-async-select-field.tsx | 278 +++++++ .../form/fields/rhf-checkbox-field.tsx | 24 + .../components/form/fields/rhf-file-field.tsx | 24 + .../form/fields/rhf-select-field.tsx | 24 + .../components/form/fields/rhf-text-field.tsx | 24 + .../form/fields/rhf-textarea-field.tsx | 24 + .../form/fields/simple-title-form.tsx | 65 ++ shared/components/form/index.ts | 33 + shared/components/form/rhf-field.tsx | 62 ++ shared/components/form/rhform.tsx | 24 + shared/components/form/types.ts | 63 ++ shared/components/query-provider.tsx | 42 ++ .../components}/theme-provider.tsx | 0 shared/components/ui/accordion.tsx | 81 ++ shared/components/ui/alert-dialog.tsx | 199 +++++ shared/components/ui/alert.tsx | 76 ++ shared/components/ui/aspect-ratio.tsx | 11 + shared/components/ui/avatar.tsx | 112 +++ shared/components/ui/badge.tsx | 49 ++ shared/components/ui/breadcrumb.tsx | 122 +++ shared/components/ui/button-group.tsx | 83 +++ .../components}/ui/button.tsx | 2 +- shared/components/ui/calendar.tsx | 222 ++++++ shared/components/ui/card.tsx | 103 +++ shared/components/ui/carousel.tsx | 242 ++++++ shared/components/ui/chart.tsx | 372 ++++++++++ shared/components/ui/checkbox.tsx | 33 + shared/components/ui/collapsible.tsx | 33 + shared/components/ui/combobox.tsx | 299 ++++++++ shared/components/ui/command.tsx | 195 +++++ shared/components/ui/context-menu.tsx | 263 +++++++ shared/components/ui/dialog.tsx | 165 ++++ shared/components/ui/direction.tsx | 22 + shared/components/ui/drawer.tsx | 131 ++++ shared/components/ui/dropdown-menu.tsx | 269 +++++++ shared/components/ui/empty.tsx | 104 +++ shared/components/ui/field.tsx | 238 ++++++ shared/components/ui/hover-card.tsx | 44 ++ shared/components/ui/input-group.tsx | 156 ++++ shared/components/ui/input-otp.tsx | 87 +++ shared/components/ui/input.tsx | 19 + shared/components/ui/item.tsx | 196 +++++ shared/components/ui/kbd.tsx | 26 + shared/components/ui/label.tsx | 24 + shared/components/ui/menubar.tsx | 280 +++++++ shared/components/ui/native-select.tsx | 52 ++ shared/components/ui/navigation-menu.tsx | 164 ++++ shared/components/ui/pagination.tsx | 129 ++++ shared/components/ui/popover.tsx | 89 +++ shared/components/ui/progress.tsx | 31 + shared/components/ui/radio-group.tsx | 44 ++ shared/components/ui/resizable.tsx | 50 ++ shared/components/ui/scroll-area.tsx | 55 ++ shared/components/ui/select.tsx | 192 +++++ shared/components/ui/separator.tsx | 28 + shared/components/ui/sheet.tsx | 144 ++++ shared/components/ui/sidebar.tsx | 702 ++++++++++++++++++ shared/components/ui/skeleton.tsx | 13 + shared/components/ui/slider.tsx | 59 ++ shared/components/ui/sonner.tsx | 49 ++ shared/components/ui/spinner.tsx | 10 + shared/components/ui/switch.tsx | 33 + shared/components/ui/table.tsx | 116 +++ shared/components/ui/tabs.tsx | 90 +++ shared/components/ui/textarea.tsx | 18 + shared/components/ui/toggle-group.tsx | 89 +++ shared/components/ui/toggle.tsx | 46 ++ shared/components/ui/tooltip.tsx | 57 ++ shared/data-view/resource-page/index.ts | 14 + .../data-view/resource-page/resource-page.tsx | 89 +++ .../resource-page/use-resource-page.ts | 102 +++ .../data-view/table-view/actions-column.tsx | 64 ++ shared/data-view/table-view/column-header.tsx | 82 ++ shared/data-view/table-view/data-table.tsx | 127 ++++ .../table-view/data-view-context.tsx | 30 + .../table-view/data-view-pagination.tsx | 91 +++ shared/data-view/table-view/index.ts | 16 + shared/data-view/table-view/search-params.ts | 19 + shared/data-view/table-view/types.ts | 28 + .../table-view/use-data-table-query.ts | 86 +++ {hooks => shared/hooks}/.gitkeep | 0 shared/hooks/use-auth.ts | 24 + shared/hooks/use-form-mutation.ts | 22 + shared/hooks/use-mobile.ts | 19 + shared/hooks/use-resource-form.ts | 61 ++ {lib => shared/lib}/.gitkeep | 0 shared/lib/utils.ts | 26 + shared/stores/app-store.ts | 23 + shared/stores/auth-store.ts | 90 +++ shared/useApi.ts | 11 + tsconfig.json | 2 +- 146 files changed, 12078 insertions(+), 146 deletions(-) create mode 100644 .env create mode 100644 .vscode/mcp.json create mode 100644 app/(auth)/login/page.tsx create mode 100644 app/(authenticated)/layout.tsx create mode 100644 app/(authenticated)/page.tsx create mode 100644 app/(authenticated)/sales/customers/page.tsx create mode 100644 app/(authenticated)/sales/vehicles/page.tsx create mode 100644 app/(authenticated)/setting/shop_type/page.tsx delete mode 100644 app/page.tsx create mode 100644 base/components/auth-store-initializer.tsx create mode 100644 base/components/layout/dashboard/app-sidebar.tsx create mode 100644 base/components/layout/dashboard/dashboard-header.tsx create mode 100644 base/components/layout/dashboard/dashboard-layout.tsx create mode 100644 base/components/layout/dashboard/dashboard-page.tsx create mode 100644 base/components/layout/dashboard/index.ts create mode 100644 base/types/navigation.ts create mode 100644 cypress.config.ts create mode 100644 cypress/e2e/customers/customer-form-integration.cy.ts create mode 100644 cypress/e2e/customers/customer-form.cy.ts create mode 100644 cypress/fixtures/customers.json create mode 100644 cypress/support/commands.ts create mode 100644 cypress/support/e2e.ts create mode 100644 cypress/tsconfig.json delete mode 100644 lib/utils.ts create mode 100644 modules/auth/auth.actions.ts create mode 100644 modules/auth/login-form.schema.ts create mode 100644 modules/auth/login-form.tsx create mode 100644 modules/customers/customer-form.tsx create mode 100644 modules/customers/customer.schema.ts create mode 100644 modules/settings/shop-type/shop-type-form.tsx create mode 100644 modules/settings/shop-type/shop-type.schema.ts create mode 100644 modules/vehicles/inline-forms/body-type-inline-form.tsx create mode 100644 modules/vehicles/inline-forms/color-inline-form.tsx create mode 100644 modules/vehicles/inline-forms/fuel-type-inline-form.tsx create mode 100644 modules/vehicles/inline-forms/shop-type-inline-form.tsx create mode 100644 modules/vehicles/inline-forms/transmission-inline-form.tsx create mode 100644 modules/vehicles/vehicle-form.tsx create mode 100644 modules/vehicles/vehicle.schema.ts create mode 100644 public/assets/logo.png create mode 100644 shared/api.ts rename {components => shared/components}/.gitkeep (100%) create mode 100644 shared/components/confirm-dialog.tsx create mode 100644 shared/components/form-dialog.tsx create mode 100644 shared/components/form/controls/async-select-field.tsx create mode 100644 shared/components/form/controls/checkbox-field.tsx create mode 100644 shared/components/form/controls/file-input-field.tsx create mode 100644 shared/components/form/controls/select-field.tsx create mode 100644 shared/components/form/controls/text-input-field.tsx create mode 100644 shared/components/form/controls/textarea-field.tsx create mode 100644 shared/components/form/field-shell.tsx create mode 100644 shared/components/form/fields/rhf-async-select-field.tsx create mode 100644 shared/components/form/fields/rhf-checkbox-field.tsx create mode 100644 shared/components/form/fields/rhf-file-field.tsx create mode 100644 shared/components/form/fields/rhf-select-field.tsx create mode 100644 shared/components/form/fields/rhf-text-field.tsx create mode 100644 shared/components/form/fields/rhf-textarea-field.tsx create mode 100644 shared/components/form/fields/simple-title-form.tsx create mode 100644 shared/components/form/index.ts create mode 100644 shared/components/form/rhf-field.tsx create mode 100644 shared/components/form/rhform.tsx create mode 100644 shared/components/form/types.ts create mode 100644 shared/components/query-provider.tsx rename {components => shared/components}/theme-provider.tsx (100%) create mode 100644 shared/components/ui/accordion.tsx create mode 100644 shared/components/ui/alert-dialog.tsx create mode 100644 shared/components/ui/alert.tsx create mode 100644 shared/components/ui/aspect-ratio.tsx create mode 100644 shared/components/ui/avatar.tsx create mode 100644 shared/components/ui/badge.tsx create mode 100644 shared/components/ui/breadcrumb.tsx create mode 100644 shared/components/ui/button-group.tsx rename {components => shared/components}/ui/button.tsx (98%) create mode 100644 shared/components/ui/calendar.tsx create mode 100644 shared/components/ui/card.tsx create mode 100644 shared/components/ui/carousel.tsx create mode 100644 shared/components/ui/chart.tsx create mode 100644 shared/components/ui/checkbox.tsx create mode 100644 shared/components/ui/collapsible.tsx create mode 100644 shared/components/ui/combobox.tsx create mode 100644 shared/components/ui/command.tsx create mode 100644 shared/components/ui/context-menu.tsx create mode 100644 shared/components/ui/dialog.tsx create mode 100644 shared/components/ui/direction.tsx create mode 100644 shared/components/ui/drawer.tsx create mode 100644 shared/components/ui/dropdown-menu.tsx create mode 100644 shared/components/ui/empty.tsx create mode 100644 shared/components/ui/field.tsx create mode 100644 shared/components/ui/hover-card.tsx create mode 100644 shared/components/ui/input-group.tsx create mode 100644 shared/components/ui/input-otp.tsx create mode 100644 shared/components/ui/input.tsx create mode 100644 shared/components/ui/item.tsx create mode 100644 shared/components/ui/kbd.tsx create mode 100644 shared/components/ui/label.tsx create mode 100644 shared/components/ui/menubar.tsx create mode 100644 shared/components/ui/native-select.tsx create mode 100644 shared/components/ui/navigation-menu.tsx create mode 100644 shared/components/ui/pagination.tsx create mode 100644 shared/components/ui/popover.tsx create mode 100644 shared/components/ui/progress.tsx create mode 100644 shared/components/ui/radio-group.tsx create mode 100644 shared/components/ui/resizable.tsx create mode 100644 shared/components/ui/scroll-area.tsx create mode 100644 shared/components/ui/select.tsx create mode 100644 shared/components/ui/separator.tsx create mode 100644 shared/components/ui/sheet.tsx create mode 100644 shared/components/ui/sidebar.tsx create mode 100644 shared/components/ui/skeleton.tsx create mode 100644 shared/components/ui/slider.tsx create mode 100644 shared/components/ui/sonner.tsx create mode 100644 shared/components/ui/spinner.tsx create mode 100644 shared/components/ui/switch.tsx create mode 100644 shared/components/ui/table.tsx create mode 100644 shared/components/ui/tabs.tsx create mode 100644 shared/components/ui/textarea.tsx create mode 100644 shared/components/ui/toggle-group.tsx create mode 100644 shared/components/ui/toggle.tsx create mode 100644 shared/components/ui/tooltip.tsx create mode 100644 shared/data-view/resource-page/index.ts create mode 100644 shared/data-view/resource-page/resource-page.tsx create mode 100644 shared/data-view/resource-page/use-resource-page.ts create mode 100644 shared/data-view/table-view/actions-column.tsx create mode 100644 shared/data-view/table-view/column-header.tsx create mode 100644 shared/data-view/table-view/data-table.tsx create mode 100644 shared/data-view/table-view/data-view-context.tsx create mode 100644 shared/data-view/table-view/data-view-pagination.tsx create mode 100644 shared/data-view/table-view/index.ts create mode 100644 shared/data-view/table-view/search-params.ts create mode 100644 shared/data-view/table-view/types.ts create mode 100644 shared/data-view/table-view/use-data-table-query.ts rename {hooks => shared/hooks}/.gitkeep (100%) create mode 100644 shared/hooks/use-auth.ts create mode 100644 shared/hooks/use-form-mutation.ts create mode 100644 shared/hooks/use-mobile.ts create mode 100644 shared/hooks/use-resource-form.ts rename {lib => shared/lib}/.gitkeep (100%) create mode 100644 shared/lib/utils.ts create mode 100644 shared/stores/app-store.ts create mode 100644 shared/stores/auth-store.ts create mode 100644 shared/useApi.ts 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 0000000000000000000000000000000000000000..c20846b4b92cb4f331682f45b61e89ec14a2db0b GIT binary patch literal 59735 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 { + 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 ( +