From 4b0aef983b5dc57b3bab7f7a4719273f7d78bb35 Mon Sep 17 00:00:00 2001 From: Mohammad Khyata Date: Thu, 26 Mar 2026 16:50:43 +0300 Subject: [PATCH] updates --- app/(authenticated)/items/parts/page.tsx | 90 ++++++ .../items/service-group/page.tsx | 72 +++++ app/(authenticated)/items/services/page.tsx | 69 +++++ app/(authenticated)/layout.tsx | 44 +-- app/(authenticated)/sales/vehicles/page.tsx | 46 ++- .../shop_type => settings/shop-type}/page.tsx | 0 .../layout/dashboard/app-sidebar.tsx | 4 +- modules/parts/part-form.tsx | 242 ++++++++++++++++ modules/parts/part.schema.ts | 19 ++ modules/service-groups/service-group-form.tsx | 274 ++++++++++++++++++ .../service-groups/service-group.schema.ts | 23 ++ .../services/department-assignment-types.ts | 7 + .../inline-forms/category-inline-form.tsx | 2 + .../inline-forms/department-inline-form.tsx | 66 +++++ .../inventory-category-inline-form.tsx | 78 +++++ .../inline-forms/unit-type-inline-form.tsx | 55 ++++ modules/services/service-form.tsx | 238 +++++++++++++++ modules/services/service.schema.ts | 19 ++ .../form/controls/checkbox-field.tsx | 4 +- .../form/fields/rhf-checkbox-field.tsx | 44 ++- shared/components/ui/checkbox.tsx | 4 +- 21 files changed, 1365 insertions(+), 35 deletions(-) create mode 100644 app/(authenticated)/items/parts/page.tsx create mode 100644 app/(authenticated)/items/service-group/page.tsx create mode 100644 app/(authenticated)/items/services/page.tsx rename app/(authenticated)/{setting/shop_type => settings/shop-type}/page.tsx (100%) create mode 100644 modules/parts/part-form.tsx create mode 100644 modules/parts/part.schema.ts create mode 100644 modules/service-groups/service-group-form.tsx create mode 100644 modules/service-groups/service-group.schema.ts create mode 100644 modules/services/department-assignment-types.ts create mode 100644 modules/services/inline-forms/category-inline-form.tsx create mode 100644 modules/services/inline-forms/department-inline-form.tsx create mode 100644 modules/services/inline-forms/inventory-category-inline-form.tsx create mode 100644 modules/services/inline-forms/unit-type-inline-form.tsx create mode 100644 modules/services/service-form.tsx create mode 100644 modules/services/service.schema.ts diff --git a/app/(authenticated)/items/parts/page.tsx b/app/(authenticated)/items/parts/page.tsx new file mode 100644 index 0000000..ef62937 --- /dev/null +++ b/app/(authenticated)/items/parts/page.tsx @@ -0,0 +1,90 @@ +"use client" + +import { ResourcePage } from "@/shared/data-view/resource-page" +import { ColumnHeader } from "@/shared/data-view/table-view" +import { PartForm } from "@/modules/parts/part-form" +import { Badge } from "@/shared/components/ui/badge" +import { PARTS_ROUTES } from "@repo/api" +import type { PartsClient } from "@repo/api" + +export default function PartsPage() { + return ( + + pageTitle="Parts" + title="Part" + routeKey={PARTS_ROUTES.INDEX} + getClient={(api) => api.parts} + columns={({ actionsColumn }) => [ + { + accessorKey: "title", + header: ({ column }) => , + cell: ({ row }) => { + const r = row.original as any + return ( +
+ {r.title || "—"} + {r.sku && ( + {r.sku} + )} +
+ ) + }, + }, + { + accessorKey: "part_number", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).part_number || "—", + }, + { + accessorKey: "manufactured_by", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).manufactured_by || "—", + }, + { + accessorKey: "selling_price", + header: ({ column }) => , + cell: ({ row }) => { + const val = (row.original as any).selling_price + return val != null ? `$${Number(val).toFixed(2)}` : "—" + }, + }, + { + accessorKey: "purchase_price", + header: ({ column }) => , + cell: ({ row }) => { + const val = (row.original as any).purchase_price + return val != null ? `$${Number(val).toFixed(2)}` : "—" + }, + }, + { + accessorKey: "is_active", + header: ({ column }) => , + cell: ({ row }) => { + const active = (row.original as any).is_active + return ( + + {active ? "Active" : "Inactive"} + + ) + }, + }, + { + accessorKey: "created_at", + header: ({ column }) => , + cell: ({ row }) => { + const val = (row.original as any).created_at + return val ? new Date(val).toLocaleDateString() : "—" + }, + }, + actionsColumn(), + ]} + renderForm={({ resourceId, initialData, onSuccess }) => ( + + )} + /> + ) +} diff --git a/app/(authenticated)/items/service-group/page.tsx b/app/(authenticated)/items/service-group/page.tsx new file mode 100644 index 0000000..931f811 --- /dev/null +++ b/app/(authenticated)/items/service-group/page.tsx @@ -0,0 +1,72 @@ +"use client" + +import { ResourcePage } from "@/shared/data-view/resource-page" +import { ColumnHeader } from "@/shared/data-view/table-view" +import { ServiceGroupForm } from "@/modules/service-groups/service-group-form" +import { Badge } from "@/shared/components/ui/badge" +import { SERVICE_GROUP_ROUTES } from "@repo/api" +import type { ServiceGroupsClient } from "@repo/api" + +export default function ServiceGroupPage() { + return ( + + pageTitle="Service Groups" + title="Service Group" + routeKey={SERVICE_GROUP_ROUTES.INDEX} + getClient={(api) => api.serviceGroups} + columns={({ actionsColumn }) => [ + { + accessorKey: "name", + header: ({ column }) => , + cell: ({ row }) => { + const r = row.original as any + return ( +
+ {r.service_name || r.name || "—"} + {r.code && ( + {r.code} + )} +
+ ) + }, + }, + { + accessorKey: "selling_price", + header: ({ column }) => , + cell: ({ row }) => { + const val = (row.original as any).selling_price + return val != null ? `$${Number(val).toFixed(2)}` : "—" + }, + }, + { + accessorKey: "is_active", + header: ({ column }) => , + cell: ({ row }) => { + const active = (row.original as any).is_active + return ( + + {active ? "Active" : "Inactive"} + + ) + }, + }, + { + accessorKey: "created_at", + header: ({ column }) => , + cell: ({ row }) => { + const val = (row.original as any).created_at + return val ? new Date(val).toLocaleDateString() : "—" + }, + }, + actionsColumn(), + ]} + renderForm={({ resourceId, initialData, onSuccess }) => ( + + )} + /> + ) +} diff --git a/app/(authenticated)/items/services/page.tsx b/app/(authenticated)/items/services/page.tsx new file mode 100644 index 0000000..1165e27 --- /dev/null +++ b/app/(authenticated)/items/services/page.tsx @@ -0,0 +1,69 @@ +"use client" + +import { ResourcePage } from "@/shared/data-view/resource-page" +import { ColumnHeader } from "@/shared/data-view/table-view" +import { ServiceForm } from "@/modules/services/service-form" +import { SERVICE_ROUTES } from "@repo/api" +import type { ServicesClient } from "@repo/api" + +export default function ServicesPage() { + return ( + + pageTitle="Services" + title="Service" + routeKey={SERVICE_ROUTES.INDEX} + getClient={(api) => api.services} + columns={({ actionsColumn }) => [ + { + accessorKey: "labor_name", + header: ({ column }) => , + cell: ({ row }) => { + const r = row.original as any + return ( +
+ {r.labor_name || r.name || "—"} + {r.service_code && ( + {r.service_code} + )} +
+ ) + }, + }, + { + accessorKey: "description", + header: ({ column }) => , + cell: ({ row }) => { + const val = (row.original as any).description + return val + ? {val} + : "—" + }, + }, + { + accessorKey: "selling_price", + header: ({ column }) => , + cell: ({ row }) => { + const val = (row.original as any).selling_price + return val != null ? `$${Number(val).toFixed(2)}` : "—" + }, + }, + { + accessorKey: "created_at", + header: ({ column }) => , + cell: ({ row }) => { + const val = (row.original as any).created_at + return val ? new Date(val).toLocaleDateString() : "—" + }, + }, + actionsColumn(), + ]} + renderForm={({ resourceId, initialData, onSuccess }) => ( + + )} + /> + ) +} diff --git a/app/(authenticated)/layout.tsx b/app/(authenticated)/layout.tsx index e19769f..59e4a03 100644 --- a/app/(authenticated)/layout.tsx +++ b/app/(authenticated)/layout.tsx @@ -61,7 +61,7 @@ const navGroups: NavGroup[] = [ }, { title: "Customer & Vehicles", - href: "/customer_vehicles", + href: "/customer-vehicles", icon: , }, { @@ -79,7 +79,7 @@ const navGroups: NavGroup[] = [ href: "/calendars", icon: , items: [ - { title: "Work Schedule", href: "/calendar/work_schedule/list", icon: }, + { title: "Work Schedule", href: "/calendar/work-schedule/list", icon: }, { title: "Appointments", href: "/calendar/appointment/list", icon: }, ], }, @@ -94,8 +94,8 @@ const navGroups: NavGroup[] = [ { 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: "Payments Received", href: "/sales/payment-received", icon: }, + { title: "Credit Notes", href: "/sales/credit-notes", icon: }, ], }, { @@ -105,10 +105,10 @@ const navGroups: NavGroup[] = [ items: [ { title: "Vendors", href: "/purchase/vendor", icon: }, { title: "Expenses", href: "/purchase/expense", icon: }, - { title: "Purchase Orders", href: "/purchase/purchase_order", 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: "Payments Made", href: "/purchase/payments-made", icon: }, + { title: "Vendor Credits", href: "/purchase/vendor-credit", icon: }, ], }, { @@ -117,7 +117,7 @@ const navGroups: NavGroup[] = [ icon: , items: [ { title: "Leads", href: "/crm/leads/list", icon: }, - { title: "Calls", href: "/crm/calls_follow_up/list", icon: }, + { title: "Calls", href: "/crm/calls-follow-up/list", icon: }, { title: "Tasks", href: "/crm/tasks/list", icon: }, ], }, @@ -126,9 +126,9 @@ const navGroups: NavGroup[] = [ 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: "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: }, ], }, { @@ -136,8 +136,8 @@ const navGroups: NavGroup[] = [ href: "/accountants", icon: , items: [ - { title: "Manual Journals", href: "/accountants/manual_journal", icon: }, - { title: "Chart of Accounts", href: "/accountants/chart_of_account", icon: }, + { title: "Manual Journals", href: "/accountants/manual-journal", icon: }, + { title: "Chart of Accounts", href: "/accountants/chart-of-account", icon: }, ], }, { @@ -146,12 +146,12 @@ const navGroups: NavGroup[] = [ icon: , items: [ { title: "Employees", href: "/productivity/employees", icon: }, - { title: "Time Clocks", href: "/productivity/time_clocks", 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: "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: }, ], }, @@ -162,8 +162,8 @@ const navGroups: NavGroup[] = [ 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: "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: }, ], @@ -174,12 +174,12 @@ const navGroups: NavGroup[] = [ 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: "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: }, + { title: "Master", href: "/setting/master/body-type", icon: }, ], }, ], diff --git a/app/(authenticated)/sales/vehicles/page.tsx b/app/(authenticated)/sales/vehicles/page.tsx index e7e16d8..528c6f9 100644 --- a/app/(authenticated)/sales/vehicles/page.tsx +++ b/app/(authenticated)/sales/vehicles/page.tsx @@ -20,11 +20,18 @@ export default function VehiclesPage() { header: ({ column }) => , cell: ({ row }) => { const r = row.original as any - const display = r.name || `${r.make ?? ""} ${r.model ?? ""}`.trim() || "—" + const make = r.make ?? "" + const model = r.model ?? "" + const display = r.name || `${make} ${model}`.trim() || "—" return (
- {display} +
+ {display} + {r.sub_model && ( + {r.sub_model} + )} +
) }, @@ -37,12 +44,43 @@ export default function VehiclesPage() { { accessorKey: "license_plate", header: ({ column }) => , - cell: ({ row }) => (row.original as any).license_plate ?? "—", + cell: ({ row }) => { + const val = (row.original as any).license_plate + return val + ? {val} + : "—" + }, + }, + { + accessorKey: "vin_number", + header: ({ column }) => , + cell: ({ row }) => { + const val = (row.original as any).vin_number + return val + ? {val} + : "—" + }, + }, + { + accessorKey: "engine_size", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).engine_size ?? "—", }, { accessorKey: "mileage", header: ({ column }) => , - cell: ({ row }) => (row.original as any).mileage ?? "—", + cell: ({ row }) => { + const val = (row.original as any).mileage + return val != null ? `${Number(val).toLocaleString()} mi` : "—" + }, + }, + { + accessorKey: "created_at", + header: ({ column }) => , + cell: ({ row }) => { + const val = (row.original as any).created_at + return val ? new Date(val).toLocaleDateString() : "—" + }, }, actionsColumn(), ]} diff --git a/app/(authenticated)/setting/shop_type/page.tsx b/app/(authenticated)/settings/shop-type/page.tsx similarity index 100% rename from app/(authenticated)/setting/shop_type/page.tsx rename to app/(authenticated)/settings/shop-type/page.tsx diff --git a/base/components/layout/dashboard/app-sidebar.tsx b/base/components/layout/dashboard/app-sidebar.tsx index 427afa6..a3cbfc6 100644 --- a/base/components/layout/dashboard/app-sidebar.tsx +++ b/base/components/layout/dashboard/app-sidebar.tsx @@ -204,13 +204,13 @@ function CollapsibleNavItem({ item, isCollapsed }: { item: NavItem; isCollapsed: /> - + {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")}> diff --git a/modules/parts/part-form.tsx b/modules/parts/part-form.tsx new file mode 100644 index 0000000..4e13276 --- /dev/null +++ b/modules/parts/part-form.tsx @@ -0,0 +1,242 @@ +"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 "@/modules/vehicles/inline-forms/shop-type-inline-form" +import { InventoryCategoryInlineForm } from "@/modules/services/inline-forms/inventory-category-inline-form" +import { UnitTypeInlineForm } from "@/modules/services/inline-forms/unit-type-inline-form" +import { DepartmentInlineForm } from "@/modules/services/inline-forms/department-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 { toId } from "@/shared/lib/utils" + +import { partFormSchema, type PartFormValues } from "./part.schema" +import { PARTS_ROUTES } from "@repo/api" + +// ── Props ── + +export type PartFormProps = { + resourceId?: string | null + initialData?: unknown + onSuccess?: () => void +} + +// ── Default values ── + +const DEFAULT_VALUES: PartFormValues = { + shop_type: null, + category: null, + unit_type: null, + department: null, + title: "", + sku: "", + description: "", + selling_price: undefined, + purchase_price: undefined, +} + +// ── 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): PartFormValues { + const d = (data as any)?.data ?? data ?? {} + + return { + shop_type: null, + category: null, + unit_type: null, + department: null, + title: d.title ?? d.name ?? "", + sku: d.sku ?? "", + description: d.description ?? "", + selling_price: d.selling_price ?? undefined, + purchase_price: d.purchase_price ?? undefined, + } +} + +function mapCreatePayload(values: PartFormValues) { + return { + shop_type_id: toId(values.shop_type), + category_id: toId(values.category), + unit_type_id: toId(values.unit_type), + department_id: toId(values.department), + title: values.title, + sku: values.sku || undefined, + description: values.description || undefined, + selling_price: values.selling_price, + purchase_price: values.purchase_price, + } +} + +function mapUpdatePayload(values: PartFormValues) { + return { + title: values.title, + selling_price: values.selling_price, + } +} + +// ── Component ── + +export function PartForm({ resourceId, initialData, onSuccess }: PartFormProps) { + const api = useAuthApi() + + const { form, isEditing } = useResourceForm({ + schema: partFormSchema, + defaultValues: DEFAULT_VALUES, + resourceId, + initialData, + mapToFormValues, + }) + + const { mutate, error, isPending } = useFormMutation(form, { + mutationFn: (values: PartFormValues) => { + const promise = isEditing && resourceId + ? api.parts.update(resourceId, mapUpdatePayload(values)) + : api.parts.create(mapCreatePayload(values)) + toast.promise(promise, { + loading: isEditing ? "Updating part..." : "Creating part...", + success: isEditing ? "Part updated successfully" : "Part created successfully", + error: isEditing ? "Failed to update part" : "Failed to create part", + }) + return promise + }, + onSuccess: () => { + form.reset() + onSuccess?.() + }, + }) + + return ( + mutate(values)}> + {error && ( + + + + {isEditing ? "Failed to update part" : "Failed to create part"} + + {error.message} + + )} + + +
+ + +
+ + {!isEditing && ( + <> +
+ api.shopTypes.list()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Shop Type" + {...STORE_OBJECT} + /> + api.inventory.listCategories()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Category" + {...STORE_OBJECT} + /> +
+ +
+ api.inventory.listUnitTypes()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Unit Type" + {...STORE_OBJECT} + /> + api.departments.list()} + mapOption={(item: any) => ({ value: String(item.id), label: item.name ?? String(item.id) })} + createForm={(props) => } + createLabel="Department" + {...STORE_OBJECT} + /> +
+ + )} + +
+ + {!isEditing && ( + + )} +
+ + +
+ +
+ +
+
+ ) +} diff --git a/modules/parts/part.schema.ts b/modules/parts/part.schema.ts new file mode 100644 index 0000000..c07cc37 --- /dev/null +++ b/modules/parts/part.schema.ts @@ -0,0 +1,19 @@ +import { z } from "zod" + +export const relationFieldSchema = z + .object({ value: z.string(), label: z.string() }) + .nullable() + +export const partFormSchema = z.object({ + shop_type: relationFieldSchema, + category: relationFieldSchema, + unit_type: relationFieldSchema, + department: relationFieldSchema, + title: z.string().min(1, "Title is required"), + sku: z.string().optional(), + description: z.string().optional(), + selling_price: z.coerce.number().min(0).optional(), + purchase_price: z.coerce.number().min(0).optional(), +}) + +export type PartFormValues = z.infer diff --git a/modules/service-groups/service-group-form.tsx b/modules/service-groups/service-group-form.tsx new file mode 100644 index 0000000..fa4c0e7 --- /dev/null +++ b/modules/service-groups/service-group-form.tsx @@ -0,0 +1,274 @@ +"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, + RhfCheckboxField, +} from "@/shared/components/form" +import { ShopTypeInlineForm } from "@/modules/vehicles/inline-forms/shop-type-inline-form" +import { InventoryCategoryInlineForm } from "@/modules/services/inline-forms/inventory-category-inline-form" +import { UnitTypeInlineForm } from "@/modules/services/inline-forms/unit-type-inline-form" +import { DepartmentInlineForm } from "@/modules/services/inline-forms/department-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 { toId } from "@/shared/lib/utils" + +import { serviceGroupFormSchema, type ServiceGroupFormValues } from "./service-group.schema" +import { SERVICE_GROUP_ROUTES } from "@repo/api" + +// ── Props ── + +export type ServiceGroupFormProps = { + resourceId?: string | null + initialData?: unknown + onSuccess?: () => void +} + +// ── Default values ── + +const DEFAULT_VALUES: ServiceGroupFormValues = { + shop_type: null, + inventory_category: null, + unit_type: null, + department: null, + service_name: "", + code: "", + service_description: "", + selling_price: undefined, + selling_chart_of_account: "", + show_as_lump_sum: false, + mark_as_recommended: false, + set_packaged_pricing: false, + is_active: true, +} + +// ── 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): ServiceGroupFormValues { + const d = (data as any)?.data ?? data ?? {} + + return { + shop_type: null, + inventory_category: null, + unit_type: null, + department: null, + service_name: d.service_name ?? d.name ?? "", + code: d.code ?? "", + service_description: d.service_description ?? "", + selling_price: d.selling_price ?? undefined, + selling_chart_of_account: d.selling_chart_of_account ?? "", + show_as_lump_sum: d.show_as_lump_sum ?? false, + mark_as_recommended: d.mark_as_recommended ?? false, + set_packaged_pricing: d.set_packaged_pricing ?? false, + is_active: d.is_active ?? true, + } +} + +function mapCreatePayload(values: ServiceGroupFormValues) { + return { + service_name: values.service_name, + shop_type_id: toId(values.shop_type), + code: values.code || undefined, + inventory_category_id: toId(values.inventory_category), + unit_type_id: toId(values.unit_type), + department_id: toId(values.department), + service_description: values.service_description || undefined, + show_as_lump_sum: values.show_as_lump_sum, + mark_as_recommended: values.mark_as_recommended, + set_packaged_pricing: values.set_packaged_pricing, + selling_price: values.selling_price, + selling_chart_of_account: values.selling_chart_of_account || undefined, + is_active: values.is_active, + } +} + +function mapUpdatePayload(values: ServiceGroupFormValues) { + return { + service_name: values.service_name, + selling_price: values.selling_price, + is_active: values.is_active, + } +} + +// ── Component ── + +export function ServiceGroupForm({ resourceId, initialData, onSuccess }: ServiceGroupFormProps) { + const api = useAuthApi() + + const { form, isEditing } = useResourceForm({ + schema: serviceGroupFormSchema, + defaultValues: DEFAULT_VALUES, + resourceId, + initialData, + mapToFormValues, + }) + + const { mutate, error, isPending } = useFormMutation(form, { + mutationFn: (values: ServiceGroupFormValues) => { + const promise = isEditing && resourceId + ? api.serviceGroups.update(resourceId, mapUpdatePayload(values)) + : api.serviceGroups.create(mapCreatePayload(values)) + toast.promise(promise, { + loading: isEditing ? "Updating service group..." : "Creating service group...", + success: isEditing ? "Service group updated" : "Service group created", + error: isEditing ? "Failed to update service group" : "Failed to create service group", + }) + return promise + }, + onSuccess: () => { + form.reset() + onSuccess?.() + }, + }) + + return ( + mutate(values)}> + {error && ( + + + + {isEditing ? "Failed to update service group" : "Failed to create service group"} + + {error.message} + + )} + + +
+ + +
+ + {!isEditing && ( + <> +
+ api.shopTypes.list()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Shop Type" + {...STORE_OBJECT} + /> + api.inventory.listCategories()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Category" + {...STORE_OBJECT} + /> +
+ +
+ api.inventory.listUnitTypes()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Unit Type" + {...STORE_OBJECT} + /> + api.departments.list()} + mapOption={(item: any) => ({ value: String(item.id), label: item.name ?? String(item.id) })} + createForm={(props) => } + createLabel="Department" + {...STORE_OBJECT} + /> +
+ + )} + +
+ + +
+ + + +
+ + + + +
+ + +
+ +
+ +
+
+ ) +} diff --git a/modules/service-groups/service-group.schema.ts b/modules/service-groups/service-group.schema.ts new file mode 100644 index 0000000..01cc51c --- /dev/null +++ b/modules/service-groups/service-group.schema.ts @@ -0,0 +1,23 @@ +import { z } from "zod" + +export const relationFieldSchema = z + .object({ value: z.string(), label: z.string() }) + .nullable() + +export const serviceGroupFormSchema = z.object({ + shop_type: relationFieldSchema, + inventory_category: relationFieldSchema, + unit_type: relationFieldSchema, + department: relationFieldSchema, + service_name: z.string().min(1, "Service name is required"), + code: z.string().optional(), + service_description: z.string().optional(), + selling_price: z.coerce.number().min(0).optional(), + selling_chart_of_account: z.string().optional(), + show_as_lump_sum: z.boolean().optional(), + mark_as_recommended: z.boolean().optional(), + set_packaged_pricing: z.boolean().optional(), + is_active: z.boolean().optional(), +}) + +export type ServiceGroupFormValues = z.infer diff --git a/modules/services/department-assignment-types.ts b/modules/services/department-assignment-types.ts new file mode 100644 index 0000000..44a1af1 --- /dev/null +++ b/modules/services/department-assignment-types.ts @@ -0,0 +1,7 @@ +export const DEPARTMENT_ASSIGNMENT_TYPE_OPTIONS = [ + { value: "none", label: "None" }, + { value: "bays", label: "Bays" }, + { value: "outsourced", label: "Outsourced" }, +] as const + +export type DepartmentAssignmentType = typeof DEPARTMENT_ASSIGNMENT_TYPE_OPTIONS[number]["value"] diff --git a/modules/services/inline-forms/category-inline-form.tsx b/modules/services/inline-forms/category-inline-form.tsx new file mode 100644 index 0000000..82a67b6 --- /dev/null +++ b/modules/services/inline-forms/category-inline-form.tsx @@ -0,0 +1,2 @@ +// Renamed to inventory-category-inline-form.tsx +export { InventoryCategoryInlineForm as CategoryInlineForm } from "./inventory-category-inline-form" diff --git a/modules/services/inline-forms/department-inline-form.tsx b/modules/services/inline-forms/department-inline-form.tsx new file mode 100644 index 0000000..2bfff41 --- /dev/null +++ b/modules/services/inline-forms/department-inline-form.tsx @@ -0,0 +1,66 @@ +"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, RhfSelectField, type InlineCreateFormProps } from "@/shared/components/form" +import { toast } from "sonner" +import { useAuthApi } from "@/shared/useApi" +import { DEPARTMENT_ASSIGNMENT_TYPE_OPTIONS } from "../department-assignment-types" + +const schema = z.object({ + name: z.string().min(1, "Name is required"), + assignment_type: z.string().optional(), +}) + +type FormValues = z.infer + +export function DepartmentInlineForm({ onSuccess }: InlineCreateFormProps) { + const api = useAuthApi() + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { name: "", assignment_type: "none" }, + }) + + const handleSubmit = async (values: FormValues) => { + try { + const result = await api.departments.create({ + name: values.name, + assignment_type: values.assignment_type || undefined, + }) + toast.success("Department created") + form.reset() + const item = (result as any)?.data ?? result + onSuccess({ value: String(item.id), label: item.name ?? String(item.id) }) + } catch { + toast.error("Failed to create department") + } + } + + return ( + + + + + + + + ) +} diff --git a/modules/services/inline-forms/inventory-category-inline-form.tsx b/modules/services/inline-forms/inventory-category-inline-form.tsx new file mode 100644 index 0000000..5bcf2c8 --- /dev/null +++ b/modules/services/inline-forms/inventory-category-inline-form.tsx @@ -0,0 +1,78 @@ +"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, RhfAsyncSelectField, type InlineCreateFormProps } from "@/shared/components/form" +import { ShopTypeInlineForm } from "@/modules/vehicles/inline-forms/shop-type-inline-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.object({ value: z.string(), label: z.string() }).nullable(), +}) + +type FormValues = z.infer + +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 } + +export function InventoryCategoryInlineForm({ onSuccess }: InlineCreateFormProps) { + const api = useAuthApi() + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { title: "", shop_type: null }, + }) + + const handleSubmit = async (values: FormValues) => { + try { + const result = await api.inventory.createCategory({ + title: values.title, + shop_type_id: values.shop_type ? Number(values.shop_type.value) : undefined, + }) + toast.success("Category created") + form.reset() + const item = (result as any)?.data ?? result + onSuccess({ value: String(item.id), label: item.title ?? item.name ?? String(item.id) }) + } catch { + toast.error("Failed to create category") + } + } + + return ( + + + + api.shopTypes.list()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Shop Type" + {...STORE_OBJECT} + /> + + + + ) +} diff --git a/modules/services/inline-forms/unit-type-inline-form.tsx b/modules/services/inline-forms/unit-type-inline-form.tsx new file mode 100644 index 0000000..70c7bf0 --- /dev/null +++ b/modules/services/inline-forms/unit-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 UnitTypeInlineForm({ onSuccess }: InlineCreateFormProps) { + const api = useAuthApi() + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { title: "" }, + }) + + const handleSubmit = async (values: FormValues) => { + try { + const result = await api.inventory.createUnitType({ title: values.title }) + toast.success("Unit type created") + form.reset() + const item = (result as any)?.data ?? result + onSuccess({ value: String(item.id), label: item.title ?? item.name ?? String(item.id) }) + } catch { + toast.error("Failed to create unit type") + } + } + + return ( + + + + + + + ) +} diff --git a/modules/services/service-form.tsx b/modules/services/service-form.tsx new file mode 100644 index 0000000..b783fbc --- /dev/null +++ b/modules/services/service-form.tsx @@ -0,0 +1,238 @@ +"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 "@/modules/vehicles/inline-forms/shop-type-inline-form" +import { InventoryCategoryInlineForm } from "./inline-forms/inventory-category-inline-form" +import { UnitTypeInlineForm } from "./inline-forms/unit-type-inline-form" +import { DepartmentInlineForm } from "./inline-forms/department-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 { toId } from "@/shared/lib/utils" + +import { serviceFormSchema, type ServiceFormValues } from "./service.schema" +import { SERVICE_ROUTES } from "@repo/api" + +// ── Props ── + +export type ServiceFormProps = { + resourceId?: string | null + initialData?: unknown + onSuccess?: () => void +} + +// ── Default values ── + +const DEFAULT_VALUES: ServiceFormValues = { + shop_type: null, + category: null, + unit_type: null, + department: null, + labor_name: "", + service_code: "", + labor_matrix: "", + description: "", + selling_price: undefined, +} + +// ── 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): ServiceFormValues { + const d = (data as any)?.data ?? data ?? {} + + return { + shop_type: null, + category: null, + unit_type: null, + department: null, + labor_name: d.name || d.labor_name || "", + service_code: d.service_code || "", + labor_matrix: d.labor_matrix || "", + description: d.description || "", + selling_price: d.selling_price ?? undefined, + } +} + +function mapCreatePayload(values: ServiceFormValues) { + return { + shop_type_id: toId(values.shop_type), + category_id: toId(values.category), + unit_type_id: toId(values.unit_type), + department_id: toId(values.department), + labor_name: values.labor_name, + service_code: values.service_code || undefined, + labor_matrix: values.labor_matrix || undefined, + description: values.description || undefined, + selling_price: values.selling_price, + } +} + +function mapUpdatePayload(values: ServiceFormValues) { + return { + labor_name: values.labor_name, + selling_price: values.selling_price, + } +} + +// ── Component ── + +export function ServiceForm({ resourceId, initialData, onSuccess }: ServiceFormProps) { + const api = useAuthApi() + + const { form, isEditing } = useResourceForm({ + schema: serviceFormSchema, + defaultValues: DEFAULT_VALUES, + resourceId, + initialData, + mapToFormValues, + }) + + const { mutate, error, isPending } = useFormMutation(form, { + mutationFn: (values: ServiceFormValues) => { + const promise = isEditing && resourceId + ? api.services.update(resourceId, mapUpdatePayload(values)) + : api.services.create(mapCreatePayload(values)) + toast.promise(promise, { + loading: isEditing ? "Updating service..." : "Creating service...", + success: isEditing ? "Service updated successfully" : "Service created successfully", + error: isEditing ? "Failed to update service" : "Failed to create service", + }) + return promise + }, + onSuccess: () => { + form.reset() + onSuccess?.() + }, + }) + + return ( + mutate(values)}> + {error && ( + + + + {isEditing ? "Failed to update service" : "Failed to create service"} + + {error.message} + + )} + + +
+ + +
+ + {!isEditing && ( + <> +
+ api.shopTypes.list()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Shop Type" + {...STORE_OBJECT} + /> + api.inventory.listCategories()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Category" + {...STORE_OBJECT} + /> +
+ +
+ api.inventory.listUnitTypes()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Unit Type" + {...STORE_OBJECT} + /> + api.departments.list()} + mapOption={(item: any) => ({ value: String(item.id), label: item.name ?? String(item.id) })} + createForm={(props) => } + createLabel="Department" + {...STORE_OBJECT} + /> +
+ + + + )} + +
+ +
+ + + + +
+
+ ) +} diff --git a/modules/services/service.schema.ts b/modules/services/service.schema.ts new file mode 100644 index 0000000..116b02f --- /dev/null +++ b/modules/services/service.schema.ts @@ -0,0 +1,19 @@ +import { z } from "zod" + +export const relationFieldSchema = z + .object({ value: z.string(), label: z.string() }) + .nullable() + +export const serviceFormSchema = z.object({ + shop_type: relationFieldSchema, + category: relationFieldSchema, + unit_type: relationFieldSchema, + department: relationFieldSchema, + labor_name: z.string().min(1, "Labor name is required"), + service_code: z.string().optional(), + labor_matrix: z.string().optional(), + description: z.string().optional(), + selling_price: z.coerce.number().min(0).optional(), +}) + +export type ServiceFormValues = z.infer diff --git a/shared/components/form/controls/checkbox-field.tsx b/shared/components/form/controls/checkbox-field.tsx index 66a9e79..4756e3d 100644 --- a/shared/components/form/controls/checkbox-field.tsx +++ b/shared/components/form/controls/checkbox-field.tsx @@ -1,7 +1,7 @@ "use client" import type { BaseFieldControlProps } from "../types" -import { Checkbox } from "@/shared/components/ui/checkbox" +import { Switch } from "@/shared/components/ui/switch" export type CheckboxFieldProps = BaseFieldControlProps & { label?: string @@ -16,7 +16,7 @@ export function CheckboxField({ invalid, }: CheckboxFieldProps) { return ( - onChange(checked === true)} onBlur={onBlur} diff --git a/shared/components/form/fields/rhf-checkbox-field.tsx b/shared/components/form/fields/rhf-checkbox-field.tsx index 13db6a1..9961e22 100644 --- a/shared/components/form/fields/rhf-checkbox-field.tsx +++ b/shared/components/form/fields/rhf-checkbox-field.tsx @@ -1,9 +1,10 @@ "use client" import type { FieldValues, FieldPath } from "react-hook-form" -import { RhfField } from "../rhf-field" +import { useFormContext, useController } from "react-hook-form" import { CheckboxField, type CheckboxFieldProps } from "../controls/checkbox-field" import type { BaseFieldControlProps } from "../types" +import { FieldError } from "@/shared/components/ui/field" type RhfCheckboxFieldProps< TValues extends FieldValues, @@ -19,6 +20,43 @@ type RhfCheckboxFieldProps< export function RhfCheckboxField< TValues extends FieldValues, TName extends FieldPath, ->(props: RhfCheckboxFieldProps) { - return +>({ + name, + label, + description, + required, + disabled, + ...controlProps +}: RhfCheckboxFieldProps) { + const { control } = useFormContext() + const { + field, + fieldState: { error }, + } = useController({ name, control, disabled }) + + return ( +
+
+ {label && ( +

+ {label} + {required && *} +

+ )} + {description && ( +

{description}

+ )} + {error && {error.message}} +
+ +
+ ) } diff --git a/shared/components/ui/checkbox.tsx b/shared/components/ui/checkbox.tsx index 168fa1f..b3b01ca 100644 --- a/shared/components/ui/checkbox.tsx +++ b/shared/components/ui/checkbox.tsx @@ -14,14 +14,14 @@ function Checkbox({