This commit is contained in:
Mohammad Khyata 2026-04-06 02:32:47 +03:00
parent 5f3d208158
commit 24a44481a0
152 changed files with 85913 additions and 44338 deletions

View File

@ -0,0 +1,76 @@
---
name: api-enums-reference
description: "Use the central API enums file as the source of truth for enum values in this project. Use when: adding enum fields, updating enum options, creating form selects, typing status/discount/rate fields, syncing backend enum changes, or avoiding duplicated hardcoded enum literals."
---
# API Enums Reference
Use this skill whenever work touches enum-like fields in API clients, schemas, forms, table filters, or page logic.
## Source of Truth
All shared enum values and enum union types must come from:
- packages/api/src/contracts/enums.ts
Do not recreate enum arrays inline when an equivalent enum already exists in this file.
## Rules
1. Reuse before creating.
Search and import from `@garage/api` exports (or local contracts path inside packages/api) before adding new literals.
2. Keep runtime and type together.
For every enum, keep this pattern in `enums.ts`:
```ts
export const ExampleStatus = ['a', 'b'] as const;
export type ExampleStatus = (typeof ExampleStatus)[number];
```
3. Preserve backend values exactly.
Enum string values are case- and space-sensitive; keep exact spelling from backend migrations/spec.
4. Avoid duplicate synonyms.
If two domains share the same canonical values, prefer reusing an existing enum unless domain separation is intentional.
5. Update centrally first.
When backend enum options change, update `packages/api/src/contracts/enums.ts` first, then update consuming UI/API code.
6. Prefer imports in forms and schemas.
Use central enums for select options and for typed payload/status fields instead of hardcoded string unions.
## Workflow
1. Identify the enum field and backend values.
2. Check `packages/api/src/contracts/enums.ts` for an existing enum.
3. If found, import and use it.
4. If missing, add a new const+type pair in `enums.ts`.
5. Update consumers to reference the central enum.
6. Verify there are no duplicated literal arrays for the same field.
## Examples
```ts
import { InvoiceStatus, type InvoiceStatus as InvoiceStatusType } from '@garage/api'
const statusOptions = InvoiceStatus
type Payload = {
status: InvoiceStatusType
}
```
```ts
import { DiscountType } from '@garage/api'
const discountOptions = DiscountType.map((value) => ({
label: value,
value,
}))
```
## Notes
- If a module needs a presentation-specific label, map from the central enum value instead of changing raw enum literals.
- If backend adds/removes values, keep API and dashboard aligned in the same change set.

View File

@ -0,0 +1,86 @@
---
name: date-time-pickers
description: "Use RhfDateField and RhfTimeField (shadcn Calendar/Popover-based) for all date and time inputs in forms. Use when: adding date fields, adding time fields, replacing `type=\"date\"` or `type=\"time\"` RhfTextField inputs, building any form that captures a date or time value."
---
# Date & Time Pickers
Always use the shadcn-based picker components for date and time fields. Never use `<RhfTextField type="date">` or `<RhfTextField type="time">`.
## Components
| Use For | Component | Import |
|---|---|---|
| Date fields (YYYY-MM-DD) | `RhfDateField` | `@/shared/components/form` |
| Time fields (HH:MM:SS) | `RhfTimeField` | `@/shared/components/form` |
## RhfDateField
Renders a shadcn Calendar inside a Popover. Value is a `string` in `"YYYY-MM-DD"` format.
```tsx
import { RhfDateField } from "@/shared/components/form"
<RhfDateField name="check_in_date" label="Check-in Date" />
```
**Schema type**: `z.string().optional()` (stores `"YYYY-MM-DD"`)
**Default value**: `""` for empty, or `new Date().toISOString().split("T")[0]` for today.
**`mapToFormValues`**: `d.check_in_date ? d.check_in_date.split("T")[0] : ""`
**`mapFormToPayload`**: `values.check_in_date || undefined`
## RhfTimeField
Renders an HH / MM / SS spinner inside a Popover. Value is a `string` in `"HH:MM:SS"` format.
```tsx
import { RhfTimeField } from "@/shared/components/form"
// With seconds (default)
<RhfTimeField name="check_in_time" label="Check-in Time" withSeconds />
// Without seconds
<RhfTimeField name="check_in_time" label="Check-in Time" />
```
**Props**:
- `withSeconds?: boolean` — show the SS spinner (default `true`)
- `placeholder?: string` — trigger button placeholder text
**Schema type**: `z.string().optional()` (stores `"HH:MM:SS"` or `"HH:MM"`)
**Default value for current time**:
```ts
(() => {
const n = new Date()
return `${String(n.getHours()).padStart(2,"0")}:${String(n.getMinutes()).padStart(2,"0")}:${String(n.getSeconds()).padStart(2,"0")}`
})()
```
**`mapToFormValues`** (API returns `"HH:MM"` or ISO datetime):
- If ISO: `d.check_in_time ? d.check_in_time.split("T")[1]?.slice(0, 8) ?? "" : ""`
- If plain time string: `d.check_in_time ?? ""`
**`mapFormToPayload`**: `values.check_in_time || undefined`
## Underlying Controls (non-RHF use)
If you need to use the pickers outside of RHF:
```tsx
import { DatePickerField, TimePickerField } from "@/shared/components/form"
<DatePickerField value={date} onChange={setDate} placeholder="Select date" />
<TimePickerField value={time} onChange={setTime} withSeconds />
```
## File Locations
- Control: `apps/dashboard/shared/components/form/controls/date-picker-field.tsx`
- Control: `apps/dashboard/shared/components/form/controls/time-picker-field.tsx`
- RHF wrapper: `apps/dashboard/shared/components/form/fields/rhf-date-field.tsx`
- RHF wrapper: `apps/dashboard/shared/components/form/fields/rhf-time-field.tsx`
- Exports: `apps/dashboard/shared/components/form/index.ts`

View File

@ -0,0 +1,29 @@
import { DashboardDetailsPage } from "@/base/components/layout/dashboard"
import { AppointmentActions } from "@/modules/appointments/appointment-actions"
import { AppointmentProvider } from "@/modules/appointments/appointment-context"
import { CalendarCheck2 } from "lucide-react"
import React from "react"
export default async function layout(props: {
params: Promise<{ id: string }>
children: React.ReactNode
}) {
const { id } = await props.params
return (
<AppointmentProvider appointment={{ id, label: `Appointment #${id}` }}>
<DashboardDetailsPage
className="p-0 lg:p-0"
icon={<CalendarCheck2 className="size-5" />}
title={`Appointment #${id}`}
backHref="/calendar/appointment/list"
actions={<AppointmentActions appointmentId={id} />}
tabs={[
{ href: `/calendar/appointment/${id}`, label: "Details" },
]}
>
{props.children}
</DashboardDetailsPage>
</AppointmentProvider>
)
}

View File

@ -0,0 +1,49 @@
"use client"
import { use } from "react"
import { useQuery } from "@tanstack/react-query"
import { useAuthApi } from "@/shared/useApi"
import { APPOINTMENT_ROUTES } from "@garage/api"
import { AppointmentGeneralInfo } from "@/modules/appointments/appointment-general-info"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function AppointmentDetailsPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const api = useAuthApi()
const { data, isLoading } = useQuery({
queryKey: [APPOINTMENT_ROUTES.INDEX, "detail", id],
queryFn: async () => {
const response = await api.appointments.list()
const items = (response as any)?.data ?? []
return items.find((item: any) => String(item.id) === id) ?? null
},
})
if (isLoading) {
return (
<DashboardPage header={null}>
<div className="grid gap-6 md:grid-cols-2 p-6">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-48 rounded-xl" />
))}
</div>
</DashboardPage>
)
}
if (!data) {
return (
<DashboardPage header={null}>
<p className="text-muted-foreground p-6">Appointment not found.</p>
</DashboardPage>
)
}
return (
<DashboardPage header={null}>
<AppointmentGeneralInfo appointment={data} />
</DashboardPage>
)
}

View File

@ -0,0 +1,112 @@
"use client"
import { useRouter } from "next/navigation"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { AppointmentForm } from "@/modules/appointments/appointment-form"
import { APPOINTMENT_ROUTES } from "@garage/api"
import type { AppointmentsClient } from "@garage/api"
import { CalendarCheck2Icon, ClipboardListIcon, ClockIcon, ExternalLinkIcon } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
const STATUS_COLORS: Record<string, string> = {
requested: "bg-yellow-100 text-yellow-800",
confirmed: "bg-blue-100 text-blue-800",
in_progress: "bg-purple-100 text-purple-800",
completed: "bg-green-100 text-green-800",
cancelled: "bg-red-100 text-red-800",
}
export default function AppointmentsPage() {
const router = useRouter()
return (
<ResourcePage<AppointmentsClient>
pageTitle="Appointments"
routeKey={APPOINTMENT_ROUTES.INDEX}
getClient={(api) => api.appointments}
onRowClick={(row) => router.push(`/calendar/appointment/${(row as any).id}`)}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<FormDialog title="Appointment">
{(resourceId) => (
<AppointmentForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<CalendarCheck2Icon className="size-4 text-muted-foreground" />
<span>{(row.original as any).title}</span>
</div>
),
},
{
accessorKey: "date",
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
},
{
accessorKey: "from_time",
header: ({ column }) => <ColumnHeader column={column} title="Time" />,
cell: ({ row }) => {
const r = row.original as any
return (
<div className="flex items-center gap-1">
<ClockIcon className="size-3 text-muted-foreground" />
<span>{r.from_time} {r.to_time}</span>
</div>
)
},
},
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const status = (row.original as any).status
const colorClass = STATUS_COLORS[status] ?? "bg-gray-100 text-gray-800"
return (
<Badge className={colorClass}>
{status?.replace("_", " ") ?? "—"}
</Badge>
)
},
},
{
id: "job_card",
header: ({ column }) => <ColumnHeader column={column} title="Job Card" />,
cell: ({ row }) => {
const jobCardId = (row.original as any).job_card_id
if (!jobCardId) return <span className="text-muted-foreground"></span>
return (
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5"
onClick={(e) => {
e.stopPropagation()
router.push(`/sales/job-cards/${jobCardId}`)
}}
>
<ClipboardListIcon className="size-3" />
#{jobCardId}
<ExternalLinkIcon className="size-3" />
</Button>
)
},
},
actionsColumn(),
]}
/>
)
}

View File

@ -0,0 +1,292 @@
"use client"
import { useState, useRef } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { Paperclip, Plus, Trash2, FileIcon, ImageIcon, FileTextIcon } from "lucide-react"
import { toast } from "sonner"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { InventoryAdjustmentForm } from "@/modules/inventory-adjustments/inventory-adjustment-form"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent } from "@/shared/components/ui/card"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { confirm } from "@/shared/components/confirm-dialog"
import { useAuthApi } from "@/shared/useApi"
import { INVENTORY_ADJUSTMENT_ROUTES } from "@garage/api"
import type { InventoryAdjustmentsClient } from "@garage/api"
// ── Attachment helpers ──
type AttachmentFile = {
id: number
original_name?: string
attachment_path?: string
created_at?: string
}
function getFileIcon(path?: string) {
if (!path) return FileIcon
const lower = path.toLowerCase()
if (/\.(jpg|jpeg|png|gif|webp|svg)$/.test(lower)) return ImageIcon
if (/\.pdf$/.test(lower)) return FileTextIcon
return FileIcon
}
// ── Attachments Dialog ──
function AttachmentsDialog({
open,
adjustmentId,
adjustmentRef,
onClose,
}: {
open: boolean
adjustmentId: string
adjustmentRef: string
onClose: () => void
}) {
const api = useAuthApi()
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const [isUploading, setIsUploading] = useState(false)
const [sessionFiles, setSessionFiles] = useState<AttachmentFile[]>([])
const queryKey = [INVENTORY_ADJUSTMENT_ROUTES.INDEX, adjustmentId, "attachments"]
const deleteMutation = useMutation({
mutationFn: (attachmentId: number) =>
api.inventoryAdjustments.deleteAttachment(adjustmentId, attachmentId),
onSuccess: (_, attachmentId) => {
toast.success("Attachment deleted.")
setSessionFiles((prev) => prev.filter((f) => f.id !== attachmentId))
queryClient.invalidateQueries({ queryKey })
},
onError: () => toast.error("Failed to delete attachment."),
})
const handleDelete = async (attachment: AttachmentFile) => {
const confirmed = await confirm({
title: "Delete Attachment",
description: `Are you sure you want to delete "${attachment.original_name ?? "this file"}"?`,
confirmLabel: "Delete",
variant: "destructive",
})
if (confirmed) deleteMutation.mutate(attachment.id)
}
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files || files.length === 0) return
setIsUploading(true)
const fileArray = Array.from(files)
try {
const result = await toast.promise(
api.inventoryAdjustments.addAttachment(adjustmentId, fileArray),
{
loading: "Uploading attachment(s)...",
success: "Attachment(s) uploaded successfully",
error: "Failed to upload attachment(s)",
},
)
// Track uploaded files locally for display within this session
const now = new Date().toISOString()
const uploaded: AttachmentFile[] = fileArray.map((file, i) => ({
id: Date.now() + i,
original_name: file.name,
attachment_path: file.name,
created_at: now,
}))
setSessionFiles((prev) => [...prev, ...uploaded])
queryClient.invalidateQueries({ queryKey })
} finally {
setIsUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ""
}
}
const handleClose = () => {
setSessionFiles([])
onClose()
}
return (
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Attachments {adjustmentRef}</DialogTitle>
</DialogHeader>
<div className="flex justify-end">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleUpload}
/>
<Button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
size="sm"
>
<Plus className="size-4" />
{isUploading ? "Uploading..." : "Upload Attachment"}
</Button>
</div>
{sessionFiles.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No attachments uploaded in this session. Click "Upload Attachment" to add files.
</CardContent>
</Card>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{sessionFiles.map((attachment) => {
const Icon = getFileIcon(attachment.attachment_path)
return (
<Card key={attachment.id}>
<CardContent className="flex items-center gap-3 p-4">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Icon className="size-5" />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm font-medium" title={attachment.original_name}>
{attachment.original_name}
</span>
{attachment.created_at && (
<span className="text-xs text-muted-foreground">
{new Date(attachment.created_at).toLocaleDateString()}
</span>
)}
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleDelete(attachment)}
title="Delete attachment"
>
<Trash2 className="size-4 text-destructive" />
</Button>
</CardContent>
</Card>
)
})}
</div>
)}
</DialogContent>
</Dialog>
)
}
// ── Page ──
export default function InventoryAdjustmentsPage() {
const [attachmentTarget, setAttachmentTarget] = useState<{
id: string
ref: string
} | null>(null)
return (
<>
<ResourcePage<InventoryAdjustmentsClient>
pageTitle="Inventory Adjustments"
routeKey={INVENTORY_ADJUSTMENT_ROUTES.INDEX}
getClient={(api) => api.inventoryAdjustments}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<FormDialog title="Inventory Adjustment">
{(resourceId) => (
<InventoryAdjustmentForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "reference_number",
header: ({ column }) => <ColumnHeader column={column} title="Reference #" />,
cell: ({ row }) => (row.original as any).reference_number || "—",
},
{
accessorKey: "date",
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
cell: ({ row }) => {
const val = (row.original as any).date
return val ? new Date(val).toLocaleDateString() : "—"
},
},
{
accessorKey: "chart_of_account",
header: ({ column }) => <ColumnHeader column={column} title="Chart of Account" />,
cell: ({ row }) => (row.original as any).chart_of_account || "—",
},
{
accessorKey: "notes",
header: ({ column }) => <ColumnHeader column={column} title="Notes" />,
cell: ({ row }) => {
const notes = (row.original as any).notes
return notes ? (
<span className="max-w-50 truncate block" title={notes}>{notes}</span>
) : "—"
},
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
cell: ({ row }) => {
const val = (row.original as any).created_at
return val ? new Date(val).toLocaleDateString() : "—"
},
},
{
id: "attachments",
header: () => null,
cell: ({ row }) => {
const item = row.original as any
return (
<Button
variant="ghost"
size="icon-sm"
title="Manage Attachments"
onClick={(e) => {
e.stopPropagation()
setAttachmentTarget({
id: String(item.id),
ref: item.reference_number || `ADJ-${item.id}`,
})
}}
>
<Paperclip className="size-4" />
</Button>
)
},
},
actionsColumn(),
]}
/>
{attachmentTarget && (
<AttachmentsDialog
open={!!attachmentTarget}
adjustmentId={attachmentTarget.id}
adjustmentRef={attachmentTarget.ref}
onClose={() => setAttachmentTarget(null)}
/>
)}
</>
)
}

View File

@ -0,0 +1,39 @@
import { DashboardDetailsPage } from '@/base/components/layout/dashboard'
import { getServerApi } from '@garage/api/server'
import { EmployeeActions } from '@/modules/employees/employee-actions'
import { EmployeeProvider } from '@/modules/employees/employee-context'
import React from 'react'
export default async function layout(props: {
params: Promise<{ id: string }>
children: React.ReactNode
}) {
const { id } = await props.params
const api = await getServerApi()
const employee = await api.employees.getById(id)
const firstName = employee.data?.first_name || ''
const lastName = employee.data?.last_name || ''
const title = [firstName, lastName].filter(Boolean).join(' ') || 'Employee Details'
const employeeLabel = title
return (
<>
<EmployeeProvider employee={{ id, label: employeeLabel }}>
<DashboardDetailsPage
className="p-0 lg:p-0"
title={title}
description={employee.data?.position || employee.data?.designation || undefined}
backHref="/productivity/employees"
actions={<EmployeeActions employeeId={id} />}
tabs={[
{ href: `/productivity/employees/${id}`, label: 'Details' },
{ href: `/productivity/employees/${id}/permissions`, label: 'Permissions' },
]}
>
{props.children}
</DashboardDetailsPage>
</EmployeeProvider>
</>
)
}

View File

@ -0,0 +1,19 @@
import { getServerApi } from '@garage/api/server'
import { EmployeeGeneralInfo } from '@/modules/employees/employee-general-info'
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
export default async function page(props: { params: Promise<{ id: string }> }) {
const { id } = await props.params
const api = await getServerApi()
const employee = await api.employees.getById(id)
if (!employee.data) {
return <div className="text-muted-foreground">Employee not found.</div>
}
return (
<DashboardPage header={null}>
<EmployeeGeneralInfo employee={employee.data as any} />
</DashboardPage>
)
}

View File

@ -0,0 +1,10 @@
"use client"
import { use } from "react"
import { EmployeePermissionsForm } from "@/modules/employees/employee-permissions-form"
export default function EmployeePermissionsPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
return <EmployeePermissionsForm employeeId={id} />
}

View File

@ -6,13 +6,16 @@ import FormDialog from "@/shared/components/form-dialog"
import { EmployeeForm } from "@/modules/employees/employee-form" import { EmployeeForm } from "@/modules/employees/employee-form"
import { EMPLOYEE_ROUTES } from "@garage/api" import { EMPLOYEE_ROUTES } from "@garage/api"
import type { EmployeesClient } from "@garage/api" import type { EmployeesClient } from "@garage/api"
import { useRouter } from "next/navigation"
export default function EmployeesPage() { export default function EmployeesPage() {
const router = useRouter()
return ( return (
<ResourcePage<EmployeesClient> <ResourcePage<EmployeesClient>
pageTitle="Employees" pageTitle="Employees"
routeKey={EMPLOYEE_ROUTES.INDEX} routeKey={EMPLOYEE_ROUTES.INDEX}
getClient={(api) => api.employees} getClient={(api) => api.employees}
onRowClick={(row) => router.push(`/productivity/employees/${(row as any).id}`)}
headerProps={({ selectedItem, invalidateQuery }) => ({ headerProps={({ selectedItem, invalidateQuery }) => ({
actions: ( actions: (
<FormDialog title="Employee"> <FormDialog title="Employee">

View File

@ -0,0 +1,73 @@
"use client"
import FormDialog from "@/shared/components/form-dialog"
import { Badge } from "@/shared/components/ui/badge"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import { BillForm } from "@/modules/bills/bill-form"
import { BILL_ROUTES } from "@garage/api"
import type { BillsClient } from "@garage/api"
export default function BillsPage() {
return (
<ResourcePage<BillsClient>
pageTitle="Bills"
routeKey={BILL_ROUTES.INDEX}
getClient={(api) => api.bills}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<FormDialog title="Bill">
{(resourceId) => (
<BillForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "bill_number",
header: ({ column }) => <ColumnHeader column={column} title="Bill #" />,
cell: ({ row }) => (row.original as any).bill_number || "—",
},
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
},
{
accessorKey: "vendor_name",
header: ({ column }) => <ColumnHeader column={column} title="Vendor" />,
cell: ({ row }) => (row.original as any).vendor_name || "—",
},
{
accessorKey: "bill_date",
header: ({ column }) => <ColumnHeader column={column} title="Bill Date" />,
cell: ({ row }) => {
const value = (row.original as any).bill_date
return value ? new Date(value).toLocaleDateString() : "—"
},
},
{
accessorKey: "bill_due_date",
header: ({ column }) => <ColumnHeader column={column} title="Due Date" />,
cell: ({ row }) => {
const value = (row.original as any).bill_due_date
return value ? new Date(value).toLocaleDateString() : "—"
},
},
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const status = (row.original as any).status
return <Badge variant={status === "paid" ? "default" : "secondary"}>{status || "—"}</Badge>
},
},
actionsColumn(),
]}
/>
)
}

View File

@ -0,0 +1,377 @@
"use client"
import { useState, useRef } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import {
Paperclip,
Plus,
Trash2,
FileIcon,
ImageIcon,
FileTextIcon,
BadgeDollarSignIcon,
CalendarIcon,
CreditCardIcon,
HashIcon,
UserIcon,
BriefcaseIcon,
} from "lucide-react"
import { toast } from "sonner"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { PaymentMadeForm } from "@/modules/payment-mades/payment-made-form"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent } from "@/shared/components/ui/card"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { confirm } from "@/shared/components/confirm-dialog"
import { useAuthApi } from "@/shared/useApi"
import { PAYMENT_MADE_ROUTES } from "@garage/api"
import type { PaymentMadesClient } from "@garage/api"
// ── Attachment helpers ──
type AttachmentFile = {
id: number
original_name?: string
attachment_path?: string
created_at?: string
}
function getFileIcon(path?: string) {
if (!path) return FileIcon
const lower = path.toLowerCase()
if (/\.(jpg|jpeg|png|gif|webp|svg)$/.test(lower)) return ImageIcon
if (/\.pdf$/.test(lower)) return FileTextIcon
return FileIcon
}
// ── Attachments Dialog ──
function AttachmentsDialog({
open,
paymentId,
paymentRef,
onClose,
}: {
open: boolean
paymentId: string
paymentRef: string
onClose: () => void
}) {
const api = useAuthApi()
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const [isUploading, setIsUploading] = useState(false)
const [sessionFiles, setSessionFiles] = useState<AttachmentFile[]>([])
const queryKey = [PAYMENT_MADE_ROUTES.INDEX, paymentId, "attachments"]
const deleteMutation = useMutation({
mutationFn: (attachmentId: number) =>
api.paymentMades.deleteAttachment(paymentId, { attachment_id: attachmentId } as any),
onSuccess: (_, attachmentId) => {
toast.success("Attachment deleted.")
setSessionFiles((prev) => prev.filter((f) => f.id !== attachmentId))
queryClient.invalidateQueries({ queryKey })
},
onError: () => toast.error("Failed to delete attachment."),
})
const handleDelete = async (attachment: AttachmentFile) => {
const confirmed = await confirm({
title: "Delete Attachment",
description: `Are you sure you want to delete "${attachment.original_name ?? "this file"}"?`,
confirmLabel: "Delete",
variant: "destructive",
})
if (confirmed) deleteMutation.mutate(attachment.id)
}
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files || files.length === 0) return
setIsUploading(true)
const fileArray = Array.from(files)
try {
const formData = new FormData()
fileArray.forEach((file) => formData.append("attachments[]", file))
await toast.promise(
api.paymentMades.addAttachment(paymentId, formData),
{
loading: "Uploading attachment(s)...",
success: "Attachment(s) uploaded successfully",
error: "Failed to upload attachment(s)",
},
)
const now = new Date().toISOString()
const uploaded: AttachmentFile[] = fileArray.map((file, i) => ({
id: Date.now() + i,
original_name: file.name,
attachment_path: file.name,
created_at: now,
}))
setSessionFiles((prev) => [...prev, ...uploaded])
queryClient.invalidateQueries({ queryKey })
} finally {
setIsUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ""
}
}
const handleClose = () => {
setSessionFiles([])
onClose()
}
return (
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Attachments {paymentRef}</DialogTitle>
</DialogHeader>
<div className="flex justify-end">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleUpload}
/>
<Button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
size="sm"
>
<Plus className="size-4" />
{isUploading ? "Uploading..." : "Upload Attachment"}
</Button>
</div>
{sessionFiles.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No attachments uploaded in this session. Click &quot;Upload Attachment&quot; to add files.
</CardContent>
</Card>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{sessionFiles.map((attachment) => {
const Icon = getFileIcon(attachment.attachment_path)
return (
<Card key={attachment.id}>
<CardContent className="flex items-center gap-3 p-4">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Icon className="size-5" />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm font-medium" title={attachment.original_name}>
{attachment.original_name}
</span>
{attachment.created_at && (
<span className="text-xs text-muted-foreground">
{new Date(attachment.created_at).toLocaleDateString()}
</span>
)}
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleDelete(attachment)}
title="Delete attachment"
>
<Trash2 className="size-4 text-destructive" />
</Button>
</CardContent>
</Card>
)
})}
</div>
)}
</DialogContent>
</Dialog>
)
}
// ── Page ──
type PaymentMadeItem = {
id: number
payment_number?: string
vendor_name?: string
employee_name?: string
payment_for?: string
payment_made?: string | number
payment_mode_name?: string
payment_date?: string
paid_through?: string
notes?: string
created_at?: string
}
export default function PaymentsMadePage() {
const [attachmentTarget, setAttachmentTarget] = useState<{
id: string
ref: string
} | null>(null)
return (
<>
<ResourcePage<PaymentMadesClient>
pageTitle="Payments Made"
routeKey={PAYMENT_MADE_ROUTES.INDEX}
getClient={(api) => api.paymentMades}
headerProps={({ invalidateQuery }) => ({
actions: (
<FormDialog title="Record Payment">
{(resourceId) => (
<PaymentMadeForm
resourceId={resourceId}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "payment_number",
header: ({ column }) => <ColumnHeader column={column} title="Payment #" />,
cell: ({ row }) => {
const item = row.original as unknown as PaymentMadeItem
return (
<div className="flex items-center gap-2">
<HashIcon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{item.payment_number || "—"}</span>
</div>
)
},
},
{
accessorKey: "vendor_name",
header: ({ column }) => <ColumnHeader column={column} title="Vendor" />,
cell: ({ row }) => {
const item = row.original as unknown as PaymentMadeItem
return (
<div className="flex items-center gap-2">
<UserIcon className="h-4 w-4 text-muted-foreground" />
<span>{item.vendor_name || "—"}</span>
</div>
)
},
},
{
accessorKey: "payment_for",
header: ({ column }) => <ColumnHeader column={column} title="Payment For" />,
cell: ({ row }) => {
const item = row.original as unknown as PaymentMadeItem
return (
<div className="flex items-center gap-2">
<BriefcaseIcon className="h-4 w-4 text-muted-foreground" />
<span className="capitalize">{item.payment_for || "—"}</span>
</div>
)
},
},
{
accessorKey: "payment_made",
header: ({ column }) => <ColumnHeader column={column} title="Amount" />,
cell: ({ row }) => {
const item = row.original as unknown as PaymentMadeItem
const amount = item.payment_made
? Number(item.payment_made).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
: "—"
return (
<div className="flex items-center gap-2">
<BadgeDollarSignIcon className="h-4 w-4 text-emerald-600" />
<span className="font-semibold text-emerald-700 dark:text-emerald-400">
{amount}
</span>
</div>
)
},
},
{
accessorKey: "payment_mode_name",
header: ({ column }) => <ColumnHeader column={column} title="Payment Mode" />,
cell: ({ row }) => {
const item = row.original as unknown as PaymentMadeItem
return (
<div className="flex items-center gap-2">
<CreditCardIcon className="h-4 w-4 text-muted-foreground" />
<span className="capitalize">{item.payment_mode_name || "—"}</span>
</div>
)
},
},
{
accessorKey: "payment_date",
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
cell: ({ row }) => {
const item = row.original as unknown as PaymentMadeItem
const formatted = item.payment_date
? new Date(item.payment_date).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})
: "—"
return (
<div className="flex items-center gap-2">
<CalendarIcon className="h-4 w-4 text-muted-foreground" />
<span>{formatted}</span>
</div>
)
},
},
{
id: "attachments",
header: () => null,
cell: ({ row }) => {
const item = row.original as any
return (
<Button
variant="ghost"
size="icon-sm"
title="Manage Attachments"
onClick={(e) => {
e.stopPropagation()
setAttachmentTarget({
id: String(item.id),
ref: item.payment_number || `PAY-${item.id}`,
})
}}
>
<Paperclip className="size-4" />
</Button>
)
},
},
actionsColumn(),
]}
/>
{attachmentTarget && (
<AttachmentsDialog
open={!!attachmentTarget}
paymentId={attachmentTarget.id}
paymentRef={attachmentTarget.ref}
onClose={() => setAttachmentTarget(null)}
/>
)}
</>
)
}

View File

@ -0,0 +1,72 @@
"use client"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { PurchaseOrderForm } from "@/modules/purchase-orders/purchase-order-form"
import { PURCHASE_ORDER_ROUTES } from "@garage/api"
import type { PurchaseOrdersClient } from "@garage/api"
export default function PurchaseOrdersPage() {
return (
<ResourcePage<PurchaseOrdersClient>
pageTitle="Purchase Orders"
routeKey={PURCHASE_ORDER_ROUTES.INDEX}
getClient={(api) => api.purchaseOrders}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<FormDialog title="Purchase Order">
{(resourceId) => (
<PurchaseOrderForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "order_number",
header: ({ column }) => <ColumnHeader column={column} title="Order #" />,
cell: ({ row }) => (row.original as any).order_number || "—",
},
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
},
{
accessorKey: "vendor_name",
header: ({ column }) => <ColumnHeader column={column} title="Vendor" />,
cell: ({ row }) => (row.original as any).vendor_name || "—",
},
{
accessorKey: "order_date",
header: ({ column }) => <ColumnHeader column={column} title="Order Date" />,
cell: ({ row }) => {
const val = (row.original as any).order_date
return val ? new Date(val).toLocaleDateString() : "—"
},
},
{
accessorKey: "delivery_date",
header: ({ column }) => <ColumnHeader column={column} title="Delivery Date" />,
cell: ({ row }) => {
const val = (row.original as any).delivery_date
return val ? new Date(val).toLocaleDateString() : "—"
},
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
cell: ({ row }) => {
const val = (row.original as any).created_at
return val ? new Date(val).toLocaleDateString() : "—"
},
},
actionsColumn(),
]}
/>
)
}

View File

@ -0,0 +1,65 @@
"use client"
import FormDialog from "@/shared/components/form-dialog"
import { Badge } from "@/shared/components/ui/badge"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import { VendorCreditForm } from "@/modules/vendor-credits/vendor-credit-form"
import { VENDOR_CREDIT_ROUTES } from "@garage/api"
import type { VendorCreditsClient } from "@garage/api"
export default function VendorCreditsPage() {
return (
<ResourcePage<VendorCreditsClient>
pageTitle="Vendor Credits"
routeKey={VENDOR_CREDIT_ROUTES.INDEX}
getClient={(api) => api.vendorCredits}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<FormDialog title="Vendor Credit">
{(resourceId) => (
<VendorCreditForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "subject",
header: ({ column }) => <ColumnHeader column={column} title="Subject" />,
},
{
accessorKey: "vendor_name",
header: ({ column }) => <ColumnHeader column={column} title="Vendor" />,
cell: ({ row }) => (row.original as any).vendor_name || "—",
},
{
accessorKey: "bill_number",
header: ({ column }) => <ColumnHeader column={column} title="Bill #" />,
cell: ({ row }) => (row.original as any).bill_number || "—",
},
{
accessorKey: "vendor_credit_date",
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
cell: ({ row }) => {
const value = (row.original as any).vendor_credit_date
return value ? new Date(value).toLocaleDateString() : "—"
},
},
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const status = (row.original as any).status
return <Badge variant={status === "closed" ? "secondary" : "default"}>{status || "—"}</Badge>
},
},
actionsColumn(),
]}
/>
)
}

View File

@ -0,0 +1,40 @@
import { DashboardDetailsPage } from '@/base/components/layout/dashboard'
import { getServerApi } from '@garage/api/server'
import { CustomerActions } from '@/modules/customers/customer-actions'
import { CustomerProvider } from '@/modules/customers/customer-context'
import React from 'react'
export default async function layout(props: {
params: Promise<{ id: string }>
children: React.ReactNode
}) {
const { id } = await props.params
const api = await getServerApi()
const customer = await api.customers.getById(id)
const firstName = customer.data?.first_name ?? ''
const lastName = customer.data?.last_name ?? ''
const fullName = [firstName, lastName].filter(Boolean).join(' ') || 'Customer Details'
const customerLabel = fullName
return (
<>
<CustomerProvider customer={{ id, label: customerLabel }}>
<DashboardDetailsPage
className="p-0 lg:p-0"
title={fullName}
description={customer.data?.email ?? customer.data?.phone ?? undefined}
backHref="/sales/customers"
actions={<CustomerActions customerId={id} />}
tabs={[
{ href: `/sales/customers/${id}`, label: 'Details' },
{ href: `/sales/customers/${id}/notes`, label: 'Notes' },
{ href: `/sales/customers/${id}/vehicles`, label: 'Vehicles' },
]}
>
{props.children}
</DashboardDetailsPage>
</CustomerProvider>
</>
)
}

View File

@ -0,0 +1,206 @@
"use client"
import { useParams } from "next/navigation"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { type ColumnDef } from "@tanstack/react-table"
import { useState } from "react"
import { Plus, Trash2, StickyNote } from "lucide-react"
import { toast } from "sonner"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { useAuthApi } from "@/shared/useApi"
import { DataTable, ColumnHeader } from "@/shared/data-view/table-view"
import { confirm } from "@/shared/components/confirm-dialog"
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import {
Field,
FieldLabel,
FieldError,
} from "@/shared/components/ui/field"
import { Textarea } from "@/shared/components/ui/textarea"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
type CustomerNote = {
id: number
note: string
created_at: string
updated_at: string
}
const addNoteSchema = z.object({
note: z.string().min(1, "Note content is required"),
})
type AddNoteValues = z.infer<typeof addNoteSchema>
export default function CustomerNotesPage() {
const { id: customerId } = useParams<{ id: string }>()
const api = useAuthApi()
const queryClient = useQueryClient()
const [dialogOpen, setDialogOpen] = useState(false)
const queryKey = ["customer-notes", customerId]
const { data: customerData, isLoading } = useQuery({
queryKey,
queryFn: () => api.customers.getById(customerId),
})
const notes: CustomerNote[] = (customerData?.data as any)?.notes ?? []
const meta = (customerData as any)?.meta
const pagination = {
page: meta?.current_page ?? 1,
pageSize: meta?.per_page ?? 15,
pageCount: meta?.last_page ?? 1,
total: meta?.total ?? notes.length,
}
const addNoteMutation = useMutation({
mutationFn: (values: AddNoteValues) =>
api.customers.addNote(customerId, { note: values.note }),
onSuccess: () => {
toast.success("Note added successfully.")
queryClient.invalidateQueries({ queryKey })
setDialogOpen(false)
reset()
},
onError: () => {
toast.error("Failed to add note.")
},
})
const deleteNoteMutation = useMutation({
mutationFn: (noteId: number) => api.customers.deleteNote(customerId, noteId),
onSuccess: () => {
toast.success("Note deleted successfully.")
queryClient.invalidateQueries({ queryKey })
},
onError: () => {
toast.error("Failed to delete note.")
},
})
const {
handleSubmit,
register,
reset,
formState: { errors },
} = useForm<AddNoteValues>({
resolver: zodResolver(addNoteSchema),
defaultValues: { note: "" },
})
const handleDelete = async (note: CustomerNote) => {
const confirmed = await confirm({
title: "Delete Note",
description: "Are you sure you want to delete this note?",
confirmLabel: "Delete",
variant: "destructive",
})
if (confirmed) {
deleteNoteMutation.mutate(note.id)
}
}
const columns: ColumnDef<CustomerNote>[] = [
{
accessorKey: "note",
header: ({ column }) => <ColumnHeader column={column} title="Note" />,
cell: ({ getValue }) => (
<span className="whitespace-pre-wrap text-sm">{getValue<string>()}</span>
),
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Created At" />,
cell: ({ getValue }) => {
const val = getValue<string>()
return val ? new Date(val).toLocaleDateString() : "—"
},
},
{
id: "actions",
header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => (
<div className="flex justify-end">
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(row.original)}
disabled={deleteNoteMutation.isPending}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
),
},
]
return (
<DashboardPage
header={null}
title="Notes"
toolbar={
<Button size="sm" onClick={() => setDialogOpen(true)}>
<Plus className="size-4" />
Add Note
</Button>
}
>
<DataTable
columns={columns}
data={notes}
pagination={pagination}
sorting={[]}
onChange={() => {}}
isLoading={isLoading}
/>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Note</DialogTitle>
</DialogHeader>
<form
onSubmit={handleSubmit((values) => addNoteMutation.mutate(values))}
className="grid gap-4"
>
<Field>
<FieldLabel>Note</FieldLabel>
<Textarea
{...register("note")}
placeholder="Enter note..."
rows={4}
/>
{errors.note && <FieldError>{errors.note.message}</FieldError>}
</Field>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
setDialogOpen(false)
reset()
}}
>
Cancel
</Button>
<Button type="submit" disabled={addNoteMutation.isPending}>
{addNoteMutation.isPending ? "Saving..." : "Save Note"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</DashboardPage>
)
}

View File

@ -0,0 +1,19 @@
import { getServerApi } from '@garage/api/server'
import { CustomerGeneralInfo } from '@/modules/customers/customer-general-info'
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
export default async function page(props: { params: Promise<{ id: string }> }) {
const { id } = await props.params
const api = await getServerApi()
const customer = await api.customers.getById(id)
if (!customer.data) {
return <div className="text-muted-foreground p-6">Customer not found.</div>
}
return (
<DashboardPage header={null}>
<CustomerGeneralInfo customer={customer.data} />
</DashboardPage>
)
}

View File

@ -0,0 +1,72 @@
"use client"
import { use } from "react"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { VehicleForm } from "@/modules/vehicles/vehicle-form"
import { VEHICLE_ROUTES } from "@garage/api"
import type { VehiclesClient } from "@garage/api"
import { CarIcon } from "lucide-react"
import { useCustomer } from "@/modules/customers/customer-context"
export default function CustomerVehiclesPage({ params }: { params: Promise<{ id: string }> }) {
const { id: customerId } = use(params)
const customer = useCustomer()
return (
<ResourcePage<VehiclesClient>
toolbar={({ invalidateQuery, selectedItem, closeDialog }) => (
<FormDialog title="Vehicle">
{(resourceId) => (
<VehicleForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={() => {
closeDialog()
invalidateQuery()
}}
/>
)}
</FormDialog>
)}
pageTitle="Customer Vehicles"
routeKey={VEHICLE_ROUTES.INDEX}
getClient={(api) => api.vehicles}
extraParams={{ customer_id: customerId }}
header={null}
columns={({ actionsColumn }) => [
{
accessorKey: "make",
header: ({ column }) => <ColumnHeader column={column} title="Vehicle" />,
cell: ({ row }) => {
const r = row.original as any
const make = r.make ?? ""
const model = r.model ?? ""
const display = `${make} ${model}`.trim() || "—"
return (
<div className="flex items-center gap-2">
<CarIcon className="size-4 text-muted-foreground" />
<span className="font-medium">{display}</span>
</div>
)
},
},
{
accessorKey: "year",
header: ({ column }) => <ColumnHeader column={column} title="Year" />,
cell: ({ row }) => (row.original as any).year ?? "—",
},
{
accessorKey: "license_plate",
header: ({ column }) => <ColumnHeader column={column} title="License Plate" />,
cell: ({ row }) => {
const val = (row.original as any).license_plate
return val ? <span className="font-mono text-xs">{val}</span> : "—"
},
},
actionsColumn(),
]}
/>
)
}

View File

@ -1,5 +1,6 @@
"use client" "use client"
import { useRouter } from 'next/navigation'
import { ResourcePage } from '@/shared/data-view/resource-page' import { ResourcePage } from '@/shared/data-view/resource-page'
import { ColumnHeader } from '@/shared/data-view/table-view' import { ColumnHeader } from '@/shared/data-view/table-view'
import FormDialog from '@/shared/components/form-dialog' import FormDialog from '@/shared/components/form-dialog'
@ -9,11 +10,13 @@ import type { CustomersClient } from '@garage/api'
import { Building2Icon, UserIcon } from 'lucide-react' import { Building2Icon, UserIcon } from 'lucide-react'
export default function CustomersPage() { export default function CustomersPage() {
const router = useRouter()
return ( return (
<ResourcePage<CustomersClient> <ResourcePage<CustomersClient>
pageTitle='Customers' pageTitle='Customers'
routeKey={CUSTOMER_ROUTES.INDEX} routeKey={CUSTOMER_ROUTES.INDEX}
getClient={(api) => api.customers} getClient={(api) => api.customers}
onRowClick={(row) => router.push(`/sales/customers/${(row as any).id}`)}
headerProps={({ selectedItem, invalidateQuery }) => ({ headerProps={({ selectedItem, invalidateQuery }) => ({
actions: ( actions: (
<FormDialog title="Customer"> <FormDialog title="Customer">

View File

@ -0,0 +1,41 @@
import { DashboardDetailsPage } from '@/base/components/layout/dashboard'
import { getServerApi } from '@garage/api/server'
import { EstimateActions } from '@/modules/estimates/estimate-actions'
import { EstimateProvider } from '@/modules/estimates/estimate-context'
import { FileTextIcon } from 'lucide-react'
import React from 'react'
export default async function layout(props: {
params: Promise<{ id: string }>
children: React.ReactNode
}) {
const { id } = await props.params
const api = await getServerApi()
const estimate = await api.estimates.getById(id)
const estimateData = (estimate as any)?.data
const title = estimateData?.title || estimateData?.estimate_number || `Estimate #${id}`
const estimateLabel = estimateData?.estimate_number
? `${estimateData.estimate_number}${estimateData.title ? `${estimateData.title}` : ''}`
: title
return (
<EstimateProvider estimate={{ id, label: estimateLabel }}>
<DashboardDetailsPage
className="p-0 lg:p-0"
icon={<FileTextIcon className="size-5" />}
title={title}
description={
estimateData?.date ? `Date: ${estimateData.date}` : undefined
}
backHref="/sales/estimates"
actions={<EstimateActions estimateId={id} />}
tabs={[
{ href: `/sales/estimates/${id}`, label: 'Details' },
]}
>
{props.children}
</DashboardDetailsPage>
</EstimateProvider>
)
}

View File

@ -0,0 +1,21 @@
import { getServerApi } from '@garage/api/server'
import { EstimateGeneralInfo } from '@/modules/estimates/estimate-general-info'
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
export default async function page(props: { params: Promise<{ id: string }> }) {
const { id } = await props.params
const api = await getServerApi()
const estimate = await api.estimates.getById(id)
const estimateData = (estimate as any)?.data
if (!estimateData) {
return <div className="text-muted-foreground p-4">Estimate not found.</div>
}
return (
<DashboardPage header={null}>
<EstimateGeneralInfo estimate={estimateData} />
</DashboardPage>
)
}

View File

@ -6,20 +6,11 @@ import FormDialog from '@/shared/components/form-dialog'
import { EstimateForm } from '@/modules/estimates/estimate-form' import { EstimateForm } from '@/modules/estimates/estimate-form'
import { ESTIMATE_ROUTES } from '@garage/api' import { ESTIMATE_ROUTES } from '@garage/api'
import type { EstimatesClient } from '@garage/api' import type { EstimatesClient } from '@garage/api'
import { FileTextIcon } from 'lucide-react' import { Car, FileTextIcon } from 'lucide-react'
import { Button } from '@/shared/components/ui/button'
import Link from 'next/link'
type EstimateItem = { export default function EstimatesPage() {
id: number
title?: string
estimate_number?: string
date?: string
customer_name?: string
vehicle_name?: string
has_insurance?: boolean
created_at?: string
}
export default function EstimatesPage({ vehicleId }: { vehicleId: string }) {
return ( return (
<ResourcePage<EstimatesClient> <ResourcePage<EstimatesClient>
pageTitle="Estimates" pageTitle="Estimates"
@ -31,7 +22,6 @@ export default function EstimatesPage({ vehicleId }: { vehicleId: string }) {
{(resourceId) => ( {(resourceId) => (
<EstimateForm <EstimateForm
resourceId={resourceId} resourceId={resourceId}
initialData={{ vehicle:{label: vehicleId, value: vehicleId}}}
onSuccess={invalidateQuery} onSuccess={invalidateQuery}
/> />
)} )}
@ -43,11 +33,13 @@ export default function EstimatesPage({ vehicleId }: { vehicleId: string }) {
accessorKey: "title", accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />, header: ({ column }) => <ColumnHeader column={column} title="Title" />,
cell: ({ row }) => { cell: ({ row }) => {
const item = row.original as unknown as EstimateItem const item = row.original
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link href={`/sales/estimates/${item.id}`} className="flex items-center gap-2 hover:underline">
<FileTextIcon className="text-muted-foreground h-4 w-4" /> <FileTextIcon className="text-muted-foreground h-4 w-4" />
<span>{item.title}</span> <span>{item.title}</span>
</Link>
</div> </div>
) )
}, },
@ -61,8 +53,16 @@ export default function EstimatesPage({ vehicleId }: { vehicleId: string }) {
header: ({ column }) => <ColumnHeader column={column} title="Customer" />, header: ({ column }) => <ColumnHeader column={column} title="Customer" />,
}, },
{ {
accessorKey: "vehicle_name", accessorKey: "vehicle_id",
header: ({ column }) => <ColumnHeader column={column} title="Vehicle" />, header: ({ column }) => <ColumnHeader column={column} title="Vehicle" />,
cell: ({ row }) => {
const item = row.original
return <Button variant="outline" asChild size="sm">
<Link href={`/sales/vehicles/${item.vehicle_id}`}>
<Car/> Go to vehicle
</Link>
</Button>
}
}, },
{ {
accessorKey: "date", accessorKey: "date",
@ -72,7 +72,7 @@ export default function EstimatesPage({ vehicleId }: { vehicleId: string }) {
accessorKey: "has_insurance", accessorKey: "has_insurance",
header: ({ column }) => <ColumnHeader column={column} title="Insurance" />, header: ({ column }) => <ColumnHeader column={column} title="Insurance" />,
cell: ({ row }) => { cell: ({ row }) => {
const item = row.original as unknown as EstimateItem const item = row.original
return item.has_insurance ? "Yes" : "No" return item.has_insurance ? "Yes" : "No"
}, },
}, },
@ -80,7 +80,7 @@ export default function EstimatesPage({ vehicleId }: { vehicleId: string }) {
accessorKey: "created_at", accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Created" />, header: ({ column }) => <ColumnHeader column={column} title="Created" />,
cell: ({ row }) => { cell: ({ row }) => {
const item = row.original as unknown as EstimateItem const item = row.original
return item.created_at ? new Date(item.created_at).toLocaleDateString() : "—" return item.created_at ? new Date(item.created_at).toLocaleDateString() : "—"
}, },
}, },

View File

@ -0,0 +1,589 @@
"use client"
import { use, useState, useRef, useCallback } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useAuthApi } from "@/shared/useApi"
import { DataTable, ColumnHeader } from "@/shared/data-view/table-view"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { toast } from "sonner"
import {
Plus,
Ellipsis,
Pencil,
Trash2,
CheckCircle2,
AlertTriangle,
XCircle,
MinusCircle,
CircleDot,
Paperclip,
FileUp,
FileText,
FileImage,
File,
X,
} from "lucide-react"
import { INSPECTION_ROUTES } from "@garage/api"
// ── Types ──
type CheckpointItem = {
id: number
inspection_id?: number
name?: string
description?: string
record_type?: string
condition_rate?: number
file?: string
status?: string
created_at?: string
updated_at?: string
}
// ── Constants ──
const CHECKPOINT_STATUSES = [
{ value: "passed", label: "Passed", icon: CheckCircle2, color: "bg-green-100 text-green-800" },
{ value: "need_attention", label: "Need Attention", icon: AlertTriangle, color: "bg-yellow-100 text-yellow-800" },
{ value: "failed", label: "Failed", icon: XCircle, color: "bg-red-100 text-red-800" },
{ value: "not_applicable", label: "Not Applicable", icon: MinusCircle, color: "bg-gray-100 text-gray-800" },
{ value: "not_inspected", label: "Not Inspected", icon: CircleDot, color: "bg-blue-100 text-blue-800" },
] as const
const RECORD_TYPES = [
{ value: "record_conditions", label: "Record Conditions" },
{ value: "record_audio", label: "Record Audio" },
{ value: "record_video", label: "Record Video" },
{ value: "capture_photo", label: "Capture Photo" },
] as const
function formatStatus(status?: string) {
if (!status) return "Not Inspected"
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
}
function getStatusConfig(status?: string) {
return CHECKPOINT_STATUSES.find((s) => s.value === status) || CHECKPOINT_STATUSES[4]
}
// ── Checkpoint Form Dialog ──
function CheckpointFormDialog({
open,
onOpenChange,
inspectionId,
checkpoint,
onSuccess,
}: {
open: boolean
onOpenChange: (open: boolean) => void
inspectionId: string
checkpoint?: CheckpointItem | null
onSuccess: () => void
}) {
const api = useAuthApi()
const [name, setName] = useState(checkpoint?.name ?? "")
const [description, setDescription] = useState(checkpoint?.description ?? "")
const [recordType, setRecordType] = useState(checkpoint?.record_type ?? "record_conditions")
const [isPending, setIsPending] = useState(false)
const isEditing = !!checkpoint
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) {
toast.error("Name is required")
return
}
setIsPending(true)
try {
const payload = {
inspection_id: Number(inspectionId),
name: name.trim(),
description: description.trim() || undefined,
record_type: recordType,
}
if (isEditing) {
const promise = api.inspections.updateCheckpoint(String(checkpoint.id), payload)
toast.promise(promise, {
loading: "Updating checkpoint...",
success: "Checkpoint updated",
error: "Failed to update checkpoint",
})
await promise
} else {
const promise = api.inspections.createCheckpoint(payload)
toast.promise(promise, {
loading: "Creating checkpoint...",
success: "Checkpoint created",
error: "Failed to create checkpoint",
})
await promise
}
onSuccess()
onOpenChange(false)
} finally {
setIsPending(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{isEditing ? "Edit Checkpoint" : "Add Checkpoint"}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="cp-name">Name *</Label>
<Input
id="cp-name"
placeholder="e.g. Engine Oil Level"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cp-description">Description</Label>
<Textarea
id="cp-description"
placeholder="Check oil level and condition"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cp-record-type">Record Type</Label>
<Select value={recordType} onValueChange={setRecordType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{RECORD_TYPES.map((rt) => (
<SelectItem key={rt.value} value={rt.value}>
{rt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="submit" disabled={isPending}>
{isEditing ? <Pencil className="size-4" /> : <Plus className="size-4" />}
{isPending
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update Checkpoint" : "Create Checkpoint")}
</Button>
</form>
</DialogContent>
</Dialog>
)
}
// ── Attachments Dialog ──
function getFileIcon(url: string) {
const ext = url.split(".").pop()?.toLowerCase()
if (["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(ext ?? "")) return FileImage
if (ext === "pdf") return FileText
return File
}
function getFileName(url: string) {
try {
return decodeURIComponent(url.split("/").pop() ?? "Attachment")
} catch {
return url.split("/").pop() ?? "Attachment"
}
}
function isImageUrl(url: string) {
const ext = url.split(".").pop()?.toLowerCase()
return ["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(ext ?? "")
}
function CheckpointAttachmentsDialog({
open,
onOpenChange,
checkpoint,
onSuccess,
}: {
open: boolean
onOpenChange: (open: boolean) => void
checkpoint: CheckpointItem | null
onSuccess: () => void
}) {
const api = useAuthApi()
const fileInputRef = useRef<HTMLInputElement>(null)
const [isUploading, setIsUploading] = useState(false)
const handleUpload = useCallback(async (file: globalThis.File) => {
if (!checkpoint) return
setIsUploading(true)
try {
const promise = api.inspections.uploadCheckpointMedia(
String(checkpoint.id),
{ file },
)
toast.promise(promise, {
loading: "Uploading attachment...",
success: "Attachment uploaded",
error: "Failed to upload attachment",
})
await promise
onSuccess()
} finally {
setIsUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ""
}
}, [api, checkpoint, onSuccess])
const handleDelete = useCallback(async () => {
if (!checkpoint) return
const promise = api.inspections.deleteCheckpointMedia(String(checkpoint.id))
toast.promise(promise, {
loading: "Removing attachment...",
success: "Attachment removed",
error: "Failed to remove attachment",
})
await promise
onSuccess()
}, [api, checkpoint, onSuccess])
const hasFile = !!checkpoint?.file
const FileIcon = checkpoint?.file ? getFileIcon(checkpoint.file) : File
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Attachments {checkpoint?.name}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
{/* Current attachment */}
{hasFile ? (
<div className="rounded-lg border p-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0 ">
{isImageUrl(checkpoint.file!) ? (
<img
src={checkpoint.file!}
alt="Checkpoint attachment"
className="size-12 rounded-md object-cover"
/>
) : (
<div className="flex size-12 shrink-0 items-center justify-center rounded-md bg-muted">
<FileIcon className="size-5 text-muted-foreground" />
</div>
)}
<div className="min-w-0 flex flex-col gap-0.5 max-w-48">
<a
href={checkpoint.file!}
target="_blank"
rel="noopener noreferrer"
className="truncate text-sm font-medium text-primary hover:underline"
>
{getFileName(checkpoint.file!)}
</a>
<span className="text-xs text-muted-foreground">
Current attachment
</span>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="shrink-0 text-destructive hover:text-destructive"
onClick={handleDelete}
title="Remove attachment"
>
<Trash2 className="size-4" />
</Button>
</div>
</div>
) : (
<div className="flex flex-col items-center gap-2 rounded-lg border border-dashed p-6 text-muted-foreground">
<Paperclip className="size-8" />
<span className="text-sm">No attachments yet</span>
</div>
)}
{/* Upload area */}
<input
ref={fileInputRef}
type="file"
className="hidden"
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,audio/*,video/*"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleUpload(file)
}}
/>
<Button
variant="outline"
className="w-full"
disabled={isUploading}
onClick={() => fileInputRef.current?.click()}
>
<FileUp className="size-4" />
{isUploading
? "Uploading..."
: hasFile
? "Replace Attachment"
: "Add Attachment"}
</Button>
</div>
</DialogContent>
</Dialog>
)
}
// ── Main Page ──
export default function InspectionCheckpointsPage({ params }: { params: Promise<{ id: string }> }) {
const { id: inspectionId } = use(params)
const api = useAuthApi()
const queryClient = useQueryClient()
const [formOpen, setFormOpen] = useState(false)
const [editingCheckpoint, setEditingCheckpoint] = useState<CheckpointItem | null>(null)
const [attachmentsCheckpoint, setAttachmentsCheckpoint] = useState<CheckpointItem | null>(null)
const queryKey = [INSPECTION_ROUTES.CHECKPOINTS, inspectionId]
const { data, isLoading } = useQuery({
queryKey,
queryFn: () => api.inspections.listCheckpoints({ inspection_id: inspectionId } as never),
})
const invalidate = () => {
queryClient.invalidateQueries({ queryKey })
}
// ── Status change mutation ──
const changeStatusMutation = useMutation({
mutationFn: ({ checkpointId, status }: { checkpointId: number; status: string }) =>
api.inspections.changeCheckpointStatus({
inspection_id: Number(inspectionId),
inspection_check_point_id: String(checkpointId),
name: "", // required by API type but server uses checkpoint id from context
record_type: "record_conditions",
status,
} as never),
onSuccess: () => invalidate(),
})
// ── Delete checkpoint mutation ──
const deleteMutation = useMutation({
mutationFn: (checkpointId: string) => api.inspections.destroyCheckpoint(checkpointId),
onSuccess: () => {
toast.success("Checkpoint deleted")
invalidate()
},
onError: () => toast.error("Failed to delete checkpoint"),
})
const handleEdit = (checkpoint: CheckpointItem) => {
setEditingCheckpoint(checkpoint)
setFormOpen(true)
}
const handleAdd = () => {
setEditingCheckpoint(null)
setFormOpen(true)
}
const handleStatusChange = (checkpointId: number, status: string) => {
const promise = changeStatusMutation.mutateAsync({ checkpointId, status, })
toast.promise(promise, {
loading: "Updating status...",
success: `Status changed to ${formatStatus(status)}`,
error: "Failed to update status",
})
}
const checkpoints = (data as any)?.data ?? []
const meta = (data as any)?.meta
const pagination = {
page: meta?.current_page ?? 1,
pageSize: meta?.per_page ?? 15,
pageCount: meta?.last_page ?? 1,
total: meta?.total ?? 0,
}
const columns = [
{
accessorKey: "name",
header: ({ column }: any) => <ColumnHeader column={column} title="Name" />,
cell: ({ row }: any) => {
const item = row.original as CheckpointItem
return (
<div className="flex flex-col gap-0.5">
<span className="font-medium">{item.name}</span>
{item.description && (
<span className="text-xs text-muted-foreground">{item.description}</span>
)}
</div>
)
},
},
{
accessorKey: "record_type",
header: ({ column }: any) => <ColumnHeader column={column} title="Record Type" />,
cell: ({ row }: any) => {
const item = row.original as CheckpointItem
const rt = RECORD_TYPES.find((r) => r.value === item.record_type)
return rt?.label ?? item.record_type ?? "—"
},
},
{
accessorKey: "status",
header: ({ column }: any) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }: any) => {
const item = row.original as CheckpointItem
const config = getStatusConfig(item.status)
return (
<Badge className={config.color}>
<config.icon className="mr-1 size-3" />
{formatStatus(item.status)}
</Badge>
)
},
},
{
accessorKey: "condition_rate",
header: ({ column }: any) => <ColumnHeader column={column} title="Condition" />,
cell: ({ row }: any) => {
const item = row.original as CheckpointItem
return item.condition_rate != null ? `${item.condition_rate}/10` : "—"
},
},
{
id: "attachments",
header: () => <span className="text-xs">Attachments</span>,
cell: ({ row }: any) => {
const item = row.original as CheckpointItem
return (
<Button
variant="ghost"
size="sm"
className="gap-1.5"
onClick={() => setAttachmentsCheckpoint(item)}
>
<Paperclip className="size-3.5" />
{item.file ? (
<Badge variant="secondary" className="px-1.5 py-0 text-xs">1</Badge>
) : (
<span className="text-xs text-muted-foreground">0</span>
)}
</Button>
)
},
enableSorting: false,
},
{
id: "actions",
cell: ({ row }: any) => {
const item = row.original as CheckpointItem
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{CHECKPOINT_STATUSES.map((s) => (
<DropdownMenuItem
key={s.value}
onClick={() => handleStatusChange(item.id, s.value)}
disabled={item.status === s.value}
>
<s.icon className="size-4" />
{s.label}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleEdit(item)}>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => deleteMutation.mutate(String(item.id))}
>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
},
},
]
return (
<DashboardPage header={null}>
<div className="flex items-center justify-between p-4">
<h3 className="text-lg font-semibold">Checkpoints</h3>
<Button onClick={handleAdd} size="sm">
<Plus className="size-4" />
Add Checkpoint
</Button>
</div>
<DataTable
columns={columns}
data={checkpoints}
pagination={pagination}
sorting={[]}
onChange={() => {}}
isLoading={isLoading}
/>
<CheckpointFormDialog
open={formOpen}
onOpenChange={(open) => {
setFormOpen(open)
if (!open) setEditingCheckpoint(null)
}}
inspectionId={inspectionId}
checkpoint={editingCheckpoint}
onSuccess={invalidate}
/>
<CheckpointAttachmentsDialog
open={!!attachmentsCheckpoint}
onOpenChange={(open) => {
if (!open) setAttachmentsCheckpoint(null)
}}
checkpoint={attachmentsCheckpoint}
onSuccess={invalidate}
/>
</DashboardPage>
)
}

View File

@ -0,0 +1,42 @@
"use client"
import { use } from "react"
import { useQuery } from "@tanstack/react-query"
import { useAuthApi } from "@/shared/useApi"
import { useRouter } from "next/navigation"
import { InspectionForm } from "@/modules/inspections/inspection-form"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
import { INSPECTION_ROUTES } from "@garage/api"
export default function InspectionEditPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const api = useAuthApi()
const router = useRouter()
const { data, isLoading } = useQuery({
queryKey: [INSPECTION_ROUTES.BY_ID, id],
queryFn: () => api.inspections.getById(id),
})
if (isLoading) {
return (
<DashboardPage header={null}>
<div className="flex items-center justify-center p-8 text-muted-foreground">
Loading...
</div>
</DashboardPage>
)
}
return (
<DashboardPage header={null}>
<div className="mx-auto max-w-2xl p-6">
<InspectionForm
resourceId={id}
initialData={data}
onSuccess={() => router.push(`/sales/inspections/${id}`)}
/>
</div>
</DashboardPage>
)
}

View File

@ -0,0 +1,42 @@
import { DashboardDetailsPage } from '@/base/components/layout/dashboard'
import { getServerApi } from '@garage/api/server'
import { InspectionActions } from '@/modules/inspections/inspection-actions'
import { InspectionProvider } from '@/modules/inspections/inspection-context'
import React from 'react'
export default async function layout(props: {
params: Promise<{ id: string }>
children: React.ReactNode
}) {
const { id } = await props.params
const api = await getServerApi()
const inspection = await api.inspections.getById(id)
const title = inspection.data?.title || 'Inspection Details'
const orderNumber = inspection.data?.order_number
const status = inspection.data?.status
return (
<InspectionProvider inspection={{ id, label: title }}>
<DashboardDetailsPage
className="p-0 lg:p-0"
title={title}
description={orderNumber ? `Order: ${orderNumber}` : undefined}
backHref="/sales/inspections"
actions={<InspectionActions inspectionId={id} status={status} />}
tabs={[
{
href: `/sales/inspections/${id}`,
label: 'Details',
},
{
href: `/sales/inspections/${id}/checkpoints`,
label: 'Checkpoints',
},
]}
>
{props.children}
</DashboardDetailsPage>
</InspectionProvider>
)
}

View File

@ -0,0 +1,19 @@
import { getServerApi } from '@garage/api/server'
import { InspectionGeneralInfo } from '@/modules/inspections/inspection-general-info'
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
export default async function page(props: { params: Promise<{ id: string }> }) {
const { id } = await props.params
const api = await getServerApi()
const inspection = await api.inspections.getById(id)
if (!inspection.data) {
return <div className="text-muted-foreground">Inspection not found.</div>
}
return (
<DashboardPage header={null}>
<InspectionGeneralInfo inspection={inspection.data} />
</DashboardPage>
)
}

View File

@ -6,13 +6,17 @@ import FormDialog from "@/shared/components/form-dialog"
import { InspectionForm } from "@/modules/inspections/inspection-form" import { InspectionForm } from "@/modules/inspections/inspection-form"
import { INSPECTION_ROUTES } from "@garage/api" import { INSPECTION_ROUTES } from "@garage/api"
import type { InspectionsClient } from "@garage/api" import type { InspectionsClient } from "@garage/api"
import { useRouter } from "next/navigation"
export default function InspectionsPage() { export default function InspectionsPage() {
const router = useRouter()
return ( return (
<ResourcePage<InspectionsClient> <ResourcePage<InspectionsClient>
pageTitle="Inspections" pageTitle="Inspections"
routeKey={INSPECTION_ROUTES.INDEX} routeKey={INSPECTION_ROUTES.INDEX}
getClient={(api) => api.inspections} getClient={(api) => api.inspections}
onRowClick={(row) => router.push(`/sales/inspections/${(row as any).id}`)}
headerProps={({ selectedItem, invalidateQuery }) => ({ headerProps={({ selectedItem, invalidateQuery }) => ({
actions: ( actions: (
<FormDialog title="Inspection"> <FormDialog title="Inspection">

View File

@ -0,0 +1,100 @@
"use client"
import { use } from "react"
import { useRouter } from "next/navigation"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { AppointmentForm } from "@/modules/appointments/appointment-form"
import { APPOINTMENT_ROUTES } from "@garage/api"
import type { AppointmentsClient } from "@garage/api"
import { CalendarCheck2Icon, ClockIcon } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { useJobCard } from "@/modules/job-cards/job-card-context"
const STATUS_COLORS: Record<string, string> = {
requested: "bg-yellow-100 text-yellow-800",
confirmed: "bg-blue-100 text-blue-800",
in_progress: "bg-purple-100 text-purple-800",
completed: "bg-green-100 text-green-800",
cancelled: "bg-red-100 text-red-800",
}
export default function JobCardAppointmentsPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id: jobCardId } = use(params)
const router = useRouter()
const jobCard = useJobCard()
const defaultJobCard = jobCard
? { value: String((jobCard as any).id), label: (jobCard as any).label || (jobCard as any).title || `Job Card` }
: null
return (
<ResourcePage<AppointmentsClient>
pageTitle="Appointments"
routeKey={APPOINTMENT_ROUTES.INDEX}
getClient={(api) => api.appointments}
extraParams={{ job_card_id: jobCardId }}
header={null}
onRowClick={(row) => router.push(`/calendar/appointment/${(row as any).id}`)}
toolbar={({ invalidateQuery, selectedItem, closeDialog }) => (
<FormDialog title="Appointment">
{(resourceId) => (
<AppointmentForm
resourceId={resourceId}
initialData={selectedItem ?? { job_card: defaultJobCard }}
onSuccess={() => { closeDialog(); invalidateQuery() }}
/>
)}
</FormDialog>
)}
columns={({ actionsColumn }) => [
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<CalendarCheck2Icon className="size-4 text-muted-foreground" />
<span>{(row.original as any).title}</span>
</div>
),
},
{
accessorKey: "date",
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
},
{
accessorKey: "from_time",
header: ({ column }) => <ColumnHeader column={column} title="Time" />,
cell: ({ row }) => {
const r = row.original as any
return (
<div className="flex items-center gap-1">
<ClockIcon className="size-3 text-muted-foreground" />
<span>{r.from_time} - {r.to_time}</span>
</div>
)
},
},
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const status = (row.original as any).status
const colorClass = STATUS_COLORS[status] ?? "bg-gray-100 text-gray-800"
return (
<Badge className={colorClass}>
{status?.replace("_", " ") ?? "—"}
</Badge>
)
},
},
actionsColumn(),
]}
/>
)
}

View File

@ -10,15 +10,11 @@ import { useAuthApi } from "@/shared/useApi"
import { confirm } from "@/shared/components/confirm-dialog" import { confirm } from "@/shared/components/confirm-dialog"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Card, CardContent } from "@/shared/components/ui/card" import { Card, CardContent } from "@/shared/components/ui/card"
import { JOB_CARD_ROUTES } from "@garage/api" import { JOB_CARD_ROUTES, JobCardResponseData } from "@garage/api"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
import { useJobCard } from "@/modules/job-cards/job-card-context"
import { CONSTANTS } from "@/config/constants"
type Attachment = {
id: number
file_name: string
url: string
mime_type?: string
created_at?: string
}
function getFileIcon(mimeType?: string) { function getFileIcon(mimeType?: string) {
if (mimeType?.startsWith("image/")) return ImageIcon if (mimeType?.startsWith("image/")) return ImageIcon
@ -35,13 +31,9 @@ export default function JobCardAttachmentsPage() {
const queryKey = [JOB_CARD_ROUTES.INDEX, jobCardId, "attachments"] const queryKey = [JOB_CARD_ROUTES.INDEX, jobCardId, "attachments"]
const { data, isLoading } = useQuery({ const jobcard = useJobCard()
queryKey,
queryFn: () => api.jobCards.show(jobCardId),
})
const jobCard = (data as any)?.data ?? data const attachments = jobcard?.attachment_files
const attachments: Attachment[] = jobCard?.documents ?? jobCard?.attachments ?? []
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (attachmentId: number) => mutationFn: (attachmentId: number) =>
@ -55,10 +47,10 @@ export default function JobCardAttachmentsPage() {
}, },
}) })
const handleDelete = async (attachment: Attachment) => { const handleDelete = async (attachment: JobCardResponseData['attachment_files'][number]) => {
const confirmed = await confirm({ const confirmed = await confirm({
title: "Delete Attachment", title: "Delete Attachment",
description: `Are you sure you want to delete "${attachment.file_name}"?`, description: `Are you sure you want to delete "${attachment.original_name}"?`,
confirmLabel: "Delete", confirmLabel: "Delete",
variant: "destructive", variant: "destructive",
}) })
@ -91,7 +83,9 @@ export default function JobCardAttachmentsPage() {
} }
return ( return (
<div className="flex flex-col gap-4"> <DashboardPage
header={null}
toolbar={
<div className="flex items-center justify-end"> <div className="flex items-center justify-end">
<input <input
ref={fileInputRef} ref={fileInputRef}
@ -107,15 +101,12 @@ export default function JobCardAttachmentsPage() {
<Plus className="size-4" /> <Plus className="size-4" />
{isUploading ? "Uploading..." : "Upload Attachment"} {isUploading ? "Uploading..." : "Upload Attachment"}
</Button> </Button>
</div> </div>}
title="Attachments"
>
{isLoading ? (
<Card> {attachments?.length === 0 ? (
<CardContent className="py-8 text-center text-muted-foreground">
Loading attachments...
</CardContent>
</Card>
) : attachments.length === 0 ? (
<Card> <Card>
<CardContent className="py-8 text-center text-muted-foreground"> <CardContent className="py-8 text-center text-muted-foreground">
No attachments yet. Click "Upload Attachment" to add files. No attachments yet. Click "Upload Attachment" to add files.
@ -123,8 +114,8 @@ export default function JobCardAttachmentsPage() {
</Card> </Card>
) : ( ) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{attachments.map((attachment) => { {attachments?.map((attachment) => {
const Icon = getFileIcon(attachment.mime_type) const Icon = getFileIcon(attachment.attachment_path)
return ( return (
<Card key={attachment.id}> <Card key={attachment.id}>
<CardContent className="flex items-center gap-3 p-4"> <CardContent className="flex items-center gap-3 p-4">
@ -133,13 +124,13 @@ export default function JobCardAttachmentsPage() {
</div> </div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5"> <div className="flex min-w-0 flex-1 flex-col gap-0.5">
<a <a
href={attachment.url} href={CONSTANTS.getAssetUrl(attachment.attachment_path)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="truncate text-sm font-medium hover:underline" className="truncate text-sm font-medium hover:underline"
title={attachment.file_name} title={attachment.original_name}
> >
{attachment.file_name} {attachment.original_name}
</a> </a>
{attachment.created_at && ( {attachment.created_at && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
@ -161,6 +152,6 @@ export default function JobCardAttachmentsPage() {
})} })}
</div> </div>
)} )}
</div> </DashboardPage>
) )
} }

View File

@ -0,0 +1,86 @@
"use client"
import { use } from "react"
import FormDialog from "@/shared/components/form-dialog"
import { Badge } from "@/shared/components/ui/badge"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import { BillForm } from "@/modules/bills/bill-form"
import { BILL_ROUTES } from "@garage/api"
import type { BillsClient } from "@garage/api"
import { useJobCard } from "@/modules/job-cards/job-card-context"
export default function JobCardBillsPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id: jobCardId } = use(params)
const jobCard = useJobCard()
const defaultJobCard = jobCard
? { value: String((jobCard as any).id), label: (jobCard as any).label || (jobCard as any).title || `Job Card` }
: null
return (
<ResourcePage<BillsClient>
pageTitle="Bills"
routeKey={BILL_ROUTES.INDEX}
getClient={(api) => api.bills}
extraParams={{ job_card_id: jobCardId }}
header={null}
toolbar={({ invalidateQuery, selectedItem, closeDialog }) => (
<FormDialog title="Bill">
{(resourceId) => (
<BillForm
resourceId={resourceId}
initialData={selectedItem ?? { job_card: defaultJobCard }}
onSuccess={() => { closeDialog(); invalidateQuery() }}
/>
)}
</FormDialog>
)}
columns={({ actionsColumn }) => [
{
accessorKey: "bill_number",
header: ({ column }) => <ColumnHeader column={column} title="Bill #" />,
cell: ({ row }) => (row.original as any).bill_number || "—",
},
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
},
{
accessorKey: "vendor_name",
header: ({ column }) => <ColumnHeader column={column} title="Vendor" />,
cell: ({ row }) => (row.original as any).vendor_name || "—",
},
{
accessorKey: "bill_date",
header: ({ column }) => <ColumnHeader column={column} title="Bill Date" />,
cell: ({ row }) => {
const value = (row.original as any).bill_date
return value ? new Date(value).toLocaleDateString() : "—"
},
},
{
accessorKey: "bill_due_date",
header: ({ column }) => <ColumnHeader column={column} title="Due Date" />,
cell: ({ row }) => {
const value = (row.original as any).bill_due_date
return value ? new Date(value).toLocaleDateString() : "—"
},
},
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const status = (row.original as any).status
return <Badge variant={status === "paid" ? "default" : "secondary"}>{status || "—"}</Badge>
},
},
actionsColumn(),
]}
/>
)
}

View File

@ -19,6 +19,7 @@ import {
DialogTitle, DialogTitle,
} from "@/shared/components/ui/dialog" } from "@/shared/components/ui/dialog"
import { JobCardRemarkForm } from "@/modules/job-cards/job-card-remark-form" import { JobCardRemarkForm } from "@/modules/job-cards/job-card-remark-form"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
type CustomerRemark = { type CustomerRemark = {
id: number id: number
@ -39,9 +40,7 @@ export default function CustomerRemarksPage() {
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey, queryKey,
queryFn: async () => { queryFn: async () => {
const result = await api.jobCards.show(jobCardId) // const result = await api.jobCards.
const d = (result as any)?.data ?? result
return d?.customer_remarks ?? []
}, },
}) })
@ -112,13 +111,15 @@ export default function CustomerRemarksPage() {
} }
return ( return (
<div className="flex flex-col gap-4"> <DashboardPage header={null} title="Customer Remarks" toolbar={ <div className="flex items-center justify-end">
<div className="flex items-center justify-end">
<Button onClick={() => setDialogOpen(true)}> <Button onClick={() => setDialogOpen(true)}>
<Plus className="size-4" /> <Plus className="size-4" />
Add Customer Remark Add Customer Remark
</Button> </Button>
</div> </div>}>
<div className="flex flex-col gap-4">
<Card> <Card>
<CardContent> <CardContent>
@ -126,8 +127,6 @@ export default function CustomerRemarksPage() {
columns={columns} columns={columns}
data={remarks} data={remarks}
pagination={pagination} pagination={pagination}
sorting={[]}
onChange={() => {}}
isLoading={isLoading} isLoading={isLoading}
/> />
</CardContent> </CardContent>
@ -148,5 +147,7 @@ export default function CustomerRemarksPage() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
</DashboardPage>
) )
} }

View File

@ -0,0 +1,90 @@
"use client"
import { use } from "react"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { ExpenseForm } from "@/modules/expenses/expense-form"
import { Badge } from "@/shared/components/ui/badge"
import { EXPENSE_ROUTES } from "@garage/api"
import type { ExpensesClient } from "@garage/api"
import { useJobCard } from "@/modules/job-cards/job-card-context"
export default function JobCardExpensesPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id: jobCardId } = use(params)
const jobCard = useJobCard()
const defaultJobCard = jobCard
? { value: String((jobCard as any).id), label: (jobCard as any).label || (jobCard as any).title || `Job Card` }
: null
return (
<ResourcePage<ExpensesClient>
pageTitle="Expenses"
routeKey={EXPENSE_ROUTES.INDEX}
getClient={(api) => api.expenses}
extraParams={{ job_card_id: jobCardId }}
header={null}
toolbar={({ invalidateQuery, selectedItem, closeDialog }) => (
<FormDialog title="Expense">
{(resourceId) => (
<ExpenseForm
resourceId={resourceId}
initialData={selectedItem ?? { job_card: defaultJobCard }}
onSuccess={() => { closeDialog(); invalidateQuery() }}
/>
)}
</FormDialog>
)}
columns={({ actionsColumn }) => [
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
},
{
accessorKey: "invoice_number",
header: ({ column }) => <ColumnHeader column={column} title="Invoice #" />,
cell: ({ row }) => (row.original as any).invoice_number || "—",
},
{
accessorKey: "vendor_name",
header: ({ column }) => <ColumnHeader column={column} title="Vendor" />,
cell: ({ row }) => (row.original as any).vendor_name || "—",
},
{
accessorKey: "expense_date",
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
cell: ({ row }) => {
const val = (row.original as any).expense_date
return val ? new Date(val).toLocaleDateString() : "—"
},
},
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const status = (row.original as any).status
return (
<Badge variant={status === "paid" ? "default" : "secondary"}>
{status || "—"}
</Badge>
)
},
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
cell: ({ row }) => {
const val = (row.original as any).created_at
return val ? new Date(val).toLocaleDateString() : "—"
},
},
actionsColumn(),
]}
/>
)
}

View File

@ -3,26 +3,32 @@ import { getServerApi } from '@garage/api/server'
import { JobCardActions } from '@/modules/job-cards/job-card-actions' import { JobCardActions } from '@/modules/job-cards/job-card-actions'
import { JobCardProvider } from '@/modules/job-cards/job-card-context' import { JobCardProvider } from '@/modules/job-cards/job-card-context'
import { JobCardStatusStepper } from '@/modules/job-cards/job-card-status-stepper' import { JobCardStatusStepper } from '@/modules/job-cards/job-card-status-stepper'
import { ClipboardListIcon } from 'lucide-react' import { ClipboardListIcon, Ellipsis, Pencil, Trash2 } from 'lucide-react'
import React from 'react' import React from 'react'
import JobCardDropdown from '@/modules/job-cards/job-card-dropdown'
export default async function JobCardDetailLayout(props: { params: Promise<{ id: string }>, children: React.ReactNode }) { export default async function JobCardDetailLayout(props: { params: Promise<{ id: string }>, children: React.ReactNode }) {
const { id } = await props.params const { id } = await props.params
const api = await getServerApi() const api = await getServerApi()
const jobCard = await api.jobCards.show(id)
const data = (jobCard as any)?.data ?? jobCard const jobCard = await api.jobCards.show(id).then(res => res.data)
const title = data?.title || 'Job Card Details'
const status = data?.status || 'draft' const title = jobCard?.title || 'Job Card Details'
const status = jobCard?.status || 'draft'
const docs = jobCard?.documents
return ( return (
<JobCardProvider jobCard={{ id, label: title, status }}> <JobCardProvider jobCard={{ ...jobCard, label: title }}>
<DashboardDetailsPage <DashboardDetailsPage
className='p-0 lg:p-0' className='p-0 lg:p-0'
title={title} title={title}
description={data?.status ? `Status: ${data.status.split("_").map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")}` : undefined} description={status ? `Status: ${status.split("_").map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")}` : undefined}
icon={<ClipboardListIcon className="size-5" />} icon={<ClipboardListIcon className="size-5" />}
backHref="/sales/job-cards" backHref="/sales/job-cards"
actions={<JobCardActions jobCardId={id} />} actions={
<JobCardDropdown id={id} />
}
subHeader={<JobCardStatusStepper jobCardId={id} />} subHeader={<JobCardStatusStepper jobCardId={id} />}
tabs={[ tabs={[
{ {
@ -39,7 +45,27 @@ export default async function JobCardDetailLayout(props: { params: Promise<{ id:
}, },
{ {
href: `/sales/job-cards/${id}/attachments`, href: `/sales/job-cards/${id}/attachments`,
label: 'Attachments' label: `Attachments (${docs?.length || 0})`
},
{
href: `/sales/job-cards/${id}/purchase-orders`,
label: `Purchase Orders (${jobCard?.purchase_orders_count || 0})`
},
{
href: `/sales/job-cards/${id}/bills`,
label: `Bills (${jobCard?.bills_count || 0})`
},
{
href: `/sales/job-cards/${id}/appointments`,
label: `Appointments (${jobCard?.appointments_count || 0})`
},
{
href: `/sales/job-cards/${id}/expenses`,
label: `Expenses (${jobCard?.expenses_count || 0})`
},
{
href: `/sales/job-cards/${id}/tasks`,
label: `Tasks (${jobCard?.tasks_count || 0})`
}, },
]} ]}
> >

View File

@ -0,0 +1,85 @@
"use client"
import { use } from "react"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { PurchaseOrderForm } from "@/modules/purchase-orders/purchase-order-form"
import { PURCHASE_ORDER_ROUTES } from "@garage/api"
import type { PurchaseOrdersClient } from "@garage/api"
import { useJobCard } from "@/modules/job-cards/job-card-context"
export default function JobCardPurchaseOrdersPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id: jobCardId } = use(params)
const jobCard = useJobCard()
const defaultJobCard = jobCard
? { value: String((jobCard as any).id), label: (jobCard as any).label || (jobCard as any).title || `Job Card` }
: null
return (
<ResourcePage<PurchaseOrdersClient>
pageTitle="Purchase Orders"
routeKey={PURCHASE_ORDER_ROUTES.INDEX}
getClient={(api) => api.purchaseOrders}
extraParams={{ job_card_id: jobCardId }}
header={null}
toolbar={({ invalidateQuery, selectedItem, closeDialog }) => (
<FormDialog title="Purchase Order">
{(resourceId) => (
<PurchaseOrderForm
resourceId={resourceId}
initialData={selectedItem ?? { job_card: defaultJobCard }}
onSuccess={() => { closeDialog(); invalidateQuery() }}
/>
)}
</FormDialog>
)}
columns={({ actionsColumn }) => [
{
accessorKey: "order_number",
header: ({ column }) => <ColumnHeader column={column} title="Order #" />,
cell: ({ row }) => (row.original as any).order_number || "—",
},
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
},
{
accessorKey: "vendor_name",
header: ({ column }) => <ColumnHeader column={column} title="Vendor" />,
cell: ({ row }) => (row.original as any).vendor_name || "—",
},
{
accessorKey: "order_date",
header: ({ column }) => <ColumnHeader column={column} title="Order Date" />,
cell: ({ row }) => {
const val = (row.original as any).order_date
return val ? new Date(val).toLocaleDateString() : "—"
},
},
{
accessorKey: "delivery_date",
header: ({ column }) => <ColumnHeader column={column} title="Delivery Date" />,
cell: ({ row }) => {
const val = (row.original as any).delivery_date
return val ? new Date(val).toLocaleDateString() : "—"
},
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
cell: ({ row }) => {
const val = (row.original as any).created_at
return val ? new Date(val).toLocaleDateString() : "—"
},
},
actionsColumn(),
]}
/>
)
}

View File

@ -19,6 +19,7 @@ import {
DialogTitle, DialogTitle,
} from "@/shared/components/ui/dialog" } from "@/shared/components/ui/dialog"
import { JobCardRecommendationForm } from "@/modules/job-cards/job-card-recommendation-form" import { JobCardRecommendationForm } from "@/modules/job-cards/job-card-recommendation-form"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
type ShopRecommendation = { type ShopRecommendation = {
id: number id: number
@ -112,13 +113,17 @@ export default function ShopRecommendationsPage() {
} }
return ( return (
<div className="flex flex-col gap-4"> <DashboardPage
<div className="flex items-center justify-end"> header={null}
title="Shop Recommendations"
toolbar={
<Button onClick={() => setDialogOpen(true)}> <Button onClick={() => setDialogOpen(true)}>
<Plus className="size-4" /> <Plus className="size-4 me-2" />
Add Shop Recommendation Add Recommendation
</Button> </Button>
</div> }
>
<Card> <Card>
<CardContent> <CardContent>
@ -147,6 +152,6 @@ export default function ShopRecommendationsPage() {
/> />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </DashboardPage>
) )
} }

View File

@ -0,0 +1,67 @@
"use client"
import { use } from "react"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import { TASK_ROUTES } from "@garage/api"
import type { TasksClient } from "@garage/api"
import { Badge } from "@/shared/components/ui/badge"
export default function JobCardTasksPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id: jobCardId } = use(params)
return (
<ResourcePage<TasksClient>
pageTitle="Tasks"
routeKey={TASK_ROUTES.TASKS}
getClient={(api) => api.tasks}
extraParams={{ job_card_id: jobCardId }}
header={null}
columns={() => [
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Task" />,
cell: ({ row }) => (row.original as any).title || "—",
},
{
accessorKey: "task_type_name",
header: ({ column }) => <ColumnHeader column={column} title="Type" />,
cell: ({ row }) => (row.original as any).task_type_name || "—",
},
{
accessorKey: "section_name",
header: ({ column }) => <ColumnHeader column={column} title="Section" />,
cell: ({ row }) => (row.original as any).section_name || "—",
},
{
accessorKey: "due_date",
header: ({ column }) => <ColumnHeader column={column} title="Due Date" />,
cell: ({ row }) => {
const val = (row.original as any).due_date
return val ? new Date(val).toLocaleDateString() : "—"
},
},
{
accessorKey: "priority",
header: ({ column }) => <ColumnHeader column={column} title="Priority" />,
cell: ({ row }) => {
const value = (row.original as any).priority
return value ? <Badge variant="outline">{value}</Badge> : "—"
},
},
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const value = (row.original as any).status
return value ? <Badge>{value}</Badge> : "—"
},
},
]}
/>
)
}

View File

@ -4,11 +4,14 @@ import { ResourcePage } from '@/shared/data-view/resource-page'
import { ColumnHeader } from '@/shared/data-view/table-view' import { ColumnHeader } from '@/shared/data-view/table-view'
import FormDialog from '@/shared/components/form-dialog' import FormDialog from '@/shared/components/form-dialog'
import { JobCardForm } from '@/modules/job-cards/job-card-form' import { JobCardForm } from '@/modules/job-cards/job-card-form'
import { JOB_CARD_ROUTES } from '@garage/api' import { JOB_CARD_ROUTES, JobCardStatus } from '@garage/api'
import type { JobCardsClient } from '@garage/api' import type { JobCardsClient } from '@garage/api'
import { ClipboardListIcon } from 'lucide-react' import { Tabs, TabsList, TabsTrigger } from '@/shared/components/ui/tabs'
import { ClipboardListIcon, SearchIcon } from 'lucide-react'
import { Badge } from '@/shared/components/ui/badge' import { Badge } from '@/shared/components/ui/badge'
import { Input } from '@/shared/components/ui/input'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useState, useEffect } from 'react'
type JobCardItem = { type JobCardItem = {
id: number id: number
@ -38,12 +41,25 @@ const formatStatus = (status?: string) => {
export default function JobCardsPage() { export default function JobCardsPage() {
const router = useRouter() const router = useRouter()
const [searchInput, setSearchInput] = useState("")
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<string>("all")
useEffect(() => {
const timer = setTimeout(() => setSearch(searchInput), 400)
return () => clearTimeout(timer)
}, [searchInput])
const extraParams: Record<string, unknown> = {}
if (search) extraParams.search = search
if (statusFilter !== "all") extraParams.status = statusFilter
return ( return (
<ResourcePage<JobCardsClient> <ResourcePage<JobCardsClient>
pageTitle="Job Cards" pageTitle="Job Cards"
routeKey={JOB_CARD_ROUTES.INDEX} routeKey={JOB_CARD_ROUTES.INDEX}
getClient={(api) => api.jobCards} getClient={(api) => api.jobCards}
extraParams={extraParams}
onRowClick={(row) => router.push(`/sales/job-cards/${row.id}`)} onRowClick={(row) => router.push(`/sales/job-cards/${row.id}`)}
headerProps={({ selectedItem, invalidateQuery }) => ({ headerProps={({ selectedItem, invalidateQuery }) => ({
actions: ( actions: (
@ -106,6 +122,32 @@ export default function JobCardsPage() {
}, },
actionsColumn(), actionsColumn(),
]} ]}
toolbar={
<div className="flex gap-3 w-full">
<div className="relative w-64">
<SearchIcon className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search job cards..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-8"
/>
</div>
</div>
}
tableHeader={
<Tabs value={statusFilter} onValueChange={setStatusFilter}>
<TabsList variant="default">
<TabsTrigger value="all" className='data-[state=active]:bg-primary/10 data-[state=active]:text-primary'>All</TabsTrigger>
{JobCardStatus.map((status) => (
<TabsTrigger className='data-[state=active]:bg-primary/10 data-[state=active]:text-primary ' key={status} value={status}>
{formatStatus(status)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
}
/> />
) )
} }

View File

@ -11,16 +11,6 @@ import type { EstimatesClient } from '@garage/api'
import { FileTextIcon } from 'lucide-react' import { FileTextIcon } from 'lucide-react'
import { useVehicle } from '@/modules/vehicles/vehicle-context' import { useVehicle } from '@/modules/vehicles/vehicle-context'
type EstimateItem = {
id: number
title?: string
estimate_number?: string
date?: string
customer_name?: string
has_insurance?: boolean
created_at?: string
}
export default function VehicleEstimatesPage({ params }: { params: Promise<{ id: string }> }) { export default function VehicleEstimatesPage({ params }: { params: Promise<{ id: string }> }) {
const { id: vehicleId } = use(params) const { id: vehicleId } = use(params)
const vehicle = useVehicle() const vehicle = useVehicle()
@ -57,7 +47,7 @@ export default function VehicleEstimatesPage({ params }: { params: Promise<{ id:
accessorKey: "title", accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />, header: ({ column }) => <ColumnHeader column={column} title="Title" />,
cell: ({ row }) => { cell: ({ row }) => {
const item = row.original as unknown as EstimateItem const item = row.original
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FileTextIcon className="text-muted-foreground h-4 w-4" /> <FileTextIcon className="text-muted-foreground h-4 w-4" />
@ -82,7 +72,7 @@ export default function VehicleEstimatesPage({ params }: { params: Promise<{ id:
accessorKey: "has_insurance", accessorKey: "has_insurance",
header: ({ column }) => <ColumnHeader column={column} title="Insurance" />, header: ({ column }) => <ColumnHeader column={column} title="Insurance" />,
cell: ({ row }) => { cell: ({ row }) => {
const item = row.original as unknown as EstimateItem const item = row.original
return item.has_insurance ? "Yes" : "No" return item.has_insurance ? "Yes" : "No"
}, },
}, },
@ -90,7 +80,7 @@ export default function VehicleEstimatesPage({ params }: { params: Promise<{ id:
accessorKey: "created_at", accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Created" />, header: ({ column }) => <ColumnHeader column={column} title="Created" />,
cell: ({ row }) => { cell: ({ row }) => {
const item = row.original as unknown as EstimateItem const item = row.original
return item.created_at ? new Date(item.created_at).toLocaleDateString() : "—" return item.created_at ? new Date(item.created_at).toLocaleDateString() : "—"
}, },
}, },

View File

@ -0,0 +1,17 @@
"use client"
import { SettingsForm } from "@/modules/settings/company/settings-form"
export default function CompanySettingsPage() {
return (
<div className="mx-auto max-w-3xl space-y-6 p-6">
<div>
<h1 className="text-2xl font-semibold">Company Settings</h1>
<p className="text-muted-foreground text-sm">
Manage your workshop profile, contact details, and preferences.
</p>
</div>
<SettingsForm />
</div>
)
}

View File

@ -0,0 +1,7 @@
"use client"
import { GeneralPreferencesForm } from "@/modules/settings/configurations/general-preferences-form"
export default function GeneralPreferencesPage() {
return <GeneralPreferencesForm />
}

View File

@ -0,0 +1,42 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { cn } from "@/shared/lib/utils"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
const TABS = [
{ label: "Sales", href: "/settings/configurations/preferences/sales" },
{ label: "Purchases", href: "/settings/configurations/preferences/purchases" },
{ label: "General", href: "/settings/configurations/preferences/general" },
]
export default function ConfigurationsLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
return (
<DashboardPage headerProps={{ title: "Configurations" }}>
<div className="space-y-6">
<nav className="flex gap-1 border-b">
{TABS.map((tab) => (
<Link
key={tab.href}
href={tab.href}
className={cn(
"px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
pathname === tab.href
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
{tab.label}
</Link>
))}
</nav>
<div className="max-w-xl">
{children}
</div>
</div>
</DashboardPage>
)
}

View File

@ -0,0 +1,7 @@
"use client"
import { PurchaseConfigForm } from "@/modules/settings/configurations/purchase-config-form"
export default function PurchasesConfigPage() {
return <PurchaseConfigForm />
}

View File

@ -0,0 +1,7 @@
"use client"
import { SalesConfigForm } from "@/modules/settings/configurations/sales-config-form"
export default function SalesConfigPage() {
return <SalesConfigForm />
}

View File

@ -0,0 +1,43 @@
"use client"
import { DepartmentForm } from "@/modules/settings/departments/department-form"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { DEPARTMENT_ROUTES } from "@garage/api"
import type { DepartmentsClient } from "@garage/api"
export default function DepartmentsPage() {
return (
<ResourcePage<DepartmentsClient>
pageTitle="Departments"
routeKey={DEPARTMENT_ROUTES.INDEX}
getClient={(api) => api.departments}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<FormDialog title="Department">
{(resourceId) => (
<DepartmentForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "name",
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
},
{
accessorKey: "assignment_type",
header: ({ column }) => <ColumnHeader column={column} title="Assignment Type" />,
cell: ({ row }) => (row.original as any).assignment_type ?? "none",
},
actionsColumn(),
]}
/>
)
}

View File

@ -0,0 +1,39 @@
"use client"
import { InsuranceTypeForm } from "@/modules/settings/insurance-types/insurance-type-form"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { INSURANCE_TYPE_ROUTES } from "@garage/api"
import type { InsuranceTypesClient } from "@garage/api"
export default function InsuranceTypesPage() {
return (
<ResourcePage<InsuranceTypesClient>
pageTitle="Insurance Types"
routeKey={INSURANCE_TYPE_ROUTES.INDEX}
getClient={(api) => api.insuranceTypes}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<FormDialog title="Insurance Type">
{(resourceId) => (
<InsuranceTypeForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
cell: ({ row }) => (row.original as any).title ?? (row.original as any).name ?? "—",
},
actionsColumn(),
]}
/>
)
}

View File

@ -1,12 +1,54 @@
"use client" "use client"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import { CheckIcon, StarIcon, StarOffIcon, XIcon } from "lucide-react"
import { TaxForm } from "@/modules/settings/tax-rates/tax-form" import { TaxForm } from "@/modules/settings/tax-rates/tax-form"
import { ResourcePage } from "@/shared/data-view/resource-page" import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view" import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog" import FormDialog from "@/shared/components/form-dialog"
import { Button } from "@/shared/components/ui/button"
import { useAuthApi } from "@/shared/useApi"
import { TAX_ROUTES } from "@garage/api" import { TAX_ROUTES } from "@garage/api"
import type { TaxesClient } from "@garage/api" import type { TaxesClient } from "@garage/api"
import { CheckIcon, XIcon } from "lucide-react"
function TaxDefaultCell({ row }: { row: any }) {
const api = useAuthApi()
const queryClient = useQueryClient()
const isDefault: boolean = row.is_default ?? false
const { mutate, isPending } = useMutation({
mutationFn: () => {
const promise = isDefault
? api.taxes.removeDefault({ id: row.id })
: api.taxes.setDefault({ id: row.id })
toast.promise(promise, {
loading: isDefault ? "Removing default..." : "Setting as default...",
success: isDefault ? "Default removed" : "Set as default",
error: isDefault ? "Failed to remove default" : "Failed to set as default",
})
return promise
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [TAX_ROUTES.INDEX] })
},
})
return (
<Button
variant="ghost"
size="icon-sm"
disabled={isPending}
onClick={() => mutate()}
title={isDefault ? "Remove default" : "Set as default"}
>
{isDefault
? <StarIcon className="h-4 w-4 text-yellow-500 fill-yellow-400" />
: <StarOffIcon className="h-4 w-4 text-muted-foreground" />}
</Button>
)
}
export default function TaxesPage() { export default function TaxesPage() {
return ( return (
@ -49,11 +91,16 @@ export default function TaxesPage() {
{ {
accessorKey: "is_default", accessorKey: "is_default",
header: ({ column }) => <ColumnHeader column={column} title="Default" />, header: ({ column }) => <ColumnHeader column={column} title="Default" />,
cell: ({ row }) => cell: ({ row }) => (row.original as any).is_default
(row.original as any).is_default
? <CheckIcon className="h-4 w-4 text-green-600" /> ? <CheckIcon className="h-4 w-4 text-green-600" />
: <XIcon className="h-4 w-4 text-muted-foreground" />, : <XIcon className="h-4 w-4 text-muted-foreground" />,
}, },
{
id: "set_default",
header: () => <span className="sr-only">Set Default</span>,
enableSorting: false,
cell: ({ row }) => <TaxDefaultCell row={row.original} />,
},
actionsColumn(), actionsColumn(),
]} ]}
/> />

View File

@ -68,12 +68,14 @@ export default function DashboardDetailsPageLayout({
</Button> </Button>
)} )}
{(avatarSrc || avatarFallback) && ( {(avatarSrc || avatarFallback) && (
<Avatar size="lg"> <a rel="preload" target="_blank" href={avatarSrc} >
<Avatar className="size-14">
{avatarSrc && <AvatarImage src={avatarSrc} alt={title} />} {avatarSrc && <AvatarImage src={avatarSrc} alt={title} />}
<AvatarFallback> <AvatarFallback>
{avatarFallback ?? title.charAt(0).toUpperCase()} {avatarFallback ?? title.charAt(0).toUpperCase()}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
</a>
)} )}
{!avatarSrc && !avatarFallback && icon && ( {!avatarSrc && !avatarFallback && icon && (
<div className="flex items-center justify-center size-10 rounded-full bg-muted text-muted-foreground"> <div className="flex items-center justify-center size-10 rounded-full bg-muted text-muted-foreground">

View File

@ -31,6 +31,7 @@ import {
ReceiptIcon, ReceiptIcon,
ReceiptTextIcon, ReceiptTextIcon,
SettingsIcon, SettingsIcon,
ShieldIcon,
ShoppingBasketIcon, ShoppingBasketIcon,
CircleDollarSign, CircleDollarSign,
StarIcon, StarIcon,
@ -170,6 +171,8 @@ export const navGroups: NavGroup[] = [
items: [ items: [
{ title: "Company", href: "/settings/company", icon: <Building2Icon /> }, { title: "Company", href: "/settings/company", icon: <Building2Icon /> },
{ title: "Shop Types", href: "/settings/shop-type", icon: <CarIcon /> }, { title: "Shop Types", href: "/settings/shop-type", icon: <CarIcon /> },
{ title: "Departments", href: "/settings/departments", icon: <Building2Icon /> },
{ title: "Insurance Types", href: "/settings/insurance-types", icon: <ShieldIcon /> },
{ title: "Tax & Rates", href: "/settings/tax-rates", icon: <ReceiptTextIcon /> }, { title: "Tax & Rates", href: "/settings/tax-rates", icon: <ReceiptTextIcon /> },
{ title: "Configurations", href: "/settings/configurations/preferences/sales", icon: <SettingsIcon /> }, { title: "Configurations", href: "/settings/configurations/preferences/sales", icon: <SettingsIcon /> },
{ title: "Templates", href: "/settings/templates", icon: <ClipboardListIcon /> }, { title: "Templates", href: "/settings/templates", icon: <ClipboardListIcon /> },

View File

@ -0,0 +1,124 @@
"use client"
import { useAuthApi } from "@/shared/useApi"
import { useRouter } from "next/navigation"
import { useState } from "react"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { Ellipsis, Pencil, Trash2, CheckCircle, Unlink } from "lucide-react"
import { toast } from "sonner"
import { useQueryClient } from "@tanstack/react-query"
import { APPOINTMENT_ROUTES } from "@garage/api"
type AppointmentActionsProps = {
appointmentId: string
currentStatus?: string
jobCardId?: number | null
}
export function AppointmentActions({ appointmentId, currentStatus, jobCardId }: AppointmentActionsProps) {
const api = useAuthApi()
const router = useRouter()
const queryClient = useQueryClient()
const [isLoading, setIsLoading] = useState(false)
const handleEdit = () => {
router.push(`/calendar/appointment/${appointmentId}/edit`)
}
const handleDelete = async () => {
setIsLoading(true)
try {
await api.appointments.destroy(appointmentId)
toast.success("Appointment deleted.")
router.push("/calendar/appointment/list")
} catch {
toast.error("Failed to delete appointment.")
} finally {
setIsLoading(false)
}
}
const handleChangeStatus = async (status: string) => {
setIsLoading(true)
try {
await api.appointments.changeStatus(appointmentId, { status } as any)
toast.success(`Status updated to "${status}".`)
queryClient.invalidateQueries({ queryKey: [APPOINTMENT_ROUTES.INDEX] })
router.refresh()
} catch {
toast.error("Failed to update status.")
} finally {
setIsLoading(false)
}
}
const handleUnlinkJobCard = async () => {
if (!jobCardId) return
setIsLoading(true)
try {
await api.appointments.unlinkJobCard(appointmentId, {} as any)
toast.success("Job card unlinked.")
queryClient.invalidateQueries({ queryKey: [APPOINTMENT_ROUTES.INDEX] })
router.refresh()
} catch {
toast.error("Failed to unlink job card.")
} finally {
setIsLoading(false)
}
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" disabled={isLoading}>
<Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleEdit}>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
{currentStatus !== "confirmed" && (
<DropdownMenuItem onClick={() => handleChangeStatus("confirmed")}>
<CheckCircle className="size-4" />
Confirm
</DropdownMenuItem>
)}
{currentStatus !== "completed" && (
<DropdownMenuItem onClick={() => handleChangeStatus("completed")}>
<CheckCircle className="size-4" />
Mark Completed
</DropdownMenuItem>
)}
{currentStatus !== "cancelled" && (
<DropdownMenuItem onClick={() => handleChangeStatus("cancelled")}>
<CheckCircle className="size-4" />
Cancel
</DropdownMenuItem>
)}
{jobCardId && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleUnlinkJobCard}>
<Unlink className="size-4" />
Unlink Job Card
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -0,0 +1,28 @@
"use client"
import { createContext, useContext } from "react"
type AppointmentContextValue = {
id: string
label: string
}
const AppointmentContext = createContext<AppointmentContextValue | null>(null)
export function AppointmentProvider({
appointment,
children,
}: {
appointment: AppointmentContextValue
children: React.ReactNode
}) {
return (
<AppointmentContext.Provider value={appointment}>
{children}
</AppointmentContext.Provider>
)
}
export function useAppointment() {
return useContext(AppointmentContext)
}

View File

@ -0,0 +1,208 @@
"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,
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 {
appointmentFormSchema,
type AppointmentFormValues,
APPOINTMENT_STATUS_OPTIONS,
} from "./appointment.schema"
import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field"
import { RhfEmployeeSelectField } from "@/modules/employees/rhf-employee-select-field"
// ── Props ──
export type AppointmentFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
// ── Default values ──
const DEFAULT_VALUES: AppointmentFormValues = {
title: "",
date: "",
from_time: "",
to_time: "",
customer: null,
vehicle: null,
service_writer: null,
technician: null,
department: null,
job_card: null,
notes: "",
status: "requested",
}
// ── Mapping helpers ──
function mapToFormValues(data: unknown): AppointmentFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
title: d.title || "",
date: d.date || "",
from_time: d.from_time || "",
to_time: d.to_time || "",
customer: toRelation(d.customer_id, d.customer_name),
vehicle: toRelation(d.vehicle_id, d.vehicle_name),
service_writer: toRelation(d.service_writer_id, d.service_writer_name),
technician: toRelation(d.technician_id, d.technician_name),
department: toRelation(d.department_id, d.department_name),
job_card: toRelation(d.job_card_id, d.job_card_title),
notes: d.notes || "",
status: d.status || "requested",
}
}
function mapFormToPayload(values: AppointmentFormValues) {
return {
title: values.title,
date: values.date,
from_time: values.from_time,
to_time: values.to_time,
customer_id: toId(values.customer),
vehicle_id: toId(values.vehicle),
service_writer_id: toId(values.service_writer),
technician_id: toId(values.technician),
department_id: toId(values.department),
job_card_id: toId(values.job_card),
notes: values.notes || undefined,
status: values.status || undefined,
}
}
// ── Shared helpers ──
const mapLookupOption = (item: any) => ({
value: String(item.id),
label: item.name ?? item.title ?? String(item.id),
})
const mapJobCardOption = (item: any) => ({
value: String(item.id),
label: item.title || `Job Card #${item.id}`,
})
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
// ── Component ──
export function AppointmentForm({ resourceId, initialData, onSuccess }: AppointmentFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<AppointmentFormValues>({
schema: appointmentFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
mapToFormValues,
})
const { mutate, isPending, error } = useFormMutation(form, {
mutationFn: (values: AppointmentFormValues) => {
const payload = mapFormToPayload(values)
const promise = (isEditing && resourceId
? api.appointments.update(resourceId, payload as any)
: api.appointments.create(payload as any)) as Promise<any>
toast.promise(promise, {
loading: isEditing ? "Updating appointment..." : "Creating appointment...",
success: isEditing ? "Appointment updated." : "Appointment created.",
error: isEditing ? "Failed to update appointment." : "Failed to create appointment.",
})
return promise
},
onSuccess: () => {
form.reset()
onSuccess?.()
},
})
return (
<Rhform form={form} onSubmit={(values) => mutate(values)} className="space-y-6">
{error && (
<Alert variant="destructive">
<AlertTriangle className="size-4" />
<AlertTitle>{error.message}</AlertTitle>
</Alert>
)}
<FieldGroup>
<RhfTextField name="title" label="Title" placeholder="e.g. Oil Change Appointment" required />
<div className="grid grid-cols-2 gap-4">
<RhfTextField name="date" label="Date" placeholder="YYYY-MM-DD" type="date" />
<RhfSelectField
name="status"
label="Status"
options={APPOINTMENT_STATUS_OPTIONS}
placeholder="Select status"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<RhfTextField name="from_time" label="From Time" placeholder="HH:MM" type="time" />
<RhfTextField name="to_time" label="To Time" placeholder="HH:MM" type="time" />
</div>
</FieldGroup>
<FieldGroup>
<RhfCustomerSelectField name="customer" />
<RhfVehicleSelectField name="vehicle" />
</FieldGroup>
<FieldGroup>
<RhfEmployeeSelectField name="service_writer" label="Service Writer" />
<RhfEmployeeSelectField name="technician" label="Technician" />
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department..."
queryKey={["departments-lookup"]}
listFn={() => api.departments.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
</FieldGroup>
<FieldGroup>
<RhfAsyncSelectField
name="job_card"
label="Job Card (optional)"
placeholder="Select job card..."
queryKey={["job-cards-lookup"]}
listFn={() => api.jobCards.list()}
mapOption={mapJobCardOption}
{...STORE_OBJECT}
/>
</FieldGroup>
<FieldGroup>
<RhfTextareaField name="notes" label="Notes" placeholder="Additional notes..." />
</FieldGroup>
<Button type="submit" disabled={isPending} className="w-full">
{isEditing ? <Save className="size-4" /> : <Plus className="size-4" />}
{isPending
? (isEditing ? "Saving..." : "Creating...")
: (isEditing ? "Save Changes" : "Create Appointment")}
</Button>
</Rhform>
)
}

View File

@ -0,0 +1,166 @@
import {
CalendarCheck2,
Clock,
User,
Car,
Users,
Wrench,
Building2,
FileText,
ClipboardList,
Info,
ExternalLink,
} from "lucide-react"
import Link from "next/link"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
type AppointmentData = {
id?: number
job_card_id?: number
title?: string
date?: string
from_time?: string
to_time?: string
customer_id?: number
vehicle_id?: number
service_writer_id?: number
technician_id?: number
department_id?: number
notes?: string
status?: string
created_at?: string
updated_at?: string
}
type AppointmentGeneralInfoProps = {
appointment: AppointmentData
}
function InfoItem({
icon: Icon,
label,
value,
}: {
icon: React.ComponentType<{ className?: string }>
label: string
value?: string | number | null
}) {
return (
<div className="flex items-start gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Icon className="size-4" />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-sm font-medium">
{value != null && value !== "" ? String(value) : <span className="text-muted-foreground"></span>}
</span>
</div>
</div>
)
}
const STATUS_COLORS: Record<string, string> = {
requested: "bg-yellow-100 text-yellow-800",
confirmed: "bg-blue-100 text-blue-800",
in_progress: "bg-purple-100 text-purple-800",
completed: "bg-green-100 text-green-800",
cancelled: "bg-red-100 text-red-800",
}
export function AppointmentGeneralInfo({ appointment }: AppointmentGeneralInfoProps) {
const statusClass = appointment.status
? (STATUS_COLORS[appointment.status] ?? "bg-gray-100 text-gray-800")
: "bg-gray-100 text-gray-800"
return (
<div className="grid gap-6 md:grid-cols-2 p-6">
{/* Appointment Details */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CalendarCheck2 className="size-4" />
Appointment Details
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="flex items-center gap-2">
{appointment.status && (
<Badge className={statusClass}>
{appointment.status.replace("_", " ")}
</Badge>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem icon={Info} label="Title" value={appointment.title} />
<InfoItem icon={CalendarCheck2} label="Date" value={appointment.date} />
<InfoItem icon={Clock} label="From Time" value={appointment.from_time} />
<InfoItem icon={Clock} label="To Time" value={appointment.to_time} />
</div>
</CardContent>
</Card>
{/* Customer & Vehicle */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="size-4" />
Customer &amp; Vehicle
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem icon={User} label="Customer ID" value={appointment.customer_id} />
<InfoItem icon={Car} label="Vehicle ID" value={appointment.vehicle_id} />
</div>
</CardContent>
</Card>
{/* Assignment */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="size-4" />
Assignment
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem icon={Users} label="Service Writer ID" value={appointment.service_writer_id} />
<InfoItem icon={Wrench} label="Technician ID" value={appointment.technician_id} />
<InfoItem icon={Building2} label="Department ID" value={appointment.department_id} />
</div>
{appointment.job_card_id && (
<div className="pt-2">
<span className="text-xs text-muted-foreground mb-1 block">Job Card</span>
<Button asChild variant="outline" size="sm" className="gap-1.5">
<Link href={`/sales/job-cards/${appointment.job_card_id}`}>
<ClipboardList className="size-3.5" />
Job Card #{appointment.job_card_id}
<ExternalLink className="size-3.5" />
</Link>
</Button>
</div>
)}
</CardContent>
</Card>
{/* Notes */}
{appointment.notes && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="size-4" />
Notes
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground whitespace-pre-line">{appointment.notes}</p>
</CardContent>
</Card>
)}
</div>
)
}

View File

@ -0,0 +1,33 @@
import { z } from "zod"
const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
const APPOINTMENT_STATUS_OPTIONS = [
{ value: "requested", label: "Requested" },
{ value: "confirmed", label: "Confirmed" },
{ value: "in_progress", label: "In Progress" },
{ value: "completed", label: "Completed" },
{ value: "cancelled", label: "Cancelled" },
]
const appointmentFormSchema = z.object({
title: z.string().min(1, "Title is required"),
date: z.string().min(1, "Date is required"),
from_time: z.string().min(1, "Start time is required"),
to_time: z.string().min(1, "End time is required"),
customer: relationFieldSchema,
vehicle: relationFieldSchema,
service_writer: relationFieldSchema,
technician: relationFieldSchema,
department: relationFieldSchema,
job_card: relationFieldSchema,
notes: z.string().optional(),
status: z.string().optional(),
})
type AppointmentFormValues = z.infer<typeof appointmentFormSchema>
export { appointmentFormSchema, relationFieldSchema, APPOINTMENT_STATUS_OPTIONS }
export type { AppointmentFormValues }

View File

@ -0,0 +1,197 @@
"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,
RhfAsyncSelectField,
RhfSelectField,
RhfTextField,
RhfTextareaField,
} from "@/shared/components/form"
import { useResourceForm } from "@/shared/hooks/use-resource-form"
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
import { toId, toRelation } from "@/shared/lib/utils"
import { useAuthApi } from "@/shared/useApi"
import { BillStatus, BILL_ROUTES, DEPARTMENT_ROUTES, JOB_CARD_ROUTES, PAYMENT_TERM_ROUTES, VENDOR_ROUTES } from "@garage/api"
import { toast } from "sonner"
import { billFormSchema, type BillFormValues } from "./bill.schema"
export type BillFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
const DEFAULT_VALUES: BillFormValues = {
vendor: null,
job_card: null,
payment_term: null,
department: null,
title: "",
bill_date: "",
bill_due_date: "",
status: "draft",
notes: "",
}
const STATUS_OPTIONS = BillStatus.map((value) => ({
value,
label: value.replaceAll("_", " "),
}))
const mapLookupOption = (item: any) => ({
value: String(item.id),
label: item.name ?? item.title ?? item.bill_number ?? `#${item.id}`,
})
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
function mapToFormValues(data: unknown): BillFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
vendor: toRelation(d.vendor_id, d.vendor_name),
job_card: toRelation(d.job_card_id, d.job_card_number ?? d.job_card_name),
payment_term: toRelation(d.payment_terms_id, d.payment_terms_name),
department: toRelation(d.department_id, d.department_name),
title: d.title || "",
bill_date: d.bill_date || "",
bill_due_date: d.bill_due_date || "",
status: d.status || "draft",
notes: d.notes || "",
}
}
function mapFormToPayload(values: BillFormValues) {
return {
title: values.title,
vendor_id: toId(values.vendor),
job_card_id: toId(values.job_card),
payment_terms_id: toId(values.payment_term),
department_id: toId(values.department),
bill_date: values.bill_date || undefined,
bill_due_date: values.bill_due_date || undefined,
status: values.status || undefined,
notes: values.notes || undefined,
}
}
export function BillForm({ resourceId, initialData, onSuccess }: BillFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<BillFormValues, any>({
schema: billFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
mapToFormValues,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: BillFormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing && resourceId
? api.bills.update(resourceId, payload)
: api.bills.create(payload)
toast.promise(promise, {
loading: isEditing ? "Updating bill..." : "Creating bill...",
success: isEditing ? "Bill updated successfully" : "Bill created successfully",
error: isEditing ? "Failed to update bill" : "Failed to create bill",
})
return promise
},
onSuccess: () => {
form.reset()
onSuccess?.()
},
})
return (
<Rhform form={form} onSubmit={(values) => mutate(values)}>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="me-2 h-4 w-4" />
<AlertTitle>{isEditing ? "Failed to update bill" : "Failed to create bill"}</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<RhfTextField name="title" label="Title" placeholder="Enter bill title" required />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="bill_date" label="Bill Date" type="date" />
<RhfTextField name="bill_due_date" label="Due Date" type="date" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="vendor"
label="Vendor"
placeholder="Select vendor"
queryKey={[VENDOR_ROUTES.INDEX]}
listFn={() => api.vendors.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department"
queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.departments.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="job_card"
label="Job Card"
placeholder="Select job card"
queryKey={[JOB_CARD_ROUTES.INDEX]}
listFn={() => api.jobCards.list()}
mapOption={(item: any) => ({ value: String(item.id), label: item.job_card_number || item.name || `#${item.id}` })}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="payment_term"
label="Payment Term"
placeholder="Select payment term"
queryKey={[PAYMENT_TERM_ROUTES.INDEX]}
listFn={() => api.paymentTerms.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
</div>
<RhfSelectField name="status" label="Status" options={STATUS_OPTIONS} />
<RhfTextareaField name="notes" label="Notes" rows={3} />
<Button type="submit" disabled={isPending}>
{isPending ? (
"Saving..."
) : isEditing ? (
<>
<Save className="me-2 h-4 w-4" />
Update Bill
</>
) : (
<>
<Plus className="me-2 h-4 w-4" />
Create Bill
</>
)}
</Button>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,22 @@
import { z } from "zod"
const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
const billFormSchema = z.object({
vendor: relationFieldSchema,
job_card: relationFieldSchema,
payment_term: relationFieldSchema,
department: relationFieldSchema,
title: z.string().min(1, "Title is required"),
bill_date: z.string().optional(),
bill_due_date: z.string().optional(),
status: z.string().optional(),
notes: z.string().optional(),
})
type BillFormValues = z.infer<typeof billFormSchema>
export { billFormSchema, relationFieldSchema }
export type { BillFormValues }

View File

@ -0,0 +1,65 @@
"use client"
import { useAuthApi } from "@/shared/useApi"
import { useRouter } from "next/navigation"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { Ellipsis, Pencil, Trash2 } from "lucide-react"
import { confirm } from "@/shared/components/confirm-dialog"
import { toast } from "sonner"
type CustomerActionsProps = {
customerId: string
}
export function CustomerActions({ customerId }: CustomerActionsProps) {
const api = useAuthApi()
const router = useRouter()
const handleEdit = () => {
router.push(`/sales/customers/${customerId}/edit`)
}
const handleDelete = async () => {
const confirmed = await confirm({
title: "Delete Customer",
description: "Are you sure you want to delete this customer? This action cannot be undone.",
confirmLabel: "Delete",
variant: "destructive",
})
if (!confirmed) return
try {
await api.customers.destroy(customerId)
toast.success("Customer deleted successfully.")
router.push("/sales/customers")
} catch {
toast.error("Failed to delete customer.")
}
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleEdit}>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -0,0 +1,28 @@
"use client"
import { createContext, useContext } from "react"
type CustomerContextValue = {
id: string
label: string
}
const CustomerContext = createContext<CustomerContextValue | null>(null)
export function CustomerProvider({
customer,
children,
}: {
customer: CustomerContextValue
children: React.ReactNode
}) {
return (
<CustomerContext.Provider value={customer}>
{children}
</CustomerContext.Provider>
)
}
export function useCustomer() {
return useContext(CustomerContext)
}

View File

@ -0,0 +1,167 @@
import {
User,
Mail,
Phone,
MapPin,
Building2,
Globe,
DollarSign,
CreditCard,
Tag,
Users,
FileText,
} from "lucide-react"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Separator } from "@/shared/components/ui/separator"
type CustomerData = {
id?: number
customer_type_id?: number
salutation?: string
first_name?: string
last_name?: string
company_name?: string
email?: string
phone?: string
alternate_phone?: string
opening_balance?: number
credit_limit?: number
website?: string
referral_source_id?: number
payment_terms_id?: number
address_line_1?: string
address_line_2?: string
country_id?: number
state_id?: number
city?: string
zip_code?: string
created_at?: string
updated_at?: string
customer_type?: { id?: number; name?: string }
referral_source?: { id?: number; title?: string }
payment_terms?: { id?: number; title?: string }
country?: { id?: number; name?: string }
state?: { id?: number; name?: string }
}
type CustomerGeneralInfoProps = {
customer: CustomerData
}
function InfoItem({
icon: Icon,
label,
value,
}: {
icon: React.ComponentType<{ className?: string }>
label: string
value?: string | number | null
}) {
return (
<div className="flex items-start gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Icon className="size-4" />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-sm font-medium">
{value != null && value !== "" ? (
String(value)
) : (
<span className="text-muted-foreground"></span>
)}
</span>
</div>
</div>
)
}
export function CustomerGeneralInfo({ customer }: CustomerGeneralInfoProps) {
const fullName = [customer.salutation, customer.first_name, customer.last_name]
.filter(Boolean)
.join(" ")
return (
<div className="grid gap-6 md:grid-cols-2">
{/* Personal Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="size-4" />
Personal Information
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{fullName || "Unknown"}</Badge>
{customer.customer_type?.name && (
<Badge variant="outline">{customer.customer_type.name}</Badge>
)}
</div>
<Separator />
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem icon={User} label="Full Name" value={fullName} />
<InfoItem icon={Building2} label="Company" value={customer.company_name} />
<InfoItem icon={Tag} label="Customer Type" value={customer.customer_type?.name} />
<InfoItem icon={Users} label="Referral Source" value={customer.referral_source?.title} />
</div>
</CardContent>
</Card>
{/* Contact Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Phone className="size-4" />
Contact Details
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<InfoItem icon={Mail} label="Email" value={customer.email} />
<InfoItem icon={Phone} label="Phone" value={customer.phone} />
<InfoItem icon={Phone} label="Alternate Phone" value={customer.alternate_phone} />
<InfoItem icon={Globe} label="Website" value={customer.website} />
</CardContent>
</Card>
{/* Address */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPin className="size-4" />
Address
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<InfoItem icon={MapPin} label="Address Line 1" value={customer.address_line_1} />
<InfoItem icon={MapPin} label="Address Line 2" value={customer.address_line_2} />
<InfoItem icon={MapPin} label="City" value={customer.city} />
<InfoItem icon={MapPin} label="ZIP Code" value={customer.zip_code} />
<InfoItem icon={MapPin} label="State" value={customer.state?.name} />
<InfoItem icon={MapPin} label="Country" value={customer.country?.name} />
</CardContent>
</Card>
{/* Financial Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DollarSign className="size-4" />
Financial Details
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<InfoItem icon={DollarSign} label="Opening Balance" value={customer.opening_balance} />
<InfoItem icon={CreditCard} label="Credit Limit" value={customer.credit_limit} />
<InfoItem icon={FileText} label="Payment Terms" value={customer.payment_terms?.title} />
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,196 @@
"use client"
import { useRef, useState } from "react"
import { useFormContext, useController, type FieldValues, type FieldPath } from "react-hook-form"
import { useQuery } from "@tanstack/react-query"
import { Building2, Loader2 } from "lucide-react"
import { useAuthApi } from "@/shared/useApi"
import { CUSTOMER_ROUTES } from "@garage/api"
import { FieldShell } from "@/shared/components/form/field-shell"
import {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxEmpty,
} from "@/shared/components/ui/combobox"
// ── Customer option type (enriched for display) ──
type CustomerOption = {
value: string
label: string
first_name?: string
last_name?: string
company_name?: string
email?: string
phone?: string
}
function buildCustomerOption(item: any): CustomerOption {
const name = [item.first_name, item.last_name].filter(Boolean).join(" ")
const label = name || item.company_name || `Customer #${item.id}`
return {
value: String(item.id),
label,
first_name: item.first_name,
last_name: item.last_name,
company_name: item.company_name,
email: item.email,
phone: item.phone,
}
}
function extractItems(response: unknown): any[] {
if (Array.isArray(response)) return response
const obj = response as any
if (Array.isArray(obj?.data?.data)) return obj.data.data
if (Array.isArray(obj?.data)) return obj.data
return []
}
function getInitials(opt: CustomerOption): string {
if (opt.first_name || opt.last_name) {
return [opt.first_name?.[0], opt.last_name?.[0]].filter(Boolean).join("").toUpperCase()
}
if (opt.company_name) return opt.company_name[0].toUpperCase()
return "?"
}
// ── Props ──
export type RhfCustomerSelectFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
> = {
name: TName
label?: string
description?: string
required?: boolean
disabled?: boolean
placeholder?: string
}
// ── Component ──
export function RhfCustomerSelectField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
>({
name,
label = "Customer",
description,
required,
disabled,
placeholder = "Search by name, company, or phone...",
}: RhfCustomerSelectFieldProps<TValues, TName>) {
const api = useAuthApi()
const anchorRef = useRef<HTMLDivElement>(null)
const [inputValue, setInputValue] = useState("")
const { control } = useFormContext<TValues>()
const {
field,
fieldState: { error },
} = useController({ name, control, disabled })
const { data: options = [], isLoading } = useQuery<CustomerOption[]>({
queryKey: [CUSTOMER_ROUTES.INDEX, "customer-select"],
queryFn: async () => {
const res = await api.customers.list()
return extractItems(res).map(buildCustomerOption)
},
staleTime: 5 * 60 * 1000,
})
const filtered = inputValue
? options.filter((c) =>
[c.first_name, c.last_name, c.company_name, c.email, c.phone]
.filter(Boolean)
.join(" ")
.toLowerCase()
.includes(inputValue.toLowerCase()),
)
: options
return (
<FieldShell
label={label}
error={error?.message}
description={description}
required={required}
>
<div ref={anchorRef}>
<Combobox
value={field.value}
onValueChange={(val: CustomerOption | CustomerOption[] | null) => {
const single = Array.isArray(val) ? val[0] ?? null : val
field.onChange(
single ? { value: single.value, label: single.label } : null,
)
}}
disabled={field.disabled}
onInputValueChange={(val: string, { reason }: { reason: string }) => {
if (reason === "input-change") setInputValue(val)
}}
isItemEqualToValue={(item: CustomerOption, val: any) =>
item?.value === val?.value
}
>
<ComboboxInput
placeholder={placeholder}
showClear={!!field.value}
onBlur={field.onBlur}
aria-invalid={!!error || undefined}
/>
<ComboboxContent anchor={anchorRef}>
<ComboboxList className='overflow-auto'>
{isLoading && (
<div className="flex items-center justify-center py-6">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
)}
{!isLoading &&
filtered.map((opt) => (
<ComboboxItem key={opt.value} value={opt}>
<div className="flex items-center gap-3 py-0.5 w-full min-w-0">
{/* Avatar circle with initials */}
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary border border-border text-sm font-medium">
{getInitials(opt)}
</div>
{/* Identity */}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm font-medium leading-none">
{opt.label}
</span>
<div className="flex items-center gap-2 min-w-0">
{opt.company_name && opt.label !== opt.company_name && (
<span className="flex items-center gap-1 truncate text-xs text-muted-foreground">
<Building2 className="size-3 shrink-0" />
{opt.company_name}
</span>
)}
{opt.phone && (
<span className="text-xs text-muted-foreground truncate">
{opt.phone}
</span>
)}
</div>
</div>
</div>
</ComboboxItem>
))}
{!isLoading && filtered.length === 0 && (
<ComboboxEmpty>No customers found</ComboboxEmpty>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
</FieldShell>
)
}

View File

@ -0,0 +1,70 @@
"use client"
import { useAuthApi } from "@/shared/useApi"
import { useRouter } from "next/navigation"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Ellipsis, Pencil, Trash2 } from "lucide-react"
import { useFormDialog } from "@/shared/components/form-dialog"
import { EmployeeForm } from "./employee-form"
type EmployeeActionsProps = {
employeeId: string
}
export function EmployeeActions({ employeeId }: EmployeeActionsProps) {
const api = useAuthApi()
const router = useRouter()
const editDialog = useFormDialog("employee_edit")
const handleDelete = async () => {
await api.employees.destroy(employeeId)
router.push("/productivity/employees")
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => editDialog.open(employeeId)}>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={editDialog.isOpen} onOpenChange={(v) => { if (!v) editDialog.close() }}>
<DialogContent className="min-w-xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">Edit Employee</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[80vh] px-4">
<EmployeeForm
resourceId={editDialog.resourceId}
onSuccess={() => {
editDialog.close()
router.refresh()
}}
/>
</ScrollArea>
</DialogContent>
</Dialog>
</>
)
}

View File

@ -0,0 +1,178 @@
"use client"
import { useRef, useState } from "react"
import { useQuery } from "@tanstack/react-query"
import { Loader2 } from "lucide-react"
import { useAuthApi } from "@/shared/useApi"
import { EMPLOYEE_ROUTES } from "@garage/api"
import { Badge } from "@/shared/components/ui/badge"
import {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxEmpty,
} from "@/shared/components/ui/combobox"
// ── Types ──
export type EmployeeOption = {
value: string
label: string
first_name?: string
last_name?: string
email?: string
type?: string
status?: string
}
// ── Helpers ──
function buildEmployeeOption(item: any): EmployeeOption {
const label =
[item.first_name, item.last_name].filter(Boolean).join(" ") ||
`Employee #${item.id}`
return {
value: String(item.id),
label,
first_name: item.first_name,
last_name: item.last_name,
email: item.email,
type: item.type,
status: item.status,
}
}
function extractItems(response: unknown): any[] {
if (Array.isArray(response)) return response
const obj = response as any
if (Array.isArray(obj?.data?.data)) return obj.data.data
if (Array.isArray(obj?.data)) return obj.data
return []
}
function getInitials(opt: EmployeeOption): string {
return (
[opt.first_name?.[0], opt.last_name?.[0]].filter(Boolean).join("").toUpperCase() || "E"
)
}
// ── Props ──
type EmployeeComboboxProps = {
value?: EmployeeOption | null
onValueChange: (employee: EmployeeOption | null) => void
disabled?: boolean
placeholder?: string
showClear?: boolean
onBlur?: () => void
"aria-invalid"?: boolean
}
// ── Component ──
export function EmployeeCombobox({
value,
onValueChange,
disabled,
placeholder = "Search by name or email...",
showClear,
onBlur,
"aria-invalid": ariaInvalid,
}: EmployeeComboboxProps) {
const api = useAuthApi()
const anchorRef = useRef<HTMLDivElement>(null)
const [inputValue, setInputValue] = useState("")
const { data: options = [], isLoading } = useQuery<EmployeeOption[]>({
queryKey: [EMPLOYEE_ROUTES.INDEX, "employee-select"],
queryFn: async () => {
const res = await api.employees.list()
return extractItems(res).map(buildEmployeeOption)
},
staleTime: 5 * 60 * 1000,
})
const filtered = inputValue
? options.filter((e) =>
[e.first_name, e.last_name, e.email, e.type]
.filter(Boolean)
.join(" ")
.toLowerCase()
.includes(inputValue.toLowerCase()),
)
: options
return (
<div ref={anchorRef}>
<Combobox
value={value}
onValueChange={(val: EmployeeOption | EmployeeOption[] | null) => {
const single = Array.isArray(val) ? val[0] ?? null : val
onValueChange(single)
}}
disabled={disabled}
onInputValueChange={(val: string, { reason }: { reason: string }) => {
if (reason === "input-change") setInputValue(val)
}}
isItemEqualToValue={(item: EmployeeOption, val: any) =>
item?.value === val?.value
}
>
<ComboboxInput
placeholder={placeholder}
showClear={showClear ?? !!value}
onBlur={onBlur}
aria-invalid={ariaInvalid || undefined}
/>
<ComboboxContent anchor={anchorRef}>
<ComboboxList>
{isLoading && (
<div className="flex items-center justify-center py-6">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
)}
{!isLoading &&
filtered.map((opt) => (
<ComboboxItem key={opt.value} value={opt}>
<div className="flex items-center gap-3 py-0.5 w-full min-w-0">
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary border border-border text-sm font-medium">
{getInitials(opt)}
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm font-medium leading-none">
{opt.label}
</span>
<div className="flex items-center gap-1.5">
{opt.type && (
<Badge
variant="secondary"
className="h-4 px-1.5 text-xs font-normal capitalize"
>
{opt.type}
</Badge>
)}
{opt.status && (
<span
className={`text-xs capitalize ${opt.status === "active" ? "text-green-600 dark:text-green-400" : "text-muted-foreground"}`}
>
{opt.status}
</span>
)}
</div>
</div>
</div>
</ComboboxItem>
))}
{!isLoading && filtered.length === 0 && (
<ComboboxEmpty>No employees found</ComboboxEmpty>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
)
}

View File

@ -0,0 +1,28 @@
"use client"
import { createContext, useContext } from "react"
type EmployeeContextValue = {
id: string
label: string
}
const EmployeeContext = createContext<EmployeeContextValue | null>(null)
export function EmployeeProvider({
employee,
children,
}: {
employee: EmployeeContextValue
children: React.ReactNode
}) {
return (
<EmployeeContext.Provider value={employee}>
{children}
</EmployeeContext.Provider>
)
}
export function useEmployee() {
return useContext(EmployeeContext)
}

View File

@ -0,0 +1,159 @@
import {
User,
Mail,
Phone,
Briefcase,
Building2,
Clock,
MapPin,
Calendar,
BadgeCheck,
CircleDot,
} from "lucide-react"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Separator } from "@/shared/components/ui/separator"
type EmployeeData = {
id?: number
type?: string
first_name?: string
last_name?: string
email?: string
phone?: string
position?: string
designation?: string
salary?: string
wage_type?: string
status?: string
track_attendance?: boolean
notify_owner_when_punch_in_out?: boolean
geo_fence_radius?: string | number | null
department?: { id?: number; name?: string } | null
shop_calender?: { id?: number; title?: string } | null
shop_timing?: { id?: number; title?: string } | null
created_at?: string
updated_at?: string
}
type EmployeeGeneralInfoProps = {
employee: EmployeeData
}
function InfoItem({
icon: Icon,
label,
value,
}: {
icon: React.ComponentType<{ className?: string }>
label: string
value?: string | null
}) {
return (
<div className="flex items-start gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Icon className="size-4" />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-sm font-medium">
{value || <span className="text-muted-foreground"></span>}
</span>
</div>
</div>
)
}
export function EmployeeGeneralInfo({ employee }: EmployeeGeneralInfoProps) {
const fullName = [employee.first_name, employee.last_name].filter(Boolean).join(" ")
return (
<div className="grid gap-6 md:grid-cols-2">
{/* Personal Information */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="size-4" />
Personal Information
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{fullName || "Unknown"}</Badge>
{employee.type && (
<Badge variant="outline" className="capitalize">
{employee.type}
</Badge>
)}
{employee.status && (
<Badge
variant={employee.status === "active" ? "default" : "secondary"}
className="capitalize"
>
{employee.status}
</Badge>
)}
</div>
<Separator />
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem icon={Mail} label="Email" value={employee.email} />
<InfoItem icon={Phone} label="Phone" value={employee.phone} />
<InfoItem icon={Briefcase} label="Position" value={employee.position} />
<InfoItem icon={BadgeCheck} label="Designation" value={employee.designation} />
</div>
</CardContent>
</Card>
{/* Work Details */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="size-4" />
Work Details
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem
icon={Building2}
label="Department"
value={employee.department?.name}
/>
<InfoItem
icon={Calendar}
label="Shop Calendar"
value={employee.shop_calender?.title}
/>
<InfoItem
icon={Clock}
label="Shop Timing"
value={employee.shop_timing?.title}
/>
<InfoItem
icon={MapPin}
label="Geo Fence Radius"
value={employee.geo_fence_radius != null ? String(employee.geo_fence_radius) : null}
/>
</div>
<Separator />
<div className="flex flex-wrap gap-2">
<Badge variant={employee.track_attendance ? "default" : "secondary"}>
<CircleDot className="mr-1 size-3" />
{employee.track_attendance ? "Attendance Tracked" : "No Attendance Tracking"}
</Badge>
{employee.notify_owner_when_punch_in_out && (
<Badge variant="outline">
Punch In/Out Notifications
</Badge>
)}
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,292 @@
"use client"
import { useState } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useAuthApi } from "@/shared/useApi"
import { EMPLOYEE_ROUTES } from "@garage/api"
import { Button } from "@/shared/components/ui/button"
import { Checkbox } from "@/shared/components/ui/checkbox"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { toast } from "sonner"
import { Save, ShieldCheck } from "lucide-react"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
// ── Permission resource groups ──
const PERMISSION_GROUPS: { label: string; key: string }[] = [
{ label: "Appointments", key: "appointments" },
{ label: "Bills", key: "bills" },
{ label: "Check Point Labels", key: "check_point_labels" },
{ label: "Credit Notes", key: "credit_notes" },
{ label: "Customers", key: "customers" },
{ label: "Departments", key: "departments" },
{ label: "Document Types", key: "document_types" },
{ label: "Employees", key: "employees" },
{ label: "Estimates", key: "estimates" },
{ label: "Expense Items", key: "expense_items" },
{ label: "Expenses", key: "expenses" },
{ label: "Holidays", key: "holidays" },
{ label: "Inspection Categories", key: "inspection_categories" },
{ label: "Inspection Check Points", key: "inspection_check_points" },
{ label: "Inspections", key: "inspections" },
{ label: "Insurance Types", key: "insurance_types" },
{ label: "Inventory Adjustments", key: "inventory_adjustments" },
{ label: "Inventory Categories", key: "inventory_categories" },
{ label: "Invoice Documents", key: "invoice_documents" },
{ label: "Invoice Labels", key: "invoice_labels" },
{ label: "Invoice Notes", key: "invoice_notes" },
{ label: "Invoice Sequences", key: "invoice_sequences" },
{ label: "Invoices", key: "invoices" },
{ label: "Job Cards", key: "job_cards" },
{ label: "Labels", key: "labels" },
{ label: "Labor Rates", key: "labor_rates" },
{ label: "Make & Models", key: "make_and_models" },
{ label: "Parts", key: "parts" },
{ label: "Payment Mades", key: "payment_mades" },
{ label: "Payment Modes", key: "payment_modes" },
{ label: "Payment Received", key: "payment_recieveds" },
{ label: "Payment Terms", key: "payment_terms" },
{ label: "Purchase Orders", key: "purchase_orders" },
{ label: "Quick Notes", key: "quick_notes" },
{ label: "Quick Remarks", key: "quick_remarks" },
{ label: "Reasons", key: "reasons" },
{ label: "Referral Sources", key: "referral_sources" },
{ label: "Service Group Includes", key: "service_group_includes" },
{ label: "Service Group Parts", key: "service_group_parts" },
{ label: "Service Group Pricings", key: "service_group_pricings" },
{ label: "Service Group Services", key: "service_group_services" },
{ label: "Service Groups", key: "service_groups" },
{ label: "Services", key: "services" },
{ label: "Settings", key: "settings" },
{ label: "Shop Calendars", key: "shop_calenders" },
{ label: "Shop Timings", key: "shop_timings" },
{ label: "Shop Types", key: "shop_types" },
{ label: "Task Sections", key: "task_sections" },
{ label: "Task Types", key: "task_types" },
{ label: "Tasks", key: "tasks" },
{ label: "Taxes", key: "taxes" },
{ label: "Time Sheets", key: "time_sheets" },
{ label: "Unit Types", key: "unit_types" },
{ label: "Vehicle Body Types", key: "vehicle_body_types" },
{ label: "Vehicle Colors", key: "vehicle_colors" },
{ label: "Vehicle Documents", key: "vehicle_documents" },
{ label: "Vehicle Fuel Types", key: "vehicle_fuel_types" },
{ label: "Vehicle Mile & Kms", key: "vehicle_mile_and_kms" },
{ label: "Vehicle Transmissions", key: "vehicle_transmissions" },
{ label: "Vehicles", key: "vehicles" },
{ label: "Vendor Credits", key: "vendor_credits" },
{ label: "Vendors", key: "vendors" },
]
const ACTIONS = ["view", "create", "update", "delete"] as const
type PermissionAction = typeof ACTIONS[number]
type PermissionsState = Record<string, boolean>
function buildPermissionsPayload(state: PermissionsState) {
return state
}
function extractPermissions(data: Record<string, unknown> | null | undefined): PermissionsState {
if (!data) return {}
const result: PermissionsState = {}
for (const group of PERMISSION_GROUPS) {
for (const action of ACTIONS) {
const key = `can_${action}_${group.key}`
result[key] = Boolean(data[key])
}
}
return result
}
type EmployeePermissionsFormProps = {
employeeId: string
}
export function EmployeePermissionsForm({ employeeId }: EmployeePermissionsFormProps) {
const api = useAuthApi()
const queryClient = useQueryClient()
const queryKey = [EMPLOYEE_ROUTES.BY_ID, employeeId]
const { data, isLoading } = useQuery({
queryKey,
queryFn: () => api.employees.getById(employeeId),
})
const employeeData = (data as any)?.data as Record<string, unknown> | undefined
const [permissions, setPermissions] = useState<PermissionsState | null>(null)
const currentPermissions = permissions ?? extractPermissions(employeeData)
const { mutate, isPending } = useMutation({
mutationFn: (payload: PermissionsState) =>
api.employees.updatePermissions(employeeId, payload as never),
onSuccess: () => {
toast.success("Permissions updated successfully")
queryClient.invalidateQueries({ queryKey })
},
onError: () => {
toast.error("Failed to update permissions")
},
})
const handleToggle = (key: string, value: boolean) => {
setPermissions((prev) => ({
...(prev ?? extractPermissions(employeeData)),
[key]: value,
}))
}
const handleRowToggle = (groupKey: string, checked: boolean) => {
const updates: PermissionsState = {}
for (const action of ACTIONS) {
updates[`can_${action}_${groupKey}`] = checked
}
setPermissions((prev) => ({
...(prev ?? extractPermissions(employeeData)),
...updates,
}))
}
const handleColumnToggle = (action: PermissionAction, checked: boolean) => {
const updates: PermissionsState = {}
for (const group of PERMISSION_GROUPS) {
updates[`can_${action}_${group.key}`] = checked
}
setPermissions((prev) => ({
...(prev ?? extractPermissions(employeeData)),
...updates,
}))
}
const isRowChecked = (groupKey: string) =>
ACTIONS.every((action) => currentPermissions[`can_${action}_${groupKey}`])
const isRowIndeterminate = (groupKey: string) => {
const values = ACTIONS.map((action) => currentPermissions[`can_${action}_${groupKey}`])
return values.some(Boolean) && !values.every(Boolean)
}
const isColumnChecked = (action: PermissionAction) =>
PERMISSION_GROUPS.every((g) => currentPermissions[`can_${action}_${g.key}`])
const isColumnIndeterminate = (action: PermissionAction) => {
const values = PERMISSION_GROUPS.map((g) => currentPermissions[`can_${action}_${g.key}`])
return values.some(Boolean) && !values.every(Boolean)
}
const isAllChecked = PERMISSION_GROUPS.every((g) =>
ACTIONS.every((action) => currentPermissions[`can_${action}_${g.key}`])
)
const isAllIndeterminate = (() => {
const values = PERMISSION_GROUPS.flatMap((g) =>
ACTIONS.map((action) => currentPermissions[`can_${action}_${g.key}`])
)
return values.some(Boolean) && !values.every(Boolean)
})()
const handleToggleAll = (checked: boolean) => {
const updates: PermissionsState = {}
for (const group of PERMISSION_GROUPS) {
for (const action of ACTIONS) {
updates[`can_${action}_${group.key}`] = checked
}
}
setPermissions(updates)
}
if (isLoading) {
return (
<DashboardPage header={null}>
<div className="text-muted-foreground text-sm">Loading permissions...</div>
</DashboardPage>
)
}
return (
<DashboardPage header={null}>
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-4">
<CardTitle className="flex items-center gap-2">
<ShieldCheck className="size-4" />
Permissions
</CardTitle>
<Button
size="sm"
onClick={() => mutate(buildPermissionsPayload(currentPermissions))}
disabled={isPending}
>
<Save className="size-4" />
Save Permissions
</Button>
</CardHeader>
<CardContent className="overflow-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="py-2 pr-4 text-left font-medium">
<div className="flex items-center gap-2">
<Checkbox
className="border-primary"
checked={isAllChecked || (isAllIndeterminate ? "indeterminate" : false)}
onCheckedChange={(v) => handleToggleAll(Boolean(v))}
/>
Resource
</div>
</th>
{ACTIONS.map((action) => (
<th key={action} className="py-2 px-4 text-center font-medium capitalize">
<div className="flex flex-col items-center gap-1">
<Checkbox
className="border border-primary"
checked={isColumnChecked(action) || (isColumnIndeterminate(action) ? "indeterminate" : false)}
onCheckedChange={(v) => handleColumnToggle(action, Boolean(v))}
/>
<Badge variant="outline" className="capitalize text-xs">
{action}
</Badge>
</div>
</th>
))}
</tr>
</thead>
<tbody>
{PERMISSION_GROUPS.map((group) => (
<tr key={group.key} className="border-b last:border-0 hover:bg-muted/30 transition-colors">
<td className="py-2 pr-4">
<div className="flex items-center gap-2">
<Checkbox
className="border border-primary"
checked={isRowChecked(group.key) || (isRowIndeterminate(group.key) ? "indeterminate" : false)}
onCheckedChange={(v) => handleRowToggle(group.key, Boolean(v))}
/>
<span className="text-sm">{group.label}</span>
</div>
</td>
{ACTIONS.map((action) => {
const key = `can_${action}_${group.key}`
return (
<td key={action} className="py-2 px-4 text-center ">
<Checkbox
className="mx-auto border border-primary"
checked={Boolean(currentPermissions[key])}
onCheckedChange={(v) => handleToggle(key, Boolean(v))}
/>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
</DashboardPage>
)
}

View File

@ -0,0 +1,60 @@
"use client"
import { useFormContext, useController, type FieldValues, type FieldPath } from "react-hook-form"
import { FieldShell } from "@/shared/components/form/field-shell"
import { EmployeeCombobox } from "./employee-combobox"
// ── Props ──
export type RhfEmployeeSelectFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
> = {
name: TName
label?: string
description?: string
required?: boolean
disabled?: boolean
placeholder?: string
}
// ── Component ──
export function RhfEmployeeSelectField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
>({
name,
label = "Employee",
description,
required,
disabled,
placeholder = "Search by name or email...",
}: RhfEmployeeSelectFieldProps<TValues, TName>) {
const { control } = useFormContext<TValues>()
const {
field,
fieldState: { error },
} = useController({ name, control, disabled })
return (
<FieldShell
label={label}
error={error?.message}
description={description}
required={required}
>
<EmployeeCombobox
value={field.value}
onValueChange={(emp) => {
field.onChange(emp ? { value: emp.value, label: emp.label } : null)
}}
disabled={field.disabled}
placeholder={placeholder}
showClear={!!field.value}
onBlur={field.onBlur}
aria-invalid={!!error || undefined}
/>
</FieldShell>
)
}

View File

@ -0,0 +1,75 @@
"use client"
import { useAuthApi } from "@/shared/useApi"
import { useRouter } from "next/navigation"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Ellipsis, Pencil, Trash2 } from "lucide-react"
import { useState } from "react"
import { EstimateForm } from "./estimate-form"
type EstimateActionsProps = {
estimateId: string
}
export function EstimateActions({ estimateId }: EstimateActionsProps) {
const api = useAuthApi()
const router = useRouter()
const [editOpen, setEditOpen] = useState(false)
const handleDelete = async () => {
await api.estimates.destroy(estimateId)
router.push("/sales/estimates")
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setEditOpen(true)}>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="min-w-xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">Edit Estimate</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[80vh] px-4">
<EstimateForm
resourceId={estimateId}
onSuccess={() => {
setEditOpen(false)
router.refresh()
}}
/>
</ScrollArea>
</DialogContent>
</Dialog>
</>
)
}

View File

@ -0,0 +1,28 @@
"use client"
import { createContext, useContext } from "react"
type EstimateContextValue = {
id: string
label: string
}
const EstimateContext = createContext<EstimateContextValue | null>(null)
export function EstimateProvider({
estimate,
children,
}: {
estimate: EstimateContextValue
children: React.ReactNode
}) {
return (
<EstimateContext.Provider value={estimate}>
{children}
</EstimateContext.Provider>
)
}
export function useEstimate() {
return useContext(EstimateContext)
}

View File

@ -23,7 +23,9 @@ import {
estimateFormSchema, estimateFormSchema,
type EstimateFormValues, type EstimateFormValues,
} from "./estimate.schema" } from "./estimate.schema"
import { ESTIMATE_ROUTES, CUSTOMER_ROUTES, VEHICLE_ROUTES, DEPARTMENT_ROUTES, LABEL_ROUTES } from "@garage/api" import { ESTIMATE_ROUTES, DEPARTMENT_ROUTES, LABEL_ROUTES } from "@garage/api"
import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field"
import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
// ── Props ── // ── Props ──
@ -91,16 +93,6 @@ const mapLookupOption = (item: any) => ({
label: item.name, label: item.name,
}) })
const mapCustomerOption = (item: any) => ({
value: String(item.id),
label: [item.first_name, item.last_name].filter(Boolean).join(" "),
})
const mapVehicleOption = (item: any) => ({
value: String(item.id),
label: [item.year, item.make_name, item.model_name].filter(Boolean).join(" "),
})
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label } const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
// ── Component ── // ── Component ──
@ -157,24 +149,8 @@ export function EstimateForm({ resourceId, initialData, onSuccess }: EstimateFor
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField <RhfCustomerSelectField name="customer" />
name="customer" <RhfVehicleSelectField name="vehicle" />
label="Customer"
placeholder="Select customer"
queryKey={[CUSTOMER_ROUTES.INDEX]}
listFn={() => api.customers.list()}
mapOption={mapCustomerOption}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="vehicle"
label="Vehicle"
placeholder="Select vehicle"
queryKey={[VEHICLE_ROUTES.INDEX]}
listFn={() => api.vehicles.list()}
mapOption={mapVehicleOption}
{...STORE_OBJECT}
/>
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">

View File

@ -0,0 +1,197 @@
import {
FileText,
Hash,
Calendar,
User,
Car,
Building2,
Shield,
Tag,
MessageSquare,
} from "lucide-react"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
type EstimateData = {
id?: number
title?: string
estimate_number?: string
date?: string
customer_id?: number
vehicle_id?: number
department_id?: number
has_insurance?: boolean
enable_digital_authorisation?: boolean
insurance_type_id?: string | number | null
insurer_id?: string | number | null
service_writer_id?: number
footer?: string | null
created_at?: string
updated_at?: string
labels?: {
id?: number
title?: string
color_code?: string
}[]
customer_remarks?: {
id?: number
remark?: string
created_at?: string
}[]
// Joined fields that may come from the API
customer_name?: string
vehicle_name?: string
department_name?: string
}
type EstimateGeneralInfoProps = {
estimate: EstimateData
}
function InfoItem({
icon: Icon,
label,
value,
}: {
icon: React.ComponentType<{ className?: string }>
label: string
value?: string | null
}) {
return (
<div className="flex items-start gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Icon className="size-4" />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-sm font-medium">
{value || <span className="text-muted-foreground"></span>}
</span>
</div>
</div>
)
}
export function EstimateGeneralInfo({ estimate }: EstimateGeneralInfoProps) {
return (
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="size-4" /> Estimate Details
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem icon={FileText} label="Title" value={estimate.title} />
<InfoItem icon={Hash} label="Estimate #" value={estimate.estimate_number} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem icon={Calendar} label="Date" value={estimate.date} />
<InfoItem
icon={Shield}
label="Insurance"
value={estimate.has_insurance ? "Yes" : "No"}
/>
</div>
<InfoItem
icon={Calendar}
label="Created"
value={
estimate.created_at
? new Date(estimate.created_at).toLocaleDateString()
: null
}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="size-4" /> Related Records
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<InfoItem
icon={User}
label="Customer"
value={estimate.customer_name || (estimate.customer_id ? `#${estimate.customer_id}` : null)}
/>
<InfoItem
icon={Car}
label="Vehicle"
value={estimate.vehicle_name || (estimate.vehicle_id ? `#${estimate.vehicle_id}` : null)}
/>
<InfoItem
icon={Building2}
label="Department"
value={estimate.department_name || (estimate.department_id ? `#${estimate.department_id}` : null)}
/>
</CardContent>
</Card>
{estimate.labels && estimate.labels.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Tag className="size-4" /> Labels
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{estimate.labels.map((label) => (
<Badge
key={label.id}
style={label.color_code ? { backgroundColor: label.color_code } : undefined}
>
{label.title}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{estimate.customer_remarks && estimate.customer_remarks.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageSquare className="size-4" /> Customer Remarks
</CardTitle>
</CardHeader>
<CardContent className="grid gap-3">
{estimate.customer_remarks.map((remark) => (
<div key={remark.id} className="rounded-md border p-3 text-sm">
<p>{remark.remark}</p>
{remark.created_at && (
<p className="mt-1 text-xs text-muted-foreground">
{new Date(remark.created_at).toLocaleDateString()}
</p>
)}
</div>
))}
</CardContent>
</Card>
)}
{estimate.footer && (
<Card className="md:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="size-4" /> Footer
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm">{estimate.footer}</p>
</CardContent>
</Card>
)}
</div>
)
}

View File

@ -0,0 +1,88 @@
"use client"
import { useAuthApi } from "@/shared/useApi"
import { useRouter } from "next/navigation"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { Ellipsis, Pencil, Trash2, Play, CheckCircle2 } from "lucide-react"
import { toast } from "sonner"
type InspectionActionsProps = {
inspectionId: string
status?: string
onStatusChange?: () => void
}
const STATUS_TRANSITIONS: Record<string, { next: string; label: string; icon: typeof Play }> = {
open: { next: "in_progress", label: "Start Inspection", icon: Play },
in_progress: { next: "completed", label: "Mark Completed", icon: CheckCircle2 },
}
export function InspectionActions({ inspectionId, status, onStatusChange }: InspectionActionsProps) {
const api = useAuthApi()
const router = useRouter()
const handleEdit = () => {
router.push(`/sales/inspections/${inspectionId}/edit`)
}
const handleDelete = async () => {
const promise = api.inspections.destroy(inspectionId)
toast.promise(promise, {
loading: "Deleting inspection...",
success: "Inspection deleted successfully",
error: "Failed to delete inspection",
})
await promise
router.push("/sales/inspections")
}
const handleStatusChange = async (newStatus: string) => {
const promise = api.inspections.changeStatus({
status: newStatus,
} as never)
toast.promise(promise, {
loading: "Updating status...",
success: "Status updated successfully",
error: "Failed to update status",
})
await promise
onStatusChange?.()
router.refresh()
}
const transition = status ? STATUS_TRANSITIONS[status] : undefined
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleEdit}>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
{transition && (
<DropdownMenuItem onClick={() => handleStatusChange(transition.next)}>
<transition.icon className="size-4" />
{transition.label}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -0,0 +1,28 @@
"use client"
import { createContext, useContext } from "react"
type InspectionContextValue = {
id: string
label: string
}
const InspectionContext = createContext<InspectionContextValue | null>(null)
export function InspectionProvider({
inspection,
children,
}: {
inspection: InspectionContextValue
children: React.ReactNode
}) {
return (
<InspectionContext.Provider value={inspection}>
{children}
</InspectionContext.Provider>
)
}
export function useInspection() {
return useContext(InspectionContext)
}

View File

@ -24,11 +24,11 @@ import {
} from "./inspection.schema" } from "./inspection.schema"
import { import {
INSPECTION_ROUTES, INSPECTION_ROUTES,
CUSTOMER_ROUTES,
VEHICLE_ROUTES,
DEPARTMENT_ROUTES, DEPARTMENT_ROUTES,
EMPLOYEE_ROUTES,
} from "@garage/api" } from "@garage/api"
import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field"
import { RhfEmployeeSelectField } from "@/modules/employees/rhf-employee-select-field"
// ── Props ── // ── Props ──
@ -91,21 +91,6 @@ const mapLookupOption = (item: any) => ({
label: item.name ?? item.title ?? String(item.id), label: item.name ?? item.title ?? String(item.id),
}) })
const mapCustomerOption = (item: any) => ({
value: String(item.id),
label: `${item.first_name ?? ""} ${item.last_name ?? ""}`.trim() || String(item.id),
})
const mapVehicleOption = (item: any) => ({
value: String(item.id),
label: `${item.make ?? ""} ${item.model ?? ""} ${item.year ?? ""}`.trim() || String(item.id),
})
const mapEmployeeOption = (item: any) => ({
value: String(item.id),
label: `${item.first_name ?? ""} ${item.last_name ?? ""}`.trim() || String(item.id),
})
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label } const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
// ── Component ── // ── Component ──
@ -157,24 +142,8 @@ export function InspectionForm({ resourceId, initialData, onSuccess }: Inspectio
<RhfTextField name="title" label="Title" placeholder="e.g. Pre-purchase" required /> <RhfTextField name="title" label="Title" placeholder="e.g. Pre-purchase" required />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField <RhfCustomerSelectField name="customer" />
name="customer" <RhfVehicleSelectField name="vehicle" />
label="Customer"
placeholder="Select customer"
queryKey={[CUSTOMER_ROUTES.INDEX]}
listFn={() => api.customers.list()}
mapOption={mapCustomerOption}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="vehicle"
label="Vehicle"
placeholder="Select vehicle"
queryKey={[VEHICLE_ROUTES.INDEX]}
listFn={() => api.vehicles.list()}
mapOption={mapVehicleOption}
{...STORE_OBJECT}
/>
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
@ -202,21 +171,13 @@ export function InspectionForm({ resourceId, initialData, onSuccess }: Inspectio
/> />
</div> </div>
<RhfAsyncSelectField <RhfEmployeeSelectField name="employee" />
name="employee"
label="Employee"
placeholder="Select employee"
queryKey={[EMPLOYEE_ROUTES.INDEX]}
listFn={() => api.employees.list()}
mapOption={mapEmployeeOption}
{...STORE_OBJECT}
/>
<RhfTextField name="order_number" label="Order Number" placeholder="e.g. ORD-001" /> <RhfTextField name="order_number" label="Order Number" placeholder="e.g. ORD-001" />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="date" label="Date" placeholder="YYYY-MM-DD" type="date" /> <RhfTextField name="date" label="Date" placeholder="YYYY-MM-DD" type="date" />
<RhfTextField name="time" label="Time" placeholder="HH:MM:SS" type="time" /> <RhfTextField name="time" label="Time" placeholder="HH:MM:SS" type="time" step={1} />
</div> </div>
<Button type="submit" variant="default" disabled={isPending}> <Button type="submit" variant="default" disabled={isPending}>

View File

@ -0,0 +1,215 @@
import {
ClipboardCheck,
Calendar,
Clock,
Hash,
FileText,
User,
Car,
Building2,
FolderOpen,
} from "lucide-react"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Separator } from "@/shared/components/ui/separator"
type InspectionData = {
id?: number
title?: string
customer_id?: number
vehicle_id?: number
department_id?: number
inspection_category_id?: number
employee_id?: number
order_number?: string
date?: string
time?: string
note?: string
status?: string
created_at?: string
updated_at?: string
customer?: { id?: number; first_name?: string; last_name?: string }
vehicle?: { id?: number; make?: string; model?: string; license_plate?: string }
department?: { id?: number; name?: string }
inspection_category?: { id?: number; name?: string }
employee?: { id?: number; first_name?: string; last_name?: string }
}
type InspectionGeneralInfoProps = {
inspection: InspectionData
}
function InfoItem({
icon: Icon,
label,
value,
}: {
icon: React.ComponentType<{ className?: string }>
label: string
value?: string | null
}) {
return (
<div className="flex items-start gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Icon className="size-4" />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-sm font-medium">
{value || <span className="text-muted-foreground"></span>}
</span>
</div>
</div>
)
}
const STATUS_COLORS: Record<string, string> = {
open: "bg-blue-100 text-blue-800",
in_progress: "bg-yellow-100 text-yellow-800",
completed: "bg-green-100 text-green-800",
}
function formatStatus(status?: string) {
if (!status) return "—"
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
}
export function InspectionGeneralInfo({ inspection }: InspectionGeneralInfoProps) {
const customerName = inspection.customer
? `${inspection.customer.first_name ?? ""} ${inspection.customer.last_name ?? ""}`.trim()
: undefined
const vehicleName = inspection.vehicle
? `${inspection.vehicle.make ?? ""} ${inspection.vehicle.model ?? ""}`.trim()
: undefined
const employeeName = inspection.employee
? `${inspection.employee.first_name ?? ""} ${inspection.employee.last_name ?? ""}`.trim()
: undefined
return (
<div className="grid gap-6 md:grid-cols-2">
{/* Inspection Details */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ClipboardCheck className="size-4" />
Inspection Details
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="flex flex-wrap items-center gap-2">
{inspection.title && (
<Badge variant="secondary">{inspection.title}</Badge>
)}
{inspection.status && (
<Badge className={STATUS_COLORS[inspection.status] || ""}>
{formatStatus(inspection.status)}
</Badge>
)}
</div>
<Separator />
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem
icon={Hash}
label="Order Number"
value={inspection.order_number}
/>
<InfoItem
icon={FolderOpen}
label="Category"
value={inspection.inspection_category?.name}
/>
<InfoItem
icon={Calendar}
label="Date"
value={inspection.date ? new Date(inspection.date).toLocaleDateString() : null}
/>
<InfoItem
icon={Clock}
label="Time"
value={inspection.time}
/>
</div>
</CardContent>
</Card>
{/* Assignments */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="size-4" />
Assignments
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<InfoItem
icon={User}
label="Customer"
value={customerName}
/>
<InfoItem
icon={Car}
label="Vehicle"
value={vehicleName}
/>
<InfoItem
icon={User}
label="Employee"
value={employeeName}
/>
<InfoItem
icon={Building2}
label="Department"
value={inspection.department?.name}
/>
</CardContent>
</Card>
{/* Notes & Timestamps */}
{(inspection.note || inspection.created_at) && (
<Card className="md:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="size-4" />
Notes & Timestamps
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem
icon={Calendar}
label="Created At"
value={inspection.created_at ? new Date(inspection.created_at).toLocaleString() : null}
/>
<InfoItem
icon={Calendar}
label="Updated At"
value={inspection.updated_at ? new Date(inspection.updated_at).toLocaleString() : null}
/>
</div>
{inspection.note && (
<>
<Separator />
<div className="flex items-start gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<FileText className="size-4" />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">Note</span>
<p className="text-sm whitespace-pre-wrap">{inspection.note}</p>
</div>
</div>
</>
)}
</CardContent>
</Card>
)}
</div>
)
}

View File

@ -0,0 +1,227 @@
"use client"
import { AlertTriangle, Plus, Save, Trash2 } from "lucide-react"
import { useFieldArray } from "react-hook-form"
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 { 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 {
inventoryAdjustmentFormSchema,
type InventoryAdjustmentFormValues,
} from "./inventory-adjustment.schema"
import { INVENTORY_ADJUSTMENT_ROUTES, REASON_ROUTES, PARTS_ROUTES } from "@garage/api"
// ── Props ──
export type InventoryAdjustmentFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
// ── Default values ──
const DEFAULT_VALUES: InventoryAdjustmentFormValues = {
reference_number: "",
date: "",
chart_of_account: "",
reason: null,
notes: "",
parts: [{ part: null, quantity: 1, rate: 0 }],
}
// ── Mapping helpers ──
function mapToFormValues(data: unknown): InventoryAdjustmentFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
reference_number: d.reference_number || "",
date: d.date || "",
chart_of_account: d.chart_of_account || "",
reason: toRelation(d.reason_id, d.reason_name),
notes: d.notes || "",
parts: Array.isArray(d.parts) && d.parts.length > 0
? d.parts.map((p: any) => ({
part: toRelation(p.part_id, p.part_name),
quantity: p.quantity ?? 1,
rate: p.rate ?? 0,
}))
: [{ part: null, quantity: 1, rate: 0 }],
}
}
function mapFormToPayload(values: InventoryAdjustmentFormValues) {
return {
reference_number: values.reference_number || undefined,
date: values.date || undefined,
chart_of_account: values.chart_of_account || undefined,
reason_id: toId(values.reason) ? Number(toId(values.reason)) : undefined,
notes: values.notes || undefined,
parts: values.parts.map((p) => ({
part_id: toId(p.part) ? Number(toId(p.part)) : undefined,
quantity: p.quantity,
rate: p.rate,
})),
}
}
const mapLookupOption = (item: any) => ({ value: String(item.id), label: item.name ?? item.title })
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
// ── Component ──
export function InventoryAdjustmentForm({ resourceId, initialData, onSuccess }: InventoryAdjustmentFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<InventoryAdjustmentFormValues, any>({
schema: inventoryAdjustmentFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
mapToFormValues,
})
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "parts",
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: InventoryAdjustmentFormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing && resourceId
? api.inventoryAdjustments.update(resourceId, payload as never)
: api.inventoryAdjustments.create(payload as never)
return promise
},
onSuccess: () => {
toast.success(isEditing ? "Adjustment updated." : "Adjustment created.")
onSuccess?.()
},
})
return (
<Rhform form={form} onSubmit={mutate} className="flex flex-col gap-4">
{error && (
<Alert variant="destructive">
<AlertTriangle className="size-4" />
<AlertTitle>
{isEditing ? "Failed to update adjustment" : "Failed to create adjustment"}
</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="reference_number"
label="Reference Number"
placeholder="ADJ-001"
/>
<RhfTextField
name="date"
label="Date"
type="date"
/>
<RhfTextField
name="chart_of_account"
label="Chart of Account"
placeholder="Account name"
/>
<RhfAsyncSelectField
name="reason"
label="Reason"
placeholder="Select reason..."
queryKey={[REASON_ROUTES.INDEX]}
listFn={() => api.reasons.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
</div>
<RhfTextareaField
name="notes"
label="Notes"
placeholder="Additional notes..."
rows={3}
/>
</FieldGroup>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Parts</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ part: null, quantity: 1, rate: 0 })}
>
<Plus className="size-4" />
Add Part
</Button>
</div>
{fields.length === 0 && (
<p className="text-sm text-muted-foreground">No parts added. Click "Add Part" to begin.</p>
)}
{fields.map((field, index) => (
<div key={field.id} className="grid grid-cols-[1fr_auto_auto_auto] items-end gap-2 rounded-lg border p-3">
<RhfAsyncSelectField
name={`parts.${index}.part`}
label="Part"
placeholder="Select part..."
queryKey={[PARTS_ROUTES.INDEX]}
listFn={() => api.parts.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
<RhfTextField
name={`parts.${index}.quantity`}
label="Qty"
type="number"
placeholder="1"
/>
<RhfTextField
name={`parts.${index}.rate`}
label="Rate"
type="number"
placeholder="0"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="mb-0.5"
onClick={() => remove(index)}
disabled={fields.length === 1}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
))}
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isPending}>
<Save className="size-4" />
{isPending ? "Saving..." : isEditing ? "Update Adjustment" : "Create Adjustment"}
</Button>
</div>
</Rhform>
)
}

View File

@ -0,0 +1,23 @@
import { z } from "zod"
export const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
const partLineSchema = z.object({
part: relationFieldSchema,
quantity: z.coerce.number().min(1, "Quantity must be at least 1"),
rate: z.coerce.number().min(0, "Rate must be 0 or more"),
})
export const inventoryAdjustmentFormSchema = z.object({
reference_number: z.string().optional(),
date: z.string().optional(),
chart_of_account: z.string().optional(),
reason: relationFieldSchema,
notes: z.string().optional(),
parts: z.array(partLineSchema).min(1, "At least one part is required"),
})
export type InventoryAdjustmentFormValues = z.infer<typeof inventoryAdjustmentFormSchema>
export type PartLineValues = z.infer<typeof partLineSchema>

View File

@ -22,7 +22,9 @@ import {
invoiceFormSchema, invoiceFormSchema,
type InvoiceFormValues, type InvoiceFormValues,
} from "./invoice.schema" } from "./invoice.schema"
import { INVOICE_ROUTES, CUSTOMER_ROUTES, VEHICLE_ROUTES, DEPARTMENT_ROUTES } from "@garage/api" import { INVOICE_ROUTES, DEPARTMENT_ROUTES } from "@garage/api"
import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field"
import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
// ── Constants ── // ── Constants ──
@ -95,16 +97,6 @@ const mapLookupOption = (item: any) => ({
label: item.name, label: item.name,
}) })
const mapCustomerOption = (item: any) => ({
value: String(item.id),
label: [item.first_name, item.last_name].filter(Boolean).join(" "),
})
const mapVehicleOption = (item: any) => ({
value: String(item.id),
label: [item.year, item.make_name, item.model_name].filter(Boolean).join(" "),
})
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label } const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
// ── Component ── // ── Component ──
@ -171,25 +163,8 @@ export function InvoiceForm({ resourceId, initialData, onSuccess }: InvoiceFormP
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField <RhfCustomerSelectField name="customer" />
name="customer" <RhfVehicleSelectField name="vehicle" />
label="Customer"
placeholder="Select customer"
queryKey={[CUSTOMER_ROUTES.INDEX]}
listFn={() => api.customers.list()}
mapOption={mapCustomerOption}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="vehicle"
label="Vehicle"
placeholder="Select vehicle"
queryKey={[VEHICLE_ROUTES.INDEX]}
listFn={() => api.vehicles.list()}
mapOption={mapVehicleOption}
{...STORE_OBJECT}
/>
</div> </div>
<RhfAsyncSelectField <RhfAsyncSelectField

View File

@ -1,17 +1,9 @@
"use client" "use client"
import { useState, useRef } from "react" import { useState } from "react"
import { useMutation, useQuery } from "@tanstack/react-query" import { useMutation } from "@tanstack/react-query"
import { useAuthApi } from "@/shared/useApi" import { useAuthApi } from "@/shared/useApi"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -19,39 +11,55 @@ import {
DialogTitle, DialogTitle,
DialogDescription, DialogDescription,
} from "@/shared/components/ui/dialog" } from "@/shared/components/ui/dialog"
import {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxEmpty,
} from "@/shared/components/ui/combobox"
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/shared/components/ui/popover" } from "@/shared/components/ui/popover"
import { Calendar } from "@/shared/components/ui/calendar" import { Calendar } from "@/shared/components/ui/calendar"
import { confirm } from "@/shared/components/confirm-dialog"
import { toast } from "sonner" import { toast } from "sonner"
import { Ellipsis, Pencil, Trash2, CalendarIcon, UserCog, UserCheck, Loader2 } from "lucide-react" import { Pencil, CalendarIcon, UserCog, UserCheck, Loader2 } from "lucide-react"
import { format } from "date-fns" import { format } from "date-fns"
import { EMPLOYEE_ROUTES } from "@garage/api" import { EmployeeCombobox } from "@/modules/employees/employee-combobox"
type JobCardActionsProps = { type JobCardActionsProps = {
jobCardId: string jobCardId: string
orderDate?: string | null
serviceWriterName?: string | null
salesPersonName?: string | null
} }
type Employee = { // ── Informative Action Card ──
id: number
first_name?: string
last_name?: string
name?: string
}
function getEmployeeName(emp: Employee) { function ActionCard({
return emp.name || [emp.first_name, emp.last_name].filter(Boolean).join(" ") || `Employee #${emp.id}` icon: Icon,
label,
value,
isPending,
}: {
icon: React.ComponentType<{ className?: string }>
label: string
value?: string | null
isPending?: boolean
}) {
return (
<div className="group relative flex items-center gap-3 rounded-lg border bg-card px-4 py-3 text-left transition-colors hover:bg-accent hover:text-accent-foreground cursor-pointer select-none">
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground transition-colors group-hover:bg-background">
<Icon className="size-4" />
</div>
<div className="flex flex-col gap-0.5 overflow-hidden">
<span className="text-[11px] font-medium text-muted-foreground leading-none">{label}</span>
<span className="text-sm font-semibold leading-tight truncate">
{value ?? <span className="font-normal text-muted-foreground italic">Not set</span>}
</span>
</div>
{isPending ? (
<Loader2 className="ml-2 size-3.5 shrink-0 text-muted-foreground animate-spin" />
) : (
<Pencil className="ml-2 size-3 shrink-0 text-muted-foreground opacity-0 group-hover:opacity-60 transition-opacity" />
)}
</div>
)
} }
// ── Employee Picker Dialog ── // ── Employee Picker Dialog ──
@ -61,8 +69,6 @@ type EmployeePickerDialogProps = {
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
title: string title: string
description: string description: string
employees: Employee[]
loading: boolean
isPending: boolean isPending: boolean
onSelect: (employeeId: number) => void onSelect: (employeeId: number) => void
} }
@ -72,18 +78,9 @@ function EmployeePickerDialog({
onOpenChange, onOpenChange,
title, title,
description, description,
employees,
loading,
isPending, isPending,
onSelect, onSelect,
}: EmployeePickerDialogProps) { }: EmployeePickerDialogProps) {
const anchorRef = useRef<HTMLDivElement>(null)
const handleSelect = (emp: Employee | null) => {
if (!emp) return
onSelect(emp.id)
}
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
@ -91,36 +88,15 @@ function EmployeePickerDialog({
<DialogTitle>{title}</DialogTitle> <DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription> <DialogDescription>{description}</DialogDescription>
</DialogHeader> </DialogHeader>
<div ref={anchorRef}> <EmployeeCombobox
<Combobox
value={null} value={null}
onValueChange={handleSelect} onValueChange={(emp) => {
if (emp) onSelect(Number(emp.value))
}}
disabled={isPending} disabled={isPending}
>
<ComboboxInput
placeholder="Search employees..." placeholder="Search employees..."
showClear={false} showClear={false}
/> />
<ComboboxContent anchor={anchorRef}>
<ComboboxList>
{loading && (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
{!loading &&
employees.map((emp) => (
<ComboboxItem key={emp.id} value={emp}>
{getEmployeeName(emp)}
</ComboboxItem>
))}
{!loading && employees.length === 0 && (
<ComboboxEmpty>No employees found</ComboboxEmpty>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) )
@ -128,42 +104,13 @@ function EmployeePickerDialog({
// ── Main Component ── // ── Main Component ──
export function JobCardActions({ jobCardId }: JobCardActionsProps) { export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesPersonName }: JobCardActionsProps) {
const api = useAuthApi() const api = useAuthApi()
const router = useRouter() const router = useRouter()
const [datePickerOpen, setDatePickerOpen] = useState(false) const [datePickerOpen, setDatePickerOpen] = useState(false)
const [serviceWriterDialogOpen, setServiceWriterDialogOpen] = useState(false) const [serviceWriterDialogOpen, setServiceWriterDialogOpen] = useState(false)
const [salesPersonDialogOpen, setSalesPersonDialogOpen] = useState(false) const [salesPersonDialogOpen, setSalesPersonDialogOpen] = useState(false)
const { data: employeesData, isLoading: employeesLoading } = useQuery({
queryKey: [EMPLOYEE_ROUTES.INDEX],
queryFn: () => api.employees.list(),
})
const employees: Employee[] = (employeesData as any)?.data ?? []
const handleEdit = () => {
router.push(`/sales/job-cards/${jobCardId}/edit`)
}
const handleDelete = async () => {
const confirmed = await confirm({
title: "Delete Job Card",
description: "Are you sure you want to delete this job card? This action cannot be undone.",
confirmLabel: "Delete",
variant: "destructive",
})
if (confirmed) {
const promise = api.jobCards.destroy(jobCardId)
toast.promise(promise, {
loading: "Deleting job card...",
success: "Job card deleted successfully",
error: "Failed to delete job card",
})
await promise
router.push("/sales/job-cards")
}
}
const changeDateMutation = useMutation({ const changeDateMutation = useMutation({
mutationFn: (date: Date) => { mutationFn: (date: Date) => {
@ -215,15 +162,20 @@ export function JobCardActions({ jobCardId }: JobCardActionsProps) {
}) })
return ( return (
<div className="flex items-center gap-1"> <div className="flex flex-wrap items-stretch gap-2">
{/* Check-in Date Action Card */}
<Popover open={datePickerOpen} onOpenChange={setDatePickerOpen}> <Popover open={datePickerOpen} onOpenChange={setDatePickerOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="outline" size="sm"> <button type="button" className="focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-lg">
<CalendarIcon className="size-4" /> <ActionCard
<span className="hidden sm:inline">Change Date</span> icon={CalendarIcon}
</Button> label="Order Date"
value={orderDate ? new Date(orderDate).toLocaleDateString() : null}
isPending={changeDateMutation.isPending}
/>
</button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end"> <PopoverContent className="w-auto p-0" align="start">
<Calendar <Calendar
mode="single" mode="single"
onSelect={(date) => { onSelect={(date) => {
@ -234,42 +186,42 @@ export function JobCardActions({ jobCardId }: JobCardActionsProps) {
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<Button variant="outline" size="sm" onClick={() => setServiceWriterDialogOpen(true)}> {/* Service Writer Action Card */}
<UserCog className="size-4" /> <button
<span className="hidden sm:inline">Service Writer</span> type="button"
</Button> className="focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-lg"
onClick={() => setServiceWriterDialogOpen(true)}
>
<ActionCard
icon={UserCog}
label="Service Writer"
value={serviceWriterName ?? null}
isPending={changeServiceWriterMutation.isPending}
/>
</button>
<Button variant="outline" size="sm" onClick={() => setSalesPersonDialogOpen(true)}> {/* Sales Person Action Card */}
<UserCheck className="size-4" /> <button
<span className="hidden sm:inline">Sales Person</span> type="button"
</Button> className="focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-lg"
onClick={() => setSalesPersonDialogOpen(true)}
>
<ActionCard
icon={UserCheck}
label="Sales Person"
value={salesPersonName ?? null}
isPending={changeSalesPersonMutation.isPending}
/>
</button>
{/* Edit / Delete Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleEdit}>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<EmployeePickerDialog <EmployeePickerDialog
open={serviceWriterDialogOpen} open={serviceWriterDialogOpen}
onOpenChange={setServiceWriterDialogOpen} onOpenChange={setServiceWriterDialogOpen}
title="Change Service Writer" title="Change Service Writer"
description="Search and select an employee to assign as service writer." description="Search and select an employee to assign as service writer."
employees={employees}
loading={employeesLoading}
isPending={changeServiceWriterMutation.isPending} isPending={changeServiceWriterMutation.isPending}
onSelect={(id) => changeServiceWriterMutation.mutate(id)} onSelect={(id) => changeServiceWriterMutation.mutate(id)}
/> />
@ -279,8 +231,6 @@ export function JobCardActions({ jobCardId }: JobCardActionsProps) {
onOpenChange={setSalesPersonDialogOpen} onOpenChange={setSalesPersonDialogOpen}
title="Change Sales Person" title="Change Sales Person"
description="Search and select an employee to assign as sales person." description="Search and select an employee to assign as sales person."
employees={employees}
loading={employeesLoading}
isPending={changeSalesPersonMutation.isPending} isPending={changeSalesPersonMutation.isPending}
onSelect={(id) => changeSalesPersonMutation.mutate(id)} onSelect={(id) => changeSalesPersonMutation.mutate(id)}
/> />

View File

@ -2,31 +2,26 @@
import { createContext, useContext, useState, useCallback } from "react" import { createContext, useContext, useState, useCallback } from "react"
import type { JobCardStatus } from "./job-card.schema" import type { JobCardStatus } from "./job-card.schema"
import { JobCardResponseData } from "@garage/api"
type JobCardContextValue = {
id: string
label: string
status: JobCardStatus
setStatus: (status: JobCardStatus) => void
}
const JobCardContext = createContext<JobCardContextValue | null>(null) const JobCardContext = createContext<JobCardResponseData | null>(null)
export function JobCardProvider({ export function JobCardProvider({
jobCard, jobCard,
children, children,
}: { }: {
jobCard: { id: string; label: string; status: JobCardStatus } jobCard: JobCardResponseData
children: React.ReactNode children: React.ReactNode
}) { }) {
const [status, setStatusState] = useState<JobCardStatus>(jobCard.status) const [status, setStatusState] = useState<JobCardStatus>(jobCard.status as JobCardStatus)
const setStatus = useCallback((newStatus: JobCardStatus) => { const setStatus = useCallback((newStatus: JobCardStatus) => {
setStatusState(newStatus) setStatusState(newStatus)
}, []) }, [])
return ( return (
<JobCardContext.Provider value={{ id: jobCard.id, label: jobCard.label, status, setStatus }}> <JobCardContext.Provider value={{ ...jobCard, status, setStatus } as JobCardResponseData}>
{children} {children}
</JobCardContext.Provider> </JobCardContext.Provider>
) )

View File

@ -0,0 +1,56 @@
"use client"
import { confirm } from '@/shared/components/confirm-dialog';
import { api } from '@garage/api';
import { useRouter } from 'next/dist/client/components/navigation';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/shared/components/ui/dropdown-menu'
import { Button } from '@/shared/components/ui/button'
import { toast } from 'sonner'
import { Ellipsis, Pencil, Trash2 } from 'lucide-react';
export default function JobCardDropdown({ id }: { id: string }) {
const router = useRouter();
const handleEdit = () => {
router.push(`/sales/job-cards/${id}/edit`)
}
const handleDelete = async () => {
const confirmed = await confirm({
title: "Delete Job Card",
description: "Are you sure you want to delete this job card? This action cannot be undone.",
confirmLabel: "Delete",
variant: "destructive",
})
if (confirmed) {
const promise = api.jobCards.destroy(id)
toast.promise(promise, {
loading: "Deleting job card...",
success: "Job card deleted successfully",
error: "Failed to delete job card",
})
await promise
router.push("/sales/job-cards")
}
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="self-stretch h-auto aspect-square">
<Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleEdit}>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -10,6 +10,8 @@ import {
RhfTextField, RhfTextField,
RhfSelectField, RhfSelectField,
RhfAsyncSelectField, RhfAsyncSelectField,
RhfDateField,
RhfTimeField,
} from "@/shared/components/form" } from "@/shared/components/form"
import { toast } from "sonner" import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi" import { useAuthApi } from "@/shared/useApi"
@ -23,8 +25,13 @@ import {
TAX_INCLUSIVE_OPTIONS, TAX_INCLUSIVE_OPTIONS,
DISCOUNT_TYPE_OPTIONS, DISCOUNT_TYPE_OPTIONS,
DISCOUNT_AT_OPTIONS, DISCOUNT_AT_OPTIONS,
ESTIMATE_TO_OPTIONS,
FUEL_LEVEL_OPTIONS,
JOB_CARD_STATUS_OPTIONS,
} from "./job-card.schema" } from "./job-card.schema"
import { JOB_CARD_ROUTES, CUSTOMER_ROUTES, VEHICLE_ROUTES } from "@garage/api" import { JOB_CARD_ROUTES, EMPLOYEE_ROUTES, DEPARTMENT_ROUTES } from "@garage/api"
import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field"
import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
// ── Props ── // ── Props ──
@ -40,10 +47,17 @@ const DEFAULT_VALUES: JobCardFormValues = {
title: "", title: "",
customer: null, customer: null,
vehicle: null, vehicle: null,
status: "draft", department: null,
service_writer: null,
status: "check_in",
estimate_to: "Customer",
tax_inclusive: "Tax Inclusive", tax_inclusive: "Tax Inclusive",
discount_type: "no", discount_type: "no",
discount_at: "inclusive_of_tax", discount_at: "inclusive_of_tax",
// check_in_date: "",
// check_in_time: (() => { const n = new Date(); return `${String(n.getHours()).padStart(2,"0")}:${String(n.getMinutes()).padStart(2,"0")}:${String(n.getSeconds()).padStart(2,"0")}` })(),
km_in: "",
fuel_level: "",
} }
// ── Mapping helpers ── // ── Mapping helpers ──
@ -53,12 +67,19 @@ function mapToFormValues(data: unknown): JobCardFormValues {
return { return {
title: d.title || "", title: d.title || "",
customer: toRelation(d.customer_id, d.customer_name), customer: toRelation(d.customer_id, d.customer ? `${d.customer.first_name} ${d.customer.last_name}` : undefined),
vehicle: toRelation(d.vehicle_id, d.vehicle_name), vehicle: toRelation(d.vehicle_id, d.vehicle ? `${d.vehicle.make} ${d.vehicle.model}` : undefined),
department: toRelation(d.department_id, d.department?.name),
service_writer: toRelation(d.service_writer_id, d.service_writer ? `${d.service_writer.first_name} ${d.service_writer.last_name}` : undefined),
status: d.status || "draft", status: d.status || "draft",
estimate_to: d.estimate_to || "Customer",
tax_inclusive: d.tax_inclusive || "Tax Inclusive", tax_inclusive: d.tax_inclusive || "Tax Inclusive",
discount_type: d.discount_type || "no", discount_type: d.discount_type || "no",
discount_at: d.discount_at || "inclusive_of_tax", discount_at: d.discount_at || "inclusive_of_tax",
check_in_date: d.check_in_date ? d.check_in_date.split("T")[0] : "",
check_in_time: d.check_in_time ? d.check_in_time.split("T")[0] : "",
km_in: d.km_in != null ? String(d.km_in) : "",
fuel_level: d.fuel_level || "",
} }
} }
@ -67,10 +88,17 @@ function mapFormToPayload(values: JobCardFormValues) {
title: values.title, title: values.title,
customer_id: toId(values.customer), customer_id: toId(values.customer),
vehicle_id: toId(values.vehicle), vehicle_id: toId(values.vehicle),
department_id: toId(values.department),
service_writer_id: toId(values.service_writer),
status: values.status || undefined, status: values.status || undefined,
estimate_to: values.estimate_to || undefined,
tax_inclusive: values.tax_inclusive || undefined, tax_inclusive: values.tax_inclusive || undefined,
discount_type: values.discount_type || undefined, discount_type: values.discount_type || undefined,
discount_at: values.discount_at || undefined, discount_at: values.discount_at || undefined,
check_in_date: values.check_in_date || undefined,
check_in_time: values.check_in_time || undefined,
km_in: values.km_in ? Number(values.km_in) : undefined,
fuel_level: values.fuel_level || undefined,
} }
} }
@ -81,14 +109,9 @@ const mapLookupOption = (item: any) => ({
label: item.name, label: item.name,
}) })
const mapCustomerOption = (item: any) => ({ const mapEmployeeOption = (item: any) => ({
value: String(item.id), value: String(item.id),
label: [item.first_name, item.last_name].filter(Boolean).join(" "), label: `${item.first_name} ${item.last_name}`,
})
const mapVehicleOption = (item: any) => ({
value: String(item.id),
label: [item.year, item.make_name, item.model_name].filter(Boolean).join(" "),
}) })
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label } const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
@ -141,27 +164,62 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP
<FieldGroup> <FieldGroup>
<RhfTextField name="title" label="Title" placeholder="Job Card 001" required /> <RhfTextField name="title" label="Title" placeholder="Job Card 001" required />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfCustomerSelectField name="customer" />
<RhfVehicleSelectField name="vehicle" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{/* <RhfSelectField
name="status"
label="Status"
placeholder="Select status"
options={JOB_CARD_STATUS_OPTIONS}
/> */}
<RhfSelectField
name="estimate_to"
label="Estimate To"
placeholder="Select estimate to"
options={ESTIMATE_TO_OPTIONS}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField <RhfAsyncSelectField
name="customer" name="department"
label="Customer" label="Department"
placeholder="Select customer" placeholder="Select department"
queryKey={[CUSTOMER_ROUTES.INDEX]} queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.customers.list()} listFn={() => api.departments.list()}
mapOption={mapCustomerOption} mapOption={mapLookupOption}
{...STORE_OBJECT} {...STORE_OBJECT}
/> />
<RhfAsyncSelectField <RhfAsyncSelectField
name="vehicle" name="service_writer"
label="Vehicle" label="Service Writer"
placeholder="Select vehicle" placeholder="Select service writer"
queryKey={[VEHICLE_ROUTES.INDEX]} queryKey={[EMPLOYEE_ROUTES.INDEX]}
listFn={() => api.vehicles.list()} listFn={() => api.employees.list()}
mapOption={mapVehicleOption} mapOption={mapEmployeeOption}
{...STORE_OBJECT} {...STORE_OBJECT}
/> />
</div> </div>
{/* <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfDateField name="check_in_date" label="Check-in Date" />
<RhfTimeField name="check_in_time" label="Check-in Time" withSeconds />
</div> */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="km_in" label="KM In" placeholder="50000" type="number" />
<RhfSelectField
name="fuel_level"
label="Fuel Level"
placeholder="Select fuel level"
options={FUEL_LEVEL_OPTIONS}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<RhfSelectField <RhfSelectField
name="tax_inclusive" name="tax_inclusive"

View File

@ -20,37 +20,15 @@ import {
} from "@/shared/components/ui/card" } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { Separator } from "@/shared/components/ui/separator" import { Separator } from "@/shared/components/ui/separator"
import { JobCardActions } from "./job-card-actions"
import { getVehicleLabel } from "../vehicles/utils/getVehicleLabel"
import { JobCardResponseData } from "@garage/api"
import { getFullName } from "@/shared/utils/getFullName"
type JobCardData = {
id?: number
title?: string
status?: string
check_in_date?: string
km_in?: number
tax_inclusive?: string
discount_type?: string
discount_at?: string
customer_id?: number
customer_name?: string
vehicle_id?: number
vehicle_name?: string
department_id?: number
department_name?: string
sales_person_id?: number
sales_person_name?: string
service_writer_id?: number
service_writer_name?: string
purchase_orders_count?: number
bills_count?: number
expenses_count?: number
tasks_count?: number
appointments_count?: number
created_at?: string
updated_at?: string
}
type JobCardGeneralInfoProps = { type JobCardGeneralInfoProps = {
jobCard: JobCardData jobCard: JobCardResponseData
} }
function InfoItem({ function InfoItem({
@ -86,7 +64,7 @@ const statusColorMap: Record<string, string> = {
cancelled: "destructive", cancelled: "destructive",
} }
export function JobCardGeneralInfo({ jobCard }: JobCardGeneralInfoProps) { export function JobCardGeneralInfo({ jobCard }: { jobCard: JobCardResponseData }) {
const formatStatus = (status?: string) => { const formatStatus = (status?: string) => {
if (!status) return null if (!status) return null
return status return status
@ -95,9 +73,17 @@ export function JobCardGeneralInfo({ jobCard }: JobCardGeneralInfoProps) {
.join(" ") .join(" ")
} }
console.log(jobCard)
return ( return (
<div className="flex flex-col gap-6">
{jobCard.service_writer?.first_name}
<JobCardActions
jobCardId={String(jobCard.id)}
orderDate={jobCard.order_date ?? null}
serviceWriterName={jobCard.service_writer?.first_name}
salesPersonName={jobCard.sales_person?.first_name}
/>
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
{/* Job Card Details */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@ -155,27 +141,27 @@ export function JobCardGeneralInfo({ jobCard }: JobCardGeneralInfoProps) {
<InfoItem <InfoItem
icon={Users} icon={Users}
label="Customer" label="Customer"
value={jobCard.customer_name} value={getFullName(jobCard.customer)}
/> />
<InfoItem <InfoItem
icon={Car} icon={Car}
label="Vehicle" label="Vehicle"
value={jobCard.vehicle_name} value={getVehicleLabel(jobCard.vehicle as any)}
/> />
<InfoItem <InfoItem
icon={Building2} icon={Building2}
label="Department" label="Department"
value={jobCard.department_name} value={jobCard.department as any}
/> />
<InfoItem <InfoItem
icon={Briefcase} icon={Briefcase}
label="Sales Person" label="Sales Person"
value={jobCard.sales_person_name} value={getFullName(jobCard.sales_person as any)}
/> />
<InfoItem <InfoItem
icon={UserCheck} icon={UserCheck}
label="Service Writer" label="Service Writer"
value={jobCard.service_writer_name} value={getFullName(jobCard.service_writer as any)}
/> />
</div> </div>
</CardContent> </CardContent>
@ -245,5 +231,6 @@ export function JobCardGeneralInfo({ jobCard }: JobCardGeneralInfoProps) {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div>
) )
} }

View File

@ -70,7 +70,7 @@ export function JobCardStatusStepper({ jobCardId }: JobCardStatusStepperProps) {
<TooltipProvider> <TooltipProvider>
<div className="flex items-center gap-0 overflow-x-auto"> <div className="flex items-center gap-0 overflow-x-auto">
{JOB_CARD_STATUSES.map((step, index) => { {JOB_CARD_STATUSES.map((step, index) => {
const Icon = STATUS_ICONS[step.value] const Icon = STATUS_ICONS[step.value] ?? CircleDot
const isActive = step.value === currentStatus const isActive = step.value === currentStatus
const isCompleted = index < currentIndex const isCompleted = index < currentIndex
const isTransitioning = isPending && variables === step.value const isTransitioning = isPending && variables === step.value

View File

@ -1,4 +1,12 @@
import { z } from "zod" import { z } from "zod"
import {
JobCardStatus,
EstimateTo,
TaxInclusive,
DiscountType,
DiscountAt,
FuelLevel,
} from "@garage/api"
const relationFieldSchema = z const relationFieldSchema = z
.object({ value: z.string(), label: z.string() }) .object({ value: z.string(), label: z.string() })
@ -6,31 +14,37 @@ const relationFieldSchema = z
// ── Job Card Statuses ── // ── Job Card Statuses ──
export const JOB_CARD_STATUSES = [ export const JOB_CARD_STATUS_OPTIONS = JobCardStatus.map((v) => ({
{ value: "draft", label: "Draft" }, value: v,
{ value: "check_in", label: "Check In" }, label: v
{ value: "in_progress", label: "In Progress" }, .replace(/_/g, " ")
{ value: "on_hold", label: "On Hold" }, .replace(/\b\w/g, (c) => c.toUpperCase()),
{ value: "ready_to_deliver", label: "Ready to Deliver" }, }))
{ value: "delivered", label: "Delivered" },
] as const
export type JobCardStatus = (typeof JOB_CARD_STATUSES)[number]["value"] const ESTIMATE_TO_OPTIONS = EstimateTo.map((v) => ({ value: v, label: v }))
const TAX_INCLUSIVE_OPTIONS = [ const TAX_INCLUSIVE_OPTIONS = TaxInclusive.map((v) => ({ value: v, label: v }))
{ value: "Tax Inclusive", label: "Tax Inclusive" },
{ value: "Tax Exclusive", label: "Tax Exclusive" },
]
const DISCOUNT_TYPE_OPTIONS = [ const DISCOUNT_TYPE_OPTIONS = DiscountType.map((v) => ({
{ value: "no", label: "No Discount" }, value: v,
{ value: "transaction_level", label: "Transaction Level" }, label: v
] .replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase()),
}))
const DISCOUNT_AT_OPTIONS = [ const DISCOUNT_AT_OPTIONS = DiscountAt.map((v) => ({
{ value: "inclusive_of_tax", label: "Inclusive of Tax" }, value: v,
{ value: "exclusive_of_tax", label: "Exclusive of Tax" }, label: v
] .replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase()),
}))
const FUEL_LEVEL_OPTIONS = FuelLevel.map((v) => ({
value: v,
label: v
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase()),
}))
const jobCardFormSchema = z.object({ const jobCardFormSchema = z.object({
// ── Required fields ── // ── Required fields ──
@ -39,15 +53,36 @@ const jobCardFormSchema = z.object({
// ── Relations ── // ── Relations ──
customer: relationFieldSchema, customer: relationFieldSchema,
vehicle: relationFieldSchema, vehicle: relationFieldSchema,
department: relationFieldSchema,
service_writer: relationFieldSchema,
// ── Settings ── // ── Settings ──
status: z.string().optional(), status: z.string().optional(),
estimate_to: z.string().optional(),
tax_inclusive: z.string().optional(), tax_inclusive: z.string().optional(),
discount_type: z.string().optional(), discount_type: z.string().optional(),
discount_at: z.string().optional(), discount_at: z.string().optional(),
// ── Check-in details ──
check_in_date: z.string().optional(),
check_in_time: z.string().optional(),
km_in: z.string().optional(),
fuel_level: z.string().optional(),
}) })
type JobCardFormValues = z.infer<typeof jobCardFormSchema> type JobCardFormValues = z.infer<typeof jobCardFormSchema>
export { jobCardFormSchema, relationFieldSchema, TAX_INCLUSIVE_OPTIONS, DISCOUNT_TYPE_OPTIONS, DISCOUNT_AT_OPTIONS } export {
jobCardFormSchema,
relationFieldSchema,
TAX_INCLUSIVE_OPTIONS,
DISCOUNT_TYPE_OPTIONS,
DISCOUNT_AT_OPTIONS,
ESTIMATE_TO_OPTIONS,
FUEL_LEVEL_OPTIONS,
JOB_CARD_STATUS_OPTIONS,
}
// Backward-compat alias used by job-card-status-stepper
export const JOB_CARD_STATUSES = JOB_CARD_STATUS_OPTIONS
export type { JobCardFormValues } export type { JobCardFormValues }
export type { JobCardStatus } from "@garage/api"

View File

@ -0,0 +1,254 @@
"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,
RhfTextareaField,
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 {
paymentMadeFormSchema,
type PaymentMadeFormValues,
} from "./payment-made.schema"
import {
PAYMENT_MADE_ROUTES,
PAYMENT_ROUTES,
VENDOR_ROUTES,
EMPLOYEE_ROUTES,
PaymentFor,
} from "@garage/api"
// ── Constants ──
const PAYMENT_FOR_OPTIONS = PaymentFor.map((value) => ({
value,
label: value.charAt(0).toUpperCase() + value.slice(1),
}))
// ── Props ──
export type PaymentMadeFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
// ── Default values ──
const DEFAULT_VALUES: PaymentMadeFormValues = {
vendor: null,
employee: null,
payment_mode: null,
payment_for: "",
payment_made: "",
payment_number: "",
payment_reference: "",
payment_date: "",
paid_through: "",
notes: "",
}
// ── Mapping helpers ──
function mapToFormValues(data: unknown): PaymentMadeFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
vendor: toRelation(d.vendor_id, d.vendor_name),
employee: toRelation(d.employee_id, d.employee_name),
payment_mode: toRelation(d.payment_mode_id, d.payment_mode_name),
payment_for: d.payment_for || "",
payment_made: d.payment_made ? String(d.payment_made) : "",
payment_number: d.payment_number || "",
payment_reference: d.payment_reference || "",
payment_date: d.payment_date || "",
paid_through: d.paid_through || "",
notes: d.notes || "",
}
}
function mapFormToPayload(values: PaymentMadeFormValues) {
return {
vendor_id: toId(values.vendor),
employee_id: toId(values.employee) || undefined,
payment_mode_id: toId(values.payment_mode),
payment_for: values.payment_for,
payment_made: values.payment_made,
payment_number: values.payment_number || undefined,
payment_reference: values.payment_reference || undefined,
payment_date: values.payment_date,
paid_through: values.paid_through || undefined,
notes: values.notes || undefined,
}
}
// ── Shared mapOption for async selects ──
const mapLookupOption = (item: any) => ({
value: String(item.id),
label: item.name ?? item.title ?? `#${item.id}`,
})
const mapVendorOption = (item: any) => ({
value: String(item.id),
label: item.name ?? item.company_name ?? `#${item.id}`,
})
const mapEmployeeOption = (item: any) => ({
value: String(item.id),
label: item.first_name
? `${item.first_name} ${item.last_name || ""}`.trim()
: item.name ?? `#${item.id}`,
})
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
// ── Component ──
export function PaymentMadeForm({ resourceId, initialData, onSuccess }: PaymentMadeFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<PaymentMadeFormValues, any>({
schema: paymentMadeFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
mapToFormValues,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: PaymentMadeFormValues) => {
const payload = mapFormToPayload(values)
const promise = (isEditing && resourceId
? api.paymentMades.update(resourceId, payload as any)
: api.paymentMades.create(payload as any)) as Promise<any>
toast.promise(promise, {
loading: isEditing ? "Updating payment..." : "Recording payment...",
success: isEditing ? "Payment updated successfully" : "Payment recorded successfully",
error: isEditing ? "Failed to update payment" : "Failed to record payment",
})
return promise
},
onSuccess: () => {
form.reset()
onSuccess?.()
},
})
return (
<Rhform form={form} onSubmit={(values) => mutate(values)}>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="me-2 h-4 w-4" />
<AlertTitle>
{isEditing ? "Failed to update payment" : "Failed to record payment"}
</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="vendor"
label="Vendor"
placeholder="Select vendor"
queryKey={[VENDOR_ROUTES.INDEX]}
listFn={() => api.vendors.list()}
mapOption={mapVendorOption}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="employee"
label="Employee"
placeholder="Select employee"
queryKey={[EMPLOYEE_ROUTES.INDEX]}
listFn={() => api.employees.list()}
mapOption={mapEmployeeOption}
{...STORE_OBJECT}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfSelectField
name="payment_for"
label="Payment For"
placeholder="Select type"
options={PAYMENT_FOR_OPTIONS}
required
/>
<RhfTextField
name="payment_made"
label="Amount"
placeholder="0.00"
type="number"
required
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="payment_number"
label="Payment Number"
placeholder="PAY-001"
/>
<RhfTextField
name="payment_reference"
label="Payment Reference"
placeholder="Reference"
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="payment_date"
label="Payment Date"
type="date"
required
/>
<RhfAsyncSelectField
name="payment_mode"
label="Payment Mode"
placeholder="Select payment mode"
queryKey={[PAYMENT_ROUTES.MODES]}
listFn={() => api.payments.listModes()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
</div>
<RhfTextField
name="paid_through"
label="Paid Through"
placeholder="Account name"
/>
<RhfTextareaField
name="notes"
label="Notes"
rows={3}
placeholder="Add any notes about this payment..."
/>
<Button type="submit" variant="default" disabled={isPending}>
{isEditing ? <Save /> : <Plus />}
{isPending
? (isEditing ? "Updating..." : "Recording...")
: (isEditing ? "Update Payment" : "Record Payment")}
</Button>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,26 @@
import { z } from "zod"
const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
const paymentMadeFormSchema = z.object({
// ── Relations ──
vendor: relationFieldSchema,
employee: relationFieldSchema,
payment_mode: relationFieldSchema,
// ── Payment info ──
payment_for: z.string().min(1, "Payment for is required"),
payment_made: z.string().min(1, "Amount is required"),
payment_number: z.string().optional(),
payment_reference: z.string().optional(),
payment_date: z.string().min(1, "Payment date is required"),
paid_through: z.string().optional(),
notes: z.string().optional(),
})
type PaymentMadeFormValues = z.infer<typeof paymentMadeFormSchema>
export { paymentMadeFormSchema, relationFieldSchema }
export type { PaymentMadeFormValues }

View File

@ -0,0 +1,189 @@
"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 { 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 {
purchaseOrderFormSchema,
type PurchaseOrderFormValues,
} from "./purchase-order.schema"
import { PURCHASE_ORDER_ROUTES, VENDOR_ROUTES, JOB_CARD_ROUTES, DEPARTMENT_ROUTES } from "@garage/api"
// ── Props ──
export type PurchaseOrderFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
// ── Default values ──
const DEFAULT_VALUES: PurchaseOrderFormValues = {
vendor: null,
job_card: null,
department: null,
title: "",
order_date: "",
delivery_date: "",
notes: "",
}
// ── Mapping helpers ──
function mapToFormValues(data: unknown): PurchaseOrderFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
vendor: toRelation(d.vendor_id, d.vendor_name),
job_card: toRelation(d.job_card_id, d.job_card_name),
department: toRelation(d.department_id, d.department_name),
title: d.title || "",
order_date: d.order_date || "",
delivery_date: d.delivery_date || "",
notes: d.notes || "",
}
}
function mapFormToPayload(values: PurchaseOrderFormValues) {
return {
vendor_id: toId(values.vendor),
job_card_id: toId(values.job_card),
department_id: toId(values.department),
title: values.title,
order_date: values.order_date || undefined,
delivery_date: values.delivery_date || undefined,
notes: values.notes || 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 PurchaseOrderForm({ resourceId, initialData, onSuccess }: PurchaseOrderFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<PurchaseOrderFormValues, any>({
schema: purchaseOrderFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
mapToFormValues,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: PurchaseOrderFormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing && resourceId
? api.purchaseOrders.update(resourceId, payload)
: api.purchaseOrders.create(payload)
toast.promise(promise, {
loading: isEditing ? "Updating purchase order..." : "Creating purchase order...",
success: isEditing ? "Purchase order updated successfully" : "Purchase order created successfully",
error: isEditing ? "Failed to update purchase order" : "Failed to create purchase order",
})
return promise
},
onSuccess: () => {
form.reset()
onSuccess?.()
},
})
return (
<Rhform form={form} onSubmit={(values) => mutate(values)}>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="me-2 h-4 w-4" />
<AlertTitle>
{isEditing ? "Failed to update purchase order" : "Failed to create purchase order"}
</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<RhfTextField name="title" label="Title" placeholder="Enter purchase order title" required />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="order_date" label="Order Date" placeholder="YYYY-MM-DD" type="date" />
<RhfTextField name="delivery_date" label="Delivery Date" placeholder="YYYY-MM-DD" type="date" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="vendor"
label="Vendor"
placeholder="Select vendor"
queryKey={[VENDOR_ROUTES.INDEX]}
listFn={() => api.vendors.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department"
queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.departments.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
</div>
<RhfAsyncSelectField
name="job_card"
label="Job Card"
placeholder="Select job card"
queryKey={[JOB_CARD_ROUTES.INDEX]}
listFn={() => api.jobCards.list()}
mapOption={(item: any) => ({
value: String(item.id),
label: item.job_card_number || item.name || `#${item.id}`,
})}
{...STORE_OBJECT}
/>
<RhfTextareaField name="notes" label="Notes" rows={3} />
<Button type="submit" variant="default" disabled={isPending}>
{isPending ? (
"Saving..."
) : isEditing ? (
<>
<Save className="me-2 h-4 w-4" />
Update Purchase Order
</>
) : (
<>
<Plus className="me-2 h-4 w-4" />
Create Purchase Order
</>
)}
</Button>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,23 @@
import { z } from "zod"
const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
const purchaseOrderFormSchema = z.object({
// ── Relations ──
vendor: relationFieldSchema,
job_card: relationFieldSchema,
department: relationFieldSchema,
// ── Basic info ──
title: z.string().min(1, "Title is required"),
order_date: z.string().optional(),
delivery_date: z.string().optional(),
notes: z.string().optional(),
})
type PurchaseOrderFormValues = z.infer<typeof purchaseOrderFormSchema>
export { purchaseOrderFormSchema, relationFieldSchema }
export type { PurchaseOrderFormValues }

View File

@ -0,0 +1,319 @@
"use client"
import { AlertTriangle, Save } from "lucide-react"
import { useEffect } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { useQuery } from "@tanstack/react-query"
import { toast } from "sonner"
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,
RhfTextareaField,
} from "@/shared/components/form"
import { useAuthApi } from "@/shared/useApi"
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
import { toId } from "@/shared/lib/utils"
import { FirstDayOfWork } from "@garage/api"
import { SETTINGS_ROUTES } from "@garage/api"
import { settingsFormSchema, type SettingsFormValues } from "./settings.schema"
// ── Constants ──
const FIRST_DAY_OPTIONS = FirstDayOfWork.map((d) => ({
value: d,
label: d.charAt(0).toUpperCase() + d.slice(1),
}))
const mapLookupOption = (item: any) => ({ value: String(item.id), label: item.name })
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
// ── Default values ──
const DEFAULT_VALUES: SettingsFormValues = {
name: "",
email: "",
phone: "",
alternative_phone: "",
website: "",
time_zone: "",
upi_id: "",
first_day_of_work: "",
latitude: "",
longitude: "",
bank_details: "",
first_address_line: "",
second_address_line: "",
country: null,
state: null,
city: "",
zip_code: "",
description: "",
security: "",
privacy_policy: "",
}
// ── Mapping helpers ──
function mapToFormValues(data: unknown): SettingsFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
name: d.name ?? "",
email: d.email ?? "",
phone: d.phone ?? "",
alternative_phone: d.alternative_phone ?? "",
website: d.website ?? "",
time_zone: d.time_zone ?? "",
upi_id: d.upi_id ?? "",
first_day_of_work: d.first_day_of_work ?? "",
latitude: d.latitude ?? "",
longitude: d.longitude ?? "",
bank_details: d.bank_details ?? "",
first_address_line: d.first_address_line ?? "",
second_address_line: d.second_address_line ?? "",
country: null,
state: null,
city: d.city ?? "",
zip_code: d.zip_code ?? "",
description: d.description ?? "",
security: d.security ?? "",
privacy_policy: d.privacy_policy ?? "",
}
}
function mapFormToPayload(values: SettingsFormValues) {
return {
name: values.name,
email: values.email || undefined,
phone: values.phone || undefined,
time_zone: values.time_zone || undefined,
first_day_of_work: values.first_day_of_work || undefined,
first_address_line: values.first_address_line || undefined,
country_id: toId(values.country),
city: values.city || undefined,
}
}
// ── Component ──
export function SettingsForm() {
const api = useAuthApi()
const form = useForm<SettingsFormValues>({
resolver: zodResolver(settingsFormSchema) as any,
defaultValues: DEFAULT_VALUES,
})
const { data, isLoading } = useQuery({
queryKey: [SETTINGS_ROUTES.INDEX],
queryFn: () => api.settings.fetch(),
})
useEffect(() => {
if (!data) return
const raw = (data as any)?.data
const record = Array.isArray(raw) ? raw[0] : raw
if (record) {
form.reset(mapToFormValues(record))
}
}, [data]) // eslint-disable-line react-hooks/exhaustive-deps
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: SettingsFormValues) => {
const payload = mapFormToPayload(values)
const promise = api.settings.update(payload)
toast.promise(promise, {
loading: "Saving settings...",
success: "Settings saved successfully",
error: "Failed to save settings",
})
return promise
},
})
return (
<Rhform form={form} onSubmit={(values) => mutate(values)}>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="me-2 h-4 w-4" />
<AlertTitle>Failed to save settings</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
{/* General Info */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="name"
label="Workshop Name"
placeholder="My Workshop"
required
disabled={isLoading}
/>
<RhfTextField
name="email"
label="Email"
placeholder="workshop@example.com"
type="email"
disabled={isLoading}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="phone"
label="Phone"
placeholder="+971501234567"
type="tel"
disabled={isLoading}
/>
<RhfTextField
name="alternative_phone"
label="Alternative Phone"
placeholder="+971509876543"
type="tel"
disabled={isLoading}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="website"
label="Website"
placeholder="https://example.com"
disabled={isLoading}
/>
<RhfTextField
name="upi_id"
label="UPI ID"
placeholder="workshop@upi"
disabled={isLoading}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="time_zone"
label="Time Zone"
placeholder="Asia/Dubai"
disabled={isLoading}
/>
<RhfSelectField
name="first_day_of_work"
label="First Day of Work"
placeholder="Select day"
options={FIRST_DAY_OPTIONS}
disabled={isLoading}
/>
</div>
{/* Address */}
<RhfTextField
name="first_address_line"
label="Address Line 1"
placeholder="Street 10"
disabled={isLoading}
/>
<RhfTextField
name="second_address_line"
label="Address Line 2"
placeholder="Near Central Plaza"
disabled={isLoading}
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="country"
label="Country"
placeholder="Select country"
queryKey={["countries"]}
listFn={() => api.geo.countries()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="state"
label="State"
placeholder="Select state"
queryKey={["states"]}
listFn={() => api.geo.states()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="city"
label="City"
placeholder="Dubai"
disabled={isLoading}
/>
<RhfTextField
name="zip_code"
label="Zip Code"
placeholder="00000"
disabled={isLoading}
/>
</div>
{/* Location */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="latitude"
label="Latitude"
placeholder="25.2048"
disabled={isLoading}
/>
<RhfTextField
name="longitude"
label="Longitude"
placeholder="55.2708"
disabled={isLoading}
/>
</div>
{/* Other */}
<RhfTextareaField
name="bank_details"
label="Bank Details"
placeholder="Bank name, account number, IBAN..."
disabled={isLoading}
/>
<RhfTextareaField
name="description"
label="Description"
placeholder="About the workshop..."
disabled={isLoading}
/>
<RhfTextareaField
name="security"
label="Security Policy"
placeholder="Security policy text..."
disabled={isLoading}
/>
<RhfTextareaField
name="privacy_policy"
label="Privacy Policy"
placeholder="Privacy policy text..."
disabled={isLoading}
/>
<div className="flex justify-end">
<Button type="submit" disabled={isPending || isLoading}>
<Save />
{isPending ? "Saving..." : "Save Settings"}
</Button>
</div>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,30 @@
import { z } from "zod"
export const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
export const settingsFormSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.union([z.string().email("Invalid email address"), z.literal("")]).optional(),
phone: z.string().optional(),
alternative_phone: z.string().optional(),
website: z.string().optional(),
time_zone: z.string().optional(),
upi_id: z.string().optional(),
first_day_of_work: z.string().optional(),
latitude: z.string().optional(),
longitude: z.string().optional(),
bank_details: z.string().optional(),
first_address_line: z.string().optional(),
second_address_line: z.string().optional(),
country: relationFieldSchema,
state: relationFieldSchema,
city: z.string().optional(),
zip_code: z.string().optional(),
description: z.string().optional(),
security: z.string().optional(),
privacy_policy: z.string().optional(),
})
export type SettingsFormValues = z.infer<typeof settingsFormSchema>

View File

@ -0,0 +1,24 @@
import { z } from "zod"
export const salesConfigFormSchema = z.object({
sell_rates_tax_inclusive: z.string().optional(),
give_discounts: z.string().optional(),
})
export type SalesConfigFormValues = z.infer<typeof salesConfigFormSchema>
export const purchaseConfigFormSchema = z.object({
purchase_rates_tax_inclusive: z.string().optional(),
receive_discounts: z.string().optional(),
})
export type PurchaseConfigFormValues = z.infer<typeof purchaseConfigFormSchema>
export const generalPreferencesFormSchema = z.object({
sell_rates_tax_inclusive: z.string().optional(),
give_discounts: z.string().optional(),
purchase_rates_tax_inclusive: z.string().optional(),
receive_discounts: z.string().optional(),
})
export type GeneralPreferencesFormValues = z.infer<typeof generalPreferencesFormSchema>

View File

@ -0,0 +1,102 @@
"use client"
import { AlertTriangle, Save } from "lucide-react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
import { FieldGroup } from "@/shared/components/ui/field"
import { Rhform, RhfSelectField } from "@/shared/components/form"
import { useAuthApi } from "@/shared/useApi"
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
import { TaxInclusive, DiscountType } from "@garage/api"
import { generalPreferencesFormSchema, type GeneralPreferencesFormValues } from "./configurations.schema"
const TAX_INCLUSIVE_OPTIONS = TaxInclusive.map((v) => ({ value: v, label: v }))
const DISCOUNT_OPTIONS = DiscountType.map((v) => ({
value: v,
label: v === "no" ? "No Discount" : v === "line_item_level" ? "Line Item Level" : "Transaction Level",
}))
const DEFAULT_VALUES: GeneralPreferencesFormValues = {
sell_rates_tax_inclusive: "",
give_discounts: "",
purchase_rates_tax_inclusive: "",
receive_discounts: "",
}
export function GeneralPreferencesForm() {
const api = useAuthApi()
const form = useForm<GeneralPreferencesFormValues>({
resolver: zodResolver(generalPreferencesFormSchema) as any,
defaultValues: DEFAULT_VALUES,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: GeneralPreferencesFormValues) => {
const promise = api.configurations.updateGeneralPreferences(values)
toast.promise(promise, {
loading: "Saving general preferences...",
success: "General preferences saved",
error: "Failed to save general preferences",
})
return promise
},
})
return (
<Rhform form={form} onSubmit={(values) => mutate(values)}>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="me-2 h-4 w-4" />
<AlertTitle>Failed to save general preferences</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfSelectField
name="sell_rates_tax_inclusive"
label="Sell Rates Tax"
placeholder="Select tax type"
options={TAX_INCLUSIVE_OPTIONS}
/>
<RhfSelectField
name="give_discounts"
label="Give Discounts"
placeholder="Select discount type"
options={DISCOUNT_OPTIONS}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfSelectField
name="purchase_rates_tax_inclusive"
label="Purchase Rates Tax"
placeholder="Select tax type"
options={TAX_INCLUSIVE_OPTIONS}
/>
<RhfSelectField
name="receive_discounts"
label="Receive Discounts"
placeholder="Select discount type"
options={DISCOUNT_OPTIONS}
/>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isPending}>
<Save />
{isPending ? "Saving..." : "Save"}
</Button>
</div>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,83 @@
"use client"
import { AlertTriangle, Save } from "lucide-react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
import { FieldGroup } from "@/shared/components/ui/field"
import { Rhform, RhfSelectField } from "@/shared/components/form"
import { useAuthApi } from "@/shared/useApi"
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
import { TaxInclusive, DiscountType } from "@garage/api"
import { purchaseConfigFormSchema, type PurchaseConfigFormValues } from "./configurations.schema"
const TAX_INCLUSIVE_OPTIONS = TaxInclusive.map((v) => ({ value: v, label: v }))
const DISCOUNT_OPTIONS = DiscountType.map((v) => ({
value: v,
label: v === "no" ? "No Discount" : v === "line_item_level" ? "Line Item Level" : "Transaction Level",
}))
const DEFAULT_VALUES: PurchaseConfigFormValues = {
purchase_rates_tax_inclusive: "",
receive_discounts: "",
}
export function PurchaseConfigForm() {
const api = useAuthApi()
const form = useForm<PurchaseConfigFormValues>({
resolver: zodResolver(purchaseConfigFormSchema) as any,
defaultValues: DEFAULT_VALUES,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: PurchaseConfigFormValues) => {
const promise = api.configurations.updatePurchaseTaxDiscount(values)
toast.promise(promise, {
loading: "Saving purchase configuration...",
success: "Purchase configuration saved",
error: "Failed to save purchase configuration",
})
return promise
},
})
return (
<Rhform form={form} onSubmit={(values) => mutate(values)}>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="me-2 h-4 w-4" />
<AlertTitle>Failed to save purchase configuration</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<RhfSelectField
name="purchase_rates_tax_inclusive"
label="Purchase Rates Tax"
placeholder="Select tax type"
options={TAX_INCLUSIVE_OPTIONS}
/>
<RhfSelectField
name="receive_discounts"
label="Receive Discounts"
placeholder="Select discount type"
options={DISCOUNT_OPTIONS}
/>
<div className="flex justify-end">
<Button type="submit" disabled={isPending}>
<Save />
{isPending ? "Saving..." : "Save"}
</Button>
</div>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,83 @@
"use client"
import { AlertTriangle, Save } from "lucide-react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
import { FieldGroup } from "@/shared/components/ui/field"
import { Rhform, RhfSelectField } from "@/shared/components/form"
import { useAuthApi } from "@/shared/useApi"
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
import { TaxInclusive, DiscountType } from "@garage/api"
import { salesConfigFormSchema, type SalesConfigFormValues } from "./configurations.schema"
const TAX_INCLUSIVE_OPTIONS = TaxInclusive.map((v) => ({ value: v, label: v }))
const DISCOUNT_OPTIONS = DiscountType.map((v) => ({
value: v,
label: v === "no" ? "No Discount" : v === "line_item_level" ? "Line Item Level" : "Transaction Level",
}))
const DEFAULT_VALUES: SalesConfigFormValues = {
sell_rates_tax_inclusive: "",
give_discounts: "",
}
export function SalesConfigForm() {
const api = useAuthApi()
const form = useForm<SalesConfigFormValues>({
resolver: zodResolver(salesConfigFormSchema) as any,
defaultValues: DEFAULT_VALUES,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: SalesConfigFormValues) => {
const promise = api.configurations.updateSalesTaxDiscount(values)
toast.promise(promise, {
loading: "Saving sales configuration...",
success: "Sales configuration saved",
error: "Failed to save sales configuration",
})
return promise
},
})
return (
<Rhform form={form} onSubmit={(values) => mutate(values)}>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="me-2 h-4 w-4" />
<AlertTitle>Failed to save sales configuration</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<RhfSelectField
name="sell_rates_tax_inclusive"
label="Sell Rates Tax"
placeholder="Select tax type"
options={TAX_INCLUSIVE_OPTIONS}
/>
<RhfSelectField
name="give_discounts"
label="Give Discounts"
placeholder="Select discount type"
options={DISCOUNT_OPTIONS}
/>
<div className="flex justify-end">
<Button type="submit" disabled={isPending}>
<Save />
{isPending ? "Saving..." : "Save"}
</Button>
</div>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,112 @@
"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 } 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 { DEPARTMENT_ROUTES } from "@garage/api"
import { DEPARTMENT_ASSIGNMENT_TYPE_OPTIONS } from "@/modules/services/department-assignment-types"
import { departmentFormSchema, type DepartmentFormValues } from "./department.schema"
export type DepartmentFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
const DEFAULT_VALUES: DepartmentFormValues = {
name: "",
assignment_type: "none",
}
function mapToFormValues(data: unknown): DepartmentFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
name: d.name ?? "",
assignment_type: d.assignment_type ?? "none",
}
}
function mapFormToPayload(values: DepartmentFormValues) {
return {
name: values.name,
assignment_type: values.assignment_type || undefined,
}
}
export function DepartmentForm({ resourceId, initialData, onSuccess }: DepartmentFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<DepartmentFormValues, any>({
schema: departmentFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
queryKey: [DEPARTMENT_ROUTES.BY_ID, resourceId],
mapToFormValues,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: DepartmentFormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing && resourceId
? api.departments.update(resourceId, payload)
: api.departments.create(payload)
toast.promise(promise, {
loading: isEditing ? "Updating department..." : "Creating department...",
success: isEditing ? "Department updated successfully" : "Department created successfully",
error: isEditing ? "Failed to update department" : "Failed to create department",
})
return promise
},
onSuccess: () => {
form.reset()
onSuccess?.()
},
})
return (
<Rhform form={form} onSubmit={(values) => mutate(values)}>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="me-2 h-4 w-4" />
<AlertTitle>
{isEditing ? "Failed to update department" : "Failed to create department"}
</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<RhfTextField
name="name"
label="Name"
placeholder="e.g. Mechanical"
required
/>
<RhfSelectField
name="assignment_type"
label="Assignment Type"
placeholder="Select assignment type"
options={[...DEPARTMENT_ASSIGNMENT_TYPE_OPTIONS]}
/>
<Button type="submit" variant="default" disabled={isPending}>
{isEditing ? <Save /> : <Plus />}
{isPending
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update Department" : "Create Department")}
</Button>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,8 @@
import { z } from "zod"
export const departmentFormSchema = z.object({
name: z.string().min(1, "Name is required"),
assignment_type: z.string().optional(),
})
export type DepartmentFormValues = z.infer<typeof departmentFormSchema>

View File

@ -0,0 +1,102 @@
"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 } 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 { INSURANCE_TYPE_ROUTES } from "@garage/api"
import { insuranceTypeFormSchema, type InsuranceTypeFormValues } from "./insurance-type.schema"
export type InsuranceTypeFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
const DEFAULT_VALUES: InsuranceTypeFormValues = {
title: "",
}
function mapToFormValues(data: unknown): InsuranceTypeFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
title: d.title ?? d.name ?? "",
}
}
function mapFormToPayload(values: InsuranceTypeFormValues) {
return {
title: values.title,
}
}
export function InsuranceTypeForm({ resourceId, initialData, onSuccess }: InsuranceTypeFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<InsuranceTypeFormValues, any>({
schema: insuranceTypeFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
queryKey: [INSURANCE_TYPE_ROUTES.BY_ID, resourceId],
mapToFormValues,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: InsuranceTypeFormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing && resourceId
? api.insuranceTypes.update(resourceId, payload)
: api.insuranceTypes.create(payload)
toast.promise(promise, {
loading: isEditing ? "Updating insurance type..." : "Creating insurance type...",
success: isEditing ? "Insurance type updated successfully" : "Insurance type created successfully",
error: isEditing ? "Failed to update insurance type" : "Failed to create insurance type",
})
return promise
},
onSuccess: () => {
form.reset()
onSuccess?.()
},
})
return (
<Rhform form={form} onSubmit={(values) => mutate(values)}>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="me-2 h-4 w-4" />
<AlertTitle>
{isEditing ? "Failed to update insurance type" : "Failed to create insurance type"}
</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<RhfTextField
name="title"
label="Title"
placeholder="e.g. Comprehensive"
required
/>
<Button type="submit" variant="default" disabled={isPending}>
{isEditing ? <Save /> : <Plus />}
{isPending
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update Insurance Type" : "Create Insurance Type")}
</Button>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,7 @@
import { z } from "zod"
export const insuranceTypeFormSchema = z.object({
title: z.string().min(1, "Title is required"),
})
export type InsuranceTypeFormValues = z.infer<typeof insuranceTypeFormSchema>

View File

@ -0,0 +1,209 @@
"use client"
import { useRef, useState } from "react"
import { useFormContext, useController, type FieldValues, type FieldPath } from "react-hook-form"
import { useQuery } from "@tanstack/react-query"
import { Car, Loader2 } from "lucide-react"
import { useAuthApi } from "@/shared/useApi"
import { VEHICLE_ROUTES } from "@garage/api"
import { FieldShell } from "@/shared/components/form/field-shell"
import {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxEmpty,
} from "@/shared/components/ui/combobox"
import { Badge } from "@/shared/components/ui/badge"
import Image from "next/image"
// ── Vehicle option type (enriched for display) ──
type VehicleOption = {
value: string
label: string
make?: string
model?: string
year?: string
sub_model?: string
license_plate?: string
image_url?: string
mileage?: string
}
function buildVehicleOption(item: any): VehicleOption {
const label =
[item.year, item.make, item.model].filter(Boolean).join(" ") ||
`Vehicle #${item.id}`
return {
value: String(item.id),
label,
make: item.make,
model: item.model,
year: item.year,
sub_model: item.sub_model,
license_plate: item.license_plate,
image_url: item.image_url,
mileage: item.mileage,
}
}
function extractItems(response: unknown): any[] {
if (Array.isArray(response)) return response
const obj = response as any
if (Array.isArray(obj?.data?.data)) return obj.data.data
if (Array.isArray(obj?.data)) return obj.data
return []
}
// ── Props ──
export type RhfVehicleSelectFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
> = {
name: TName
label?: string
description?: string
required?: boolean
disabled?: boolean
placeholder?: string
}
// ── Component ──
export function RhfVehicleSelectField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
>({
name,
label = "Vehicle",
description,
required,
disabled,
placeholder = "Search by make, model, year, or plate...",
}: RhfVehicleSelectFieldProps<TValues, TName>) {
const api = useAuthApi()
const anchorRef = useRef<HTMLDivElement>(null)
const [inputValue, setInputValue] = useState("")
const { control } = useFormContext<TValues>()
const {
field,
fieldState: { error },
} = useController({ name, control, disabled })
const { data: options = [], isLoading } = useQuery<VehicleOption[]>({
queryKey: [VEHICLE_ROUTES.INDEX, "vehicle-select"],
queryFn: async () => {
const res = await api.vehicles.list()
return extractItems(res).map(buildVehicleOption)
},
staleTime: 5 * 60 * 1000,
})
const filtered = inputValue
? options.filter((v) =>
[v.year, v.make, v.model, v.sub_model, v.license_plate]
.filter(Boolean)
.join(" ")
.toLowerCase()
.includes(inputValue.toLowerCase()),
)
: options
return (
<FieldShell
label={label}
error={error?.message}
description={description}
required={required}
>
<div ref={anchorRef}>
<Combobox
value={field.value}
onValueChange={(val: VehicleOption | VehicleOption[] | null) => {
const single = Array.isArray(val) ? val[0] ?? null : val
// Store only { value, label } to stay compatible with relationFieldSchema
field.onChange(
single ? { value: single.value, label: single.label } : null,
)
}}
disabled={field.disabled}
onInputValueChange={(val: string, { reason }: { reason: string }) => {
if (reason === "input-change") setInputValue(val)
}}
// Compare by id string so the selected item highlights correctly
// even when the stored form value has fewer fields than VehicleOption
isItemEqualToValue={(item: VehicleOption, val: any) =>
item?.value === val?.value
}
>
<ComboboxInput
placeholder={placeholder}
showClear={!!field.value}
onBlur={field.onBlur}
aria-invalid={!!error || undefined}
/>
<ComboboxContent anchor={anchorRef}>
<ComboboxList>
{isLoading && (
<div className="flex items-center justify-center py-6">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
)}
{!isLoading &&
filtered.map((opt) => (
<ComboboxItem key={opt.value} value={opt}>
<div className="flex items-center gap-3 py-0.5 w-full min-w-0">
{/* Thumbnail */}
{opt.image_url ? (
<Image
height={60}
width={60}
src={opt.image_url}
alt=""
className="size-9 shrink-0 rounded-md object-cover border border-border"
/>
) : (
<div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground border border-border">
<Car className="size-4" />
</div>
)}
{/* Identity */}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm font-medium leading-none">
{[opt.year, opt.make, opt.model]
.filter(Boolean)
.join(" ")}
{opt.sub_model && (
<span className="ml-1 font-normal text-muted-foreground">
{opt.sub_model}
</span>
)}
</span>
{opt.license_plate && (
<span className="font-mono text-xs text-muted-foreground">
{opt.license_plate}
</span>
)}
</div>
</div>
</ComboboxItem>
))}
{!isLoading && filtered.length === 0 && (
<ComboboxEmpty>No vehicles found</ComboboxEmpty>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
</FieldShell>
)
}

View File

@ -0,0 +1,5 @@
import { VehicleListResponse } from "@garage/api"
export const getVehicleLabel = (vehicle: VehicleListResponse) => {
return `${vehicle.make} ${vehicle.model} ${vehicle.sub_model} (${vehicle.year} - ${vehicle.license_plate})`
}

Some files were not shown because too many files have changed in this diff Show More