This commit is contained in:
Mohammad Khyata 2026-04-07 06:32:40 +03:00
parent 24a44481a0
commit 11db1e6941
101 changed files with 54585 additions and 39160 deletions

304
.github/skills/crud-dialog/SKILL.md vendored Normal file
View File

@ -0,0 +1,304 @@
---
name: crud-dialog
description: "Create CRUD dialogs for managing lookup/reference resources inline (inside a dialog) rather than a full page. Use when: adding a config/manage button next to a select field, building an inline CRUD for a simple lookup entity (e.g. insurance types, payment terms, categories), embedding list+create+edit+delete inside a modal. Uses the shared CrudDialog component and useCrudDialog hook."
---
# CRUD Dialog Generator
Create fully functional CRUD dialogs that embed list + create + edit + delete inside a modal dialog. This is the in-dialog counterpart of the page-level `ResourcePage` pattern. Ideal for managing simple lookup/reference entities without navigating away from the current form.
## When to Use
- User wants a config/settings button next to a select field to manage its options
- User wants to manage a simple lookup entity (e.g. insurance types, categories, tags) inline
- The resource is simple enough that a full page is overkill
- User says "CRUD in a dialog", "manage inside a modal", "config button", "inline CRUD"
## When NOT to Use
- The resource is complex with many fields, relations, or tabs → use **crud-page** skill instead
- The resource already has a dedicated page → link to it instead
- Only creation is needed (no listing/editing) → use `RhfAsyncSelectField` with `createForm` prop instead
## Architecture
The CRUD Dialog system has two layers:
| Layer | File | Purpose |
|-------|------|---------|
| **Hook** | `shared/components/crud-dialog/use-crud-dialog.ts` | Local state for pagination, sorting, form open/close, delete confirmation. No URL pollution. |
| **Component** | `shared/components/crud-dialog/crud-dialog.tsx` | Renders trigger button → Dialog with DataTable (list view) ↔ Form (create/edit view). Uses `useCrudDialog` internally. |
### Key Differences from ResourcePage
| Aspect | ResourcePage | CrudDialog |
|--------|-------------|------------|
| Renders in | Full page | Dialog modal |
| Pagination state | URL query params (`nuqs`) | Local `useState` (no URL pollution) |
| Form rendering | `FormDialog` component | Inline view swap (list ↔ form) |
| Trigger | Page navigation | Button click (settings icon by default) |
| Use case | Primary resource management | Lookup/reference entity management |
## Procedure
### Step 1: Ensure API Client Exists
The resource needs a client with `list`, `create`, `update`, `destroy` methods. Check `packages/api/src/clients/`. If missing, create one following the **crud-page** skill's Step 2.
### Step 2: Create the Resource Form
Create a simple form component for the resource. This is a lightweight form (no `useResourceForm` needed for simple entities).
**File**: `apps/dashboard/modules/<parent-module>/<resource>-form.tsx`
**Template**:
```tsx
"use client"
import { z } from "zod"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { Plus, Save } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { FieldGroup } from "@/shared/components/ui/field"
import { Rhform, RhfTextField } from "@/shared/components/form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { useEffect } from "react"
const schema = z.object({
name: z.string().min(1, "Name is required"),
// Add more fields as needed
})
type FormValues = z.infer<typeof schema>
type Props = {
resourceId?: string | null
initialData?: any
onSuccess?: () => void
}
export function <Resource>Form({ resourceId, initialData, onSuccess }: Props) {
const api = useAuthApi()
const isEditing = !!resourceId
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { name: "" },
})
// Pre-fill when editing
useEffect(() => {
if (initialData) {
const d = initialData?.data ?? initialData
form.reset({ name: d.name ?? "" })
}
}, [initialData, form])
const handleSubmit = async (values: FormValues) => {
try {
const promise = isEditing
? api.<resource>.update(resourceId!, { title: values.name } as any)
: api.<resource>.create({ title: values.name } as any)
toast.promise(promise, {
loading: isEditing ? "Updating..." : "Creating...",
success: isEditing ? "Updated successfully" : "Created successfully",
error: isEditing ? "Failed to update" : "Failed to create",
})
await promise
form.reset()
onSuccess?.()
} catch {
// toast already shown
}
}
return (
<Rhform form={form} onSubmit={handleSubmit}>
<FieldGroup>
<RhfTextField name="name" label="Name" placeholder="e.g. My Item" required />
<Button type="submit" disabled={form.formState.isSubmitting}>
{isEditing ? <Save className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
{form.formState.isSubmitting
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update" : "Create")}
</Button>
</FieldGroup>
</Rhform>
)
}
```
### Step 3: Create the CrudDialog Instance
Wire the form into a `CrudDialog` component.
**File**: `apps/dashboard/modules/<parent-module>/<resource>-crud-dialog.tsx`
**Template**:
```tsx
"use client"
import { CrudDialog } from "@/shared/components/crud-dialog"
import { ColumnHeader } from "@/shared/data-view/table-view"
import { useAuthApi } from "@/shared/useApi"
import { <RESOURCE>_ROUTES } from "@garage/api"
import { <Resource>Form } from "./<resource>-form"
export function <Resource>CrudDialog() {
const api = useAuthApi()
return (
<CrudDialog
title="<Resource Label>"
queryKey={[<RESOURCE>_ROUTES.INDEX]}
getClient={() => api.<resource>}
resourceLabel="<resource label>"
columns={() => [
{
accessorKey: "name",
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
},
// Add more columns as needed
]}
renderForm={({ resourceId, initialData, onSuccess }) => (
<<Resource>Form
resourceId={resourceId}
initialData={initialData}
onSuccess={onSuccess}
/>
)}
/>
)
}
```
### Step 4: Wire into the Parent Form
Place the CrudDialog trigger next to the corresponding select field. The pattern is to add a custom label row with the config button:
```tsx
import { <Resource>CrudDialog } from "./<resource>-crud-dialog"
// Inside the form JSX:
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium"><Field Label></span>
<<Resource>CrudDialog />
</div>
<RhfAsyncSelectField
name="<field_name>"
label=""
placeholder="Select..."
queryKey={[<RESOURCE>_ROUTES.INDEX]}
listFn={() => api.<resource>.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
</div>
```
**Key**: Set `label=""` on the `RhfAsyncSelectField` since the label is rendered manually above with the config button.
## CrudDialog Props Reference
```typescript
type CrudDialogProps<TClient> = {
/** Dialog title shown in the header */
title: string
/** React Query cache key */
queryKey: string[]
/** Function returning the API client instance */
getClient: () => TClient
/** Human-readable name for toast messages (e.g. "insurance type") */
resourceLabel?: string
/** Table columns definition */
columns: (helpers: {
openEdit: (row: any) => void
handleDelete: (row: any) => Promise<void>
}) => ColumnDef<any>[]
/** Render create/edit form */
renderForm: (props: {
resourceId: string | null
initialData: any
onSuccess: () => void
}) => React.ReactNode
/** Optional custom trigger element (defaults to Settings2 icon) */
trigger?: React.ReactNode
/** CSS class for the default trigger button */
triggerClassName?: string
}
```
## useCrudDialog Hook API
For advanced use cases where you need more control, use the hook directly:
```typescript
const crud = useCrudDialog({
queryKey: [ROUTES.INDEX],
getClient: () => api.resource,
resourceLabel: "item",
})
// Returns:
crud.items // Current page data
crud.isLoading // Query loading state
crud.pagination // { page, pageSize, pageCount, total }
crud.sorting // SortingState
crud.handleChange // DataViewChangeEvent handler
crud.isFormOpen // Whether form view is active
crud.editingId // ID being edited (null for create)
crud.editingItem // Full item data being edited
crud.openCreate() // Switch to create form
crud.openEdit(row) // Switch to edit form
crud.closeForm() // Back to list view
crud.handleDelete(row) // Confirm + delete
crud.handleFormSuccess() // Invalidate + close form
crud.invalidateQuery() // Refresh list data
```
## Real Example: Insurance Type
See the implementation in `apps/dashboard/modules/job-cards/`:
- [insurance-type-form.tsx](../../apps/dashboard/modules/job-cards/insurance-type-form.tsx) — Simple form with one "name" field
- [insurance-type-crud-dialog.tsx](../../apps/dashboard/modules/job-cards/insurance-type-crud-dialog.tsx) — CrudDialog wiring
- [job-card-form.tsx](../../apps/dashboard/modules/job-cards/job-card-form.tsx) — Usage next to the insurance type select field
## Naming Conventions
| Item | Pattern | Example |
|------|---------|---------|
| Form file | `modules/<parent>/<resource>-form.tsx` | `job-cards/insurance-type-form.tsx` |
| CrudDialog file | `modules/<parent>/<resource>-crud-dialog.tsx` | `job-cards/insurance-type-crud-dialog.tsx` |
| Form component | `<Resource>Form` | `InsuranceTypeForm` |
| CrudDialog component | `<Resource>CrudDialog` | `InsuranceTypeCrudDialog` |
## Imports Cheat Sheet
```tsx
// CrudDialog component
import { CrudDialog } from "@/shared/components/crud-dialog"
// Table column header
import { ColumnHeader } from "@/shared/data-view/table-view"
// API
import { useAuthApi } from "@/shared/useApi"
import { <RESOURCE>_ROUTES } from "@garage/api"
// Form components (for the resource form)
import { Rhform, RhfTextField } from "@/shared/components/form"
import { Button } from "@/shared/components/ui/button"
import { FieldGroup } from "@/shared/components/ui/field"
import { z } from "zod"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { toast } from "sonner"
```

View File

@ -0,0 +1,73 @@
---
name: shared-formatters
description: "Use the central shared/utils/formatters.ts file for all display formatting in the dashboard. Use when: formatting dates, times, numbers, currencies, or enum strings in table cells, detail pages, or any UI display. Avoid inline formatting logic like `toLocaleDateString()`, `.toLocaleString()`, or manual string splits. Import from @/shared/utils/formatters."
---
# Shared Formatters
Use this skill whenever work involves displaying dates, times, numbers, currencies, or enum strings in any UI component.
## Source of Truth
All shared display formatters live in:
```
apps/dashboard/shared/utils/formatters.ts
```
Import path: `@/shared/utils/formatters`
## Available Formatters
| Function | Input | Output example |
|---|---|---|
| `formatDate(value)` | string \| Date \| null | `"Jan 6, 2026"` |
| `formatDateTime(value)` | string \| Date \| null | `"Jan 6, 2026, 2:30 PM"` |
| `formatDateShort(value)` | string \| Date \| null | `"04/06/2026"` |
| `formatTime(value)` | string \| Date \| null | `"2:30 PM"` |
| `formatEnum(value)` | string \| null | `"In Progress"` |
| `formatNumber(value)` | number \| string \| null | `"150,000"` |
| `formatCurrency(value, currency?, locale?)` | number \| string \| null | `"$1,500.00"` |
All functions return `"—"` for null/undefined/invalid input — never return an empty string or throw.
## Rules
1. **Never inline formatting.** Do not use `new Date(x).toLocaleDateString()`, `Number(x).toLocaleString()`, or manual `split("_")` chains in components or pages. Use the shared formatters instead.
2. **Add before duplicating.** Check `formatters.ts` for an existing formatter before writing a new one. If a new formatter is needed, add it to `formatters.ts` — not inline.
3. **Keep all formatters in one file.** Do not create separate formatter files per module. All display formatting stays in `shared/utils/formatters.ts`.
4. **Consistent null handling.** Every formatter accepts `null | undefined` and returns `"—"`. Never require callers to guard against null before calling.
5. **Use `formatEnum` for status/type fields.** Any snake_case or underscore-separated enum value displayed as text must go through `formatEnum`.
## Workflow
1. Identify a display value needing formatting in a component.
2. Check `formatters.ts` for an existing matching formatter.
3. If found, import and use it.
4. If missing, add to `formatters.ts` following the null-safety pattern, then import.
5. Remove any inline formatting that the shared formatter now replaces.
## Examples
```tsx
import { formatDate, formatEnum, formatNumber, formatCurrency } from "@/shared/utils/formatters"
// Table cell — date
cell: ({ row }) => formatDate(row.original.created_at)
// Table cell — enum status
cell: ({ row }) => <Badge>{formatEnum(row.original.status)}</Badge>
// Table cell — number
cell: ({ row }) => formatNumber(row.original.km_in)
// Table cell — currency
cell: ({ row }) => formatCurrency(row.original.total_amount)
// Detail page field
<p>{formatDateTime(jobCard.updated_at)}</p>
```

View File

@ -12,7 +12,7 @@ import { redirect } from "next/navigation"
function Logo() {
return (
<div className="flex items-center gap-2">
<Image alt="Logo" src={'/assets/logo.png'} height={200} width={200} />
<Image alt="Logo" src={'/assets/logo.png'} height={100} width={100} />
</div>
)
}

View File

@ -4,7 +4,7 @@ import { DashboardContent } from "@/modules/home/dashboard-content";
export default function page() {
return (
<DashboardPage header={<DashboardHeader />} title="Dashboard">
<DashboardPage headerProps={{title: "Dashboard"}} >
<DashboardContent />
</DashboardPage>
)

View File

@ -0,0 +1,57 @@
"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 { TaskForm } from "@/modules/tasks/task-form"
import { TASK_ROUTES } from "@garage/api"
import type { TasksClient } from "@garage/api"
export default function TasksPage() {
return (
<ResourcePage<TasksClient>
routeKey={TASK_ROUTES.INDEX}
getClient={(api) => api.tasks}
headerProps={({ selectedItem, invalidateQuery }) => ({
title: "Tasks",
actions: (
<FormDialog title="Task">
{(resourceId) => (
<TaskForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "task_number",
header: ({ column }) => <ColumnHeader column={column} title="Task #" />,
},
{
accessorKey: "subject",
header: ({ column }) => <ColumnHeader column={column} title="Subject" />,
},
{
accessorKey: "priority",
header: ({ column }) => <ColumnHeader column={column} title="Priority" />,
},
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
},
{
accessorKey: "due_date",
header: ({ column }) => <ColumnHeader column={column} title="Due Date" />,
},
actionsColumn(),
]}
/>
)
}

View File

@ -35,13 +35,13 @@ export default function JobCardAppointmentsPage({
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 }) => (
tableHeader={({ invalidateQuery, selectedItem, closeDialog }) => (
<div className="flex justify-end">
<FormDialog title="Appointment">
{(resourceId) => (
<AppointmentForm
@ -51,6 +51,7 @@ export default function JobCardAppointmentsPage({
/>
)}
</FormDialog>
</div>
)}
columns={({ actionsColumn }) => [
{

View File

@ -85,8 +85,9 @@ export default function JobCardAttachmentsPage() {
return (
<DashboardPage
header={null}
toolbar={
<div className="flex items-center justify-end">
>
<div className="flex items-center justify-end mb-4">
<input
ref={fileInputRef}
type="file"
@ -101,11 +102,7 @@ export default function JobCardAttachmentsPage() {
<Plus className="size-4" />
{isUploading ? "Uploading..." : "Upload Attachment"}
</Button>
</div>}
title="Attachments"
>
</div>
{attachments?.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
@ -114,6 +111,7 @@ export default function JobCardAttachmentsPage() {
</Card>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{attachments?.map((attachment) => {
const Icon = getFileIcon(attachment.attachment_path)
return (

View File

@ -24,12 +24,13 @@ export default function JobCardBillsPage({
return (
<ResourcePage<BillsClient>
pageTitle="Bills"
routeKey={BILL_ROUTES.INDEX}
getClient={(api) => api.bills}
extraParams={{ job_card_id: jobCardId }}
header={null}
toolbar={({ invalidateQuery, selectedItem, closeDialog }) => (
tableHeader={({ invalidateQuery, selectedItem, closeDialog }) => (
<div className="flex justify-end">
<FormDialog title="Bill">
{(resourceId) => (
<BillForm
@ -39,6 +40,7 @@ export default function JobCardBillsPage({
/>
)}
</FormDialog>
</div>
)}
columns={({ actionsColumn }) => [
{

View File

@ -1,153 +0,0 @@
"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 } from "lucide-react"
import { toast } from "sonner"
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 { Card, CardContent } from "@/shared/components/ui/card"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { JobCardRemarkForm } from "@/modules/job-cards/job-card-remark-form"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
type CustomerRemark = {
id: number
job_card_id?: number
remark?: string
created_at: string
updated_at: string
}
export default function CustomerRemarksPage() {
const { id: jobCardId } = useParams<{ id: string }>()
const api = useAuthApi()
const queryClient = useQueryClient()
const [dialogOpen, setDialogOpen] = useState(false)
const queryKey = ["job-card-remarks", jobCardId]
const { data, isLoading } = useQuery({
queryKey,
queryFn: async () => {
// const result = await api.jobCards.
},
})
const deleteMutation = useMutation({
mutationFn: () => api.jobCards.deleteCustomerRemark(jobCardId),
onSuccess: () => {
toast.success("Customer remark deleted successfully.")
queryClient.invalidateQueries({ queryKey })
},
onError: () => {
toast.error("Failed to delete customer remark.")
},
})
const handleDelete = async (remark: CustomerRemark) => {
const confirmed = await confirm({
title: "Delete Customer Remark",
description: "Are you sure you want to delete this remark?",
confirmLabel: "Delete",
variant: "destructive",
})
if (confirmed) {
deleteMutation.mutate()
}
}
const columns: ColumnDef<CustomerRemark>[] = [
{
accessorKey: "remark",
header: ({ column }) => <ColumnHeader column={column} title="Remark" />,
cell: ({ getValue }) => {
const val = getValue<string>()
return val || "—"
},
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
cell: ({ getValue }) => {
const val = getValue<string>()
return val ? new Date(val).toLocaleDateString() : "—"
},
},
{
id: "actions",
header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => (
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleDelete(row.original)}
title="Delete remark"
>
<Trash2 className="size-4 text-destructive" />
</Button>
),
enableSorting: false,
},
]
const remarks = Array.isArray(data) ? data : []
const pagination = {
page: 1,
pageSize: 100,
pageCount: 1,
total: remarks.length,
}
return (
<DashboardPage header={null} title="Customer Remarks" toolbar={ <div className="flex items-center justify-end">
<Button onClick={() => setDialogOpen(true)}>
<Plus className="size-4" />
Add Customer Remark
</Button>
</div>}>
<div className="flex flex-col gap-4">
<Card>
<CardContent>
<DataTable
columns={columns}
data={remarks}
pagination={pagination}
isLoading={isLoading}
/>
</CardContent>
</Card>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Customer Remark</DialogTitle>
</DialogHeader>
<JobCardRemarkForm
jobCardId={jobCardId}
onSuccess={() => {
setDialogOpen(false)
queryClient.invalidateQueries({ queryKey })
}}
/>
</DialogContent>
</Dialog>
</div>
</DashboardPage>
)
}

View File

@ -24,12 +24,13 @@ export default function JobCardExpensesPage({
return (
<ResourcePage<ExpensesClient>
pageTitle="Expenses"
routeKey={EXPENSE_ROUTES.INDEX}
getClient={(api) => api.expenses}
extraParams={{ job_card_id: jobCardId }}
header={null}
toolbar={({ invalidateQuery, selectedItem, closeDialog }) => (
tableHeader={({ invalidateQuery, selectedItem, closeDialog }) => (
<div className="flex justify-end">
<FormDialog title="Expense">
{(resourceId) => (
<ExpenseForm
@ -39,6 +40,7 @@ export default function JobCardExpensesPage({
/>
)}
</FormDialog>
</div>
)}
columns={({ actionsColumn }) => [
{

View File

@ -19,7 +19,7 @@ export default async function JobCardDetailLayout(props: { params: Promise<{ id:
const docs = jobCard?.documents
return (
<JobCardProvider jobCard={{ ...jobCard, label: title }}>
<JobCardProvider jobCard={{ ...jobCard }}>
<DashboardDetailsPage
className='p-0 lg:p-0'
title={title}
@ -35,14 +35,6 @@ export default async function JobCardDetailLayout(props: { params: Promise<{ id:
href: `/sales/job-cards/${id}`,
label: 'Details'
},
{
href: `/sales/job-cards/${id}/customer-remarks`,
label: 'Customer Remarks'
},
{
href: `/sales/job-cards/${id}/shop-recommendations`,
label: 'Shop Recommendations'
},
{
href: `/sales/job-cards/${id}/attachments`,
label: `Attachments (${docs?.length || 0})`
@ -67,6 +59,10 @@ export default async function JobCardDetailLayout(props: { params: Promise<{ id:
href: `/sales/job-cards/${id}/tasks`,
label: `Tasks (${jobCard?.tasks_count || 0})`
},
{
href: `/sales/job-cards/${id}/parts`,
label: `Parts (${jobCard?.parts_count || 0})`
},
]}
>
{props.children}

View File

@ -0,0 +1,196 @@
"use client"
import { use, useState } from "react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { useAuthApi } from "@/shared/useApi"
import { ColumnHeader, DataTable } from "@/shared/data-view/table-view"
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { confirm } from "@/shared/components/confirm-dialog"
import { toast } from "sonner"
import { Ellipsis, Plus } from "lucide-react"
import type { ColumnDef } from "@tanstack/react-table"
import { JobCardPartForm } from "@/modules/job-cards/job-card-part-form"
import { formatDate } from "@/shared/utils/formatters"
export default function JobCardPartsPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id: jobCardId } = use(params)
const api = useAuthApi()
const queryClient = useQueryClient()
const queryKey = ["job-card-parts", jobCardId]
const [dialogOpen, setDialogOpen] = useState(false)
const [editItem, setEditItem] = useState<any | null>(null)
const { data, isLoading } = useQuery({
queryKey,
queryFn: () => api.jobCards.getParts(jobCardId),
})
const rows = (data as any)?.data ?? []
const invalidate = () => queryClient.invalidateQueries({ queryKey })
async function handleDelete(row: any) {
const confirmed = await confirm({
title: "Delete this part?",
description: `Remove part "${row.part?.title ?? "this part"}" from the job card?`,
})
if (!confirmed) return
const promise = api.jobCards.deletePart(jobCardId, row.id)
toast.promise(promise, {
loading: "Deleting...",
success: "Part deleted",
error: "Failed to delete part",
})
await promise
invalidate()
}
const columns: ColumnDef<any>[] = [
{
accessorKey: "part.title",
header: ({ column }) => <ColumnHeader column={column} title="Part" />,
cell: ({ row }) => {
const part = row.original.part
return part ? (
<div>
<span className="font-medium">{part.title}</span>
{part.sku && (
<span className="ml-2 text-xs text-muted-foreground">{part.sku}</span>
)}
</div>
) : "—"
},
},
{
accessorKey: "quantity",
header: ({ column }) => <ColumnHeader column={column} title="Qty" />,
cell: ({ row }) => row.original.quantity ?? "—",
},
{
accessorKey: "rate",
header: ({ column }) => <ColumnHeader column={column} title="Rate" />,
cell: ({ row }) => {
const val = row.original.rate
return val != null ? `$${Number(val).toFixed(2)}` : "—"
},
},
{
accessorKey: "tax",
header: ({ column }) => <ColumnHeader column={column} title="Tax" />,
cell: ({ row }) => row.original.tax || "—",
},
{
accessorKey: "department.name",
header: ({ column }) => <ColumnHeader column={column} title="Department" />,
cell: ({ row }) => row.original.department?.name || "—",
},
{
accessorKey: "description",
header: ({ column }) => <ColumnHeader column={column} title="Description" />,
cell: ({ row }) => row.original.description || "—",
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Added" />,
cell: ({ row }) => formatDate(row.original.created_at),
},
{
id: "actions",
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Ellipsis className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setEditItem(row.original)
setDialogOpen(true)
}}
>
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => handleDelete(row.original)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
]
return (
<div className="flex flex-col gap-4 p-4">
<div className="flex justify-end">
<Dialog
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open)
if (!open) setEditItem(null)
}}
>
<DialogTrigger asChild>
<Button onClick={() => setEditItem(null)}>
<Plus className="me-2 h-4 w-4" />
Add Part
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{editItem ? "Edit Part" : "Add Part"}</DialogTitle>
</DialogHeader>
<JobCardPartForm
jobCardId={jobCardId}
jobCardPartId={editItem?.id ?? null}
initialData={editItem}
onSuccess={() => {
setDialogOpen(false)
setEditItem(null)
invalidate()
}}
onCancel={() => {
setDialogOpen(false)
setEditItem(null)
}}
/>
</DialogContent>
</Dialog>
</div>
<DataTable
columns={columns}
data={rows}
pagination={{
page: 1,
pageSize: rows.length || 15,
pageCount: 1,
total: rows.length,
}}
isLoading={isLoading}
/>
</div>
)
}

View File

@ -23,12 +23,13 @@ export default function JobCardPurchaseOrdersPage({
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 }) => (
tableHeader={({ invalidateQuery, selectedItem, closeDialog }) => (
<div className="flex justify-end">
<FormDialog title="Purchase Order">
{(resourceId) => (
<PurchaseOrderForm
@ -38,6 +39,8 @@ export default function JobCardPurchaseOrdersPage({
/>
)}
</FormDialog>
</div>
)}
columns={({ actionsColumn }) => [
{

View File

@ -1,157 +0,0 @@
"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 } from "lucide-react"
import { toast } from "sonner"
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 { Card, CardContent } from "@/shared/components/ui/card"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { JobCardRecommendationForm } from "@/modules/job-cards/job-card-recommendation-form"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
type ShopRecommendation = {
id: number
job_card_id?: number
recommendation?: string
created_at: string
updated_at: string
}
export default function ShopRecommendationsPage() {
const { id: jobCardId } = useParams<{ id: string }>()
const api = useAuthApi()
const queryClient = useQueryClient()
const [dialogOpen, setDialogOpen] = useState(false)
const queryKey = ["job-card-recommendations", jobCardId]
const { data, isLoading } = useQuery({
queryKey,
queryFn: async () => {
const result = await api.jobCards.show(jobCardId)
const d = (result as any)?.data ?? result
return d?.shop_recommendations ?? []
},
})
const deleteMutation = useMutation({
mutationFn: () => api.jobCards.deleteShopRecommendation(jobCardId),
onSuccess: () => {
toast.success("Shop recommendation deleted successfully.")
queryClient.invalidateQueries({ queryKey })
},
onError: () => {
toast.error("Failed to delete shop recommendation.")
},
})
const handleDelete = async (rec: ShopRecommendation) => {
const confirmed = await confirm({
title: "Delete Shop Recommendation",
description: "Are you sure you want to delete this recommendation?",
confirmLabel: "Delete",
variant: "destructive",
})
if (confirmed) {
deleteMutation.mutate()
}
}
const columns: ColumnDef<ShopRecommendation>[] = [
{
accessorKey: "recommendation",
header: ({ column }) => <ColumnHeader column={column} title="Recommendation" />,
cell: ({ getValue }) => {
const val = getValue<string>()
return val || "—"
},
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
cell: ({ getValue }) => {
const val = getValue<string>()
return val ? new Date(val).toLocaleDateString() : "—"
},
},
{
id: "actions",
header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => (
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleDelete(row.original)}
title="Delete recommendation"
>
<Trash2 className="size-4 text-destructive" />
</Button>
),
enableSorting: false,
},
]
const recommendations = Array.isArray(data) ? data : []
const pagination = {
page: 1,
pageSize: 100,
pageCount: 1,
total: recommendations.length,
}
return (
<DashboardPage
header={null}
title="Shop Recommendations"
toolbar={
<Button onClick={() => setDialogOpen(true)}>
<Plus className="size-4 me-2" />
Add Recommendation
</Button>
}
>
<Card>
<CardContent>
<DataTable
columns={columns}
data={recommendations}
pagination={pagination}
sorting={[]}
onChange={() => {}}
isLoading={isLoading}
/>
</CardContent>
</Card>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Shop Recommendation</DialogTitle>
</DialogHeader>
<JobCardRecommendationForm
jobCardId={jobCardId}
onSuccess={() => {
setDialogOpen(false)
queryClient.invalidateQueries({ queryKey })
}}
/>
</DialogContent>
</Dialog>
</DashboardPage>
)
}

View File

@ -3,9 +3,13 @@
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 { TaskForm } from "@/modules/tasks/task-form"
import { TASK_ROUTES } from "@garage/api"
import type { TasksClient } from "@garage/api"
import { Badge } from "@/shared/components/ui/badge"
import { useJobCard } from "@/modules/job-cards/job-card-context"
import { formatDate, formatEnum } from "@/shared/utils/formatters"
export default function JobCardTasksPage({
params,
@ -13,54 +17,58 @@ export default function JobCardTasksPage({
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).title || `Job Card` }
: null
return (
<ResourcePage<TasksClient>
pageTitle="Tasks"
routeKey={TASK_ROUTES.TASKS}
routeKey={TASK_ROUTES.INDEX}
getClient={(api) => api.tasks}
extraParams={{ job_card_id: jobCardId }}
header={null}
columns={() => [
tableHeader={({ invalidateQuery, selectedItem, closeDialog }) => (
<div className="flex justify-end">
<FormDialog title="Task">
{(resourceId) => (
<TaskForm
resourceId={resourceId}
initialData={selectedItem ?? { job_card: defaultJobCard }}
onSuccess={() => { closeDialog(); invalidateQuery() }}
/>
)}
</FormDialog>
</div>
)}
columns={({ actionsColumn }) => [
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Task" />,
cell: ({ row }) => (row.original as any).title || "—",
accessorKey: "task_number",
header: ({ column }) => <ColumnHeader column={column} title="Task #" />,
cell: ({ row }) => (row.original as any).task_number || "—",
},
{
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: "subject",
header: ({ column }) => <ColumnHeader column={column} title="Subject" />,
cell: ({ row }) => (row.original as any).subject || "—",
},
{
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() : "—"
},
cell: ({ row }) => formatDate((row.original as any).due_date),
},
{
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> : "—"
},
},
actionsColumn(),
]}
/>
)

View File

@ -12,6 +12,7 @@ import { Badge } from '@/shared/components/ui/badge'
import { Input } from '@/shared/components/ui/input'
import { useRouter } from 'next/navigation'
import { useState, useEffect } from 'react'
import { formatDate, formatEnum, formatNumber } from '@/shared/utils/formatters'
type JobCardItem = {
id: number
@ -31,19 +32,11 @@ const statusColorMap: Record<string, string> = {
cancelled: "destructive",
}
const formatStatus = (status?: string) => {
if (!status) return "—"
return status
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ")
}
export default function JobCardsPage() {
const router = useRouter()
const [searchInput, setSearchInput] = useState("")
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<string>("all")
const [statusFilter, setStatusFilter] = useState<string>("check_in")
useEffect(() => {
const timer = setTimeout(() => setSearch(searchInput), 400)
@ -56,14 +49,15 @@ export default function JobCardsPage() {
return (
<ResourcePage<JobCardsClient>
pageTitle="Job Cards"
routeKey={JOB_CARD_ROUTES.INDEX}
getClient={(api) => api.jobCards}
extraParams={extraParams}
onRowClick={(row) => router.push(`/sales/job-cards/${row.id}`)}
headerProps={({ selectedItem, invalidateQuery }) => ({
title: "Job Cards",
actions: (
<FormDialog title="Job Card">
<FormDialog classNames={{ dialogContent: 'min-w-6xl' }} title="Job Card" >
{(resourceId) => (
<JobCardForm
resourceId={resourceId}
@ -88,28 +82,26 @@ export default function JobCardsPage() {
)
},
},
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const item = row.original as unknown as JobCardItem
return (
<Badge variant={statusColorMap[item.status ?? ""] as any ?? "outline"}>
{formatStatus(item.status)}
</Badge>
)
},
accessorKey: "order_number",
header: ({ column }) => <ColumnHeader column={column} title="Order Number" />,
},
{
accessorKey: "check_in_date",
header: ({ column }) => <ColumnHeader column={column} title="Check-in Date" />,
cell: ({ getValue }) => {
const val = getValue<string>()
return formatDate(val)
}
},
{
accessorKey: "km_in",
accessorKey: "vehicle_id",
header: ({ column }) => <ColumnHeader column={column} title="KM In" />,
cell: ({ row }) => {
const item = row.original as unknown as JobCardItem
return item.km_in ? Number(item.km_in).toLocaleString() : "—"
return item.km_in ? formatNumber(item.km_in) : "—"
},
},
{
@ -117,14 +109,36 @@ export default function JobCardsPage() {
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
cell: ({ row }) => {
const item = row.original as unknown as JobCardItem
return item.created_at ? new Date(item.created_at).toLocaleDateString() : "—"
return formatDate(item.created_at)
},
},
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const item = row.original as unknown as JobCardItem
return (
<Badge variant={statusColorMap[item.status ?? ""] as any ?? "outline"}>
{formatEnum(item.status)}
</Badge>
)
},
},
actionsColumn(),
]}
toolbar={
<div className="flex gap-3 w-full">
tableHeader={
<div className="flex justify-between">
<Tabs value={statusFilter} onValueChange={setStatusFilter}>
<TabsList variant="line">
<TabsTrigger value="all" >All</TabsTrigger>
{JobCardStatus.map((status) => (
<TabsTrigger key={status} value={status}>
{formatEnum(status)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="relative w-64">
<SearchIcon className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
@ -136,18 +150,6 @@ export default function JobCardsPage() {
</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

@ -4,7 +4,7 @@ import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { PaymentReceivedForm } from "@/modules/payment-received/payment-received-form"
import { PAYMENT_ROUTES } from "@garage/api"
import { PAYMENT_RECEIVED_ROUTES } from "@garage/api"
import {
BadgeDollarSignIcon,
CalendarIcon,
@ -31,11 +31,10 @@ type PaymentReceivedItem = {
export default function PaymentReceivedPage() {
return (
<ResourcePage<{ list(query?: any): Promise<any>; destroy(id: string): Promise<any> }>
pageTitle="Payments Received"
routeKey={PAYMENT_ROUTES.RECEIVED}
routeKey={PAYMENT_RECEIVED_ROUTES.INDEX}
getClient={(api) => ({
list: (query?: any) => api.payments.listReceived(query),
destroy: (id: string) => api.payments.destroyReceived(id),
list: (query?: any) => api.paymentReceived.list(query),
destroy: (id: string) => api.paymentReceived.destroy(id),
})}
headerProps={({ invalidateQuery }) => ({
actions: (

View File

@ -1,12 +1,55 @@
"use client"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import { StarIcon, StarOffIcon } from "lucide-react"
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 { Button } from "@/shared/components/ui/button"
import { useAuthApi } from "@/shared/useApi"
import { DEPARTMENT_ROUTES } from "@garage/api"
import type { DepartmentsClient } from "@garage/api"
function FavoriteCell({ row }: { row: any }) {
const api = useAuthApi()
const queryClient = useQueryClient()
const isFavorite: boolean = row.is_favorite ?? false
const { mutate, isPending } = useMutation({
mutationFn: () => {
const promise = isFavorite
? api.departments.removeFavorite({ id: row.id })
: api.departments.setFavorite({ id: row.id })
toast.promise(promise, {
loading: isFavorite ? "Removing from favourites..." : "Setting as favourite...",
success: isFavorite ? "Removed from favourites" : "Set as favourite",
error: isFavorite ? "Failed to remove favourite" : "Failed to set favourite",
})
return promise
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [DEPARTMENT_ROUTES.INDEX] })
},
})
return (
<Button
variant="ghost"
size="icon-sm"
disabled={isPending}
onClick={() => mutate()}
title={isFavorite ? "Remove from favourites" : "Set as favourite"}
>
{isFavorite
? <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 DepartmentsPage() {
return (
<ResourcePage<DepartmentsClient>
@ -36,6 +79,12 @@ export default function DepartmentsPage() {
header: ({ column }) => <ColumnHeader column={column} title="Assignment Type" />,
cell: ({ row }) => (row.original as any).assignment_type ?? "none",
},
{
id: "favourite",
header: () => <span className="sr-only">Favourite</span>,
enableSorting: false,
cell: ({ row }) => <FavoriteCell row={row.original} />,
},
actionsColumn(),
]}
/>

View File

@ -1,11 +1,15 @@
"use client"
import { useCallback } from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { ChevronRight, Circle } from "lucide-react"
import { usePathname, useRouter } from "next/navigation"
import { ChevronRight, Circle, LogOutIcon, UserIcon } from "lucide-react"
import type { NavGroup, NavItem } from "@/base/types/navigation"
import type { UserInfo } from "@/base/types/navigation"
import { useAuthStore } from "@/shared/stores/auth-store"
import { cn } from "@/shared/lib/utils"
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
import {
Collapsible,
CollapsibleContent,
@ -22,6 +26,7 @@ import {
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
@ -38,11 +43,19 @@ import {
type AppSidebarProps = React.ComponentProps<typeof Sidebar> & {
navGroups: NavGroup[]
logo?: React.ReactNode
user?: UserInfo
}
export function AppSidebar({ navGroups, logo, ...props }: AppSidebarProps) {
export function AppSidebar({ navGroups, logo, user, ...props }: AppSidebarProps) {
const { state, isMobile } = useSidebar()
const isCollapsed = state === "collapsed" && !isMobile
const { logout } = useAuthStore((s) => s)
const router = useRouter()
const handleLogout = useCallback(async () => {
await logout()
router.push("/login")
}, [logout, router])
return (
<Sidebar collapsible="icon" {...props} className="bg-card">
@ -71,6 +84,58 @@ export function AppSidebar({ navGroups, logo, ...props }: AppSidebarProps) {
</SidebarGroup>
))}
</SidebarContent>
<SidebarFooter className="p-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="size-8 rounded-lg">
{user?.avatar && <AvatarImage src={user.avatar as string} alt={user.name} />}
<AvatarFallback className="rounded-lg">
{user?.initials ?? user?.name?.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
{!isCollapsed && (
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user?.name}</span>
{user?.email && (
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
)}
</div>
)}
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
sideOffset={4}
className="w-56"
>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col">
<span className="text-sm font-medium">{user?.name}</span>
{user?.email && (
<span className="text-xs text-muted-foreground">{user.email}</span>
)}
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/profile">
<UserIcon />
Profile
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onSelect={handleLogout}>
<LogOutIcon />
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarFooter>
<SidebarRail />
</Sidebar>
)

View File

@ -1,22 +1,15 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useTheme } from "next-themes"
import {
BellIcon,
LogOutIcon,
MoonIcon,
SearchIcon,
SunIcon,
UserIcon,
} from "lucide-react"
import type { UserInfo } from "@/base/types/navigation"
import { useAuthStore } from "@/shared/stores/auth-store"
import { cn } from "@/shared/lib/utils"
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
import { Button } from "@/shared/components/ui/button"
import { SidebarTrigger } from "@/shared/components/ui/sidebar"
import {
@ -28,33 +21,17 @@ import {
CommandGroup,
CommandItem,
} from "@/shared/components/ui/command"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { Separator } from "@/shared/components/ui/separator"
export type DashboardHeaderProps = {
user?: UserInfo
actions?: React.ReactNode
className?: string
title?:string
}
export function DashboardHeader({ actions, className }: DashboardHeaderProps) {
export function DashboardHeader({ actions, className, title }: DashboardHeaderProps) {
const { resolvedTheme, setTheme } = useTheme()
const [searchOpen, setSearchOpen] = useState(false)
const { logout, user } = useAuthStore((s) => s)
const router = useRouter()
const handleLogout = useCallback(async () => {
await logout()
router.push("/login")
}, [logout, router])
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
@ -78,75 +55,16 @@ export function DashboardHeader({ actions, className }: DashboardHeaderProps) {
className,
)}
>
{title && <h1 className="text-lg font-semibold">{title}</h1>}
{/* Sidebar toggle — mobile: hamburger, desktop: collapse */}
<SidebarTrigger className="-ms-2" />
<Separator orientation="vertical" />
<SidebarTrigger className="-ms-2 md:hidden" />
<Separator orientation="vertical" className="md:hidden" />
{/* Left side — default actions */}
<div className="flex items-center gap-1">
{/* User dropdown */}
{/* {user && ( */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center gap-2 px-2">
<Avatar >
{user?.avatar && <AvatarImage src={user?.avatar as string} alt={user?.name} />}
<AvatarFallback>
{user?.initials ?? user?.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="hidden text-sm font-medium md:inline-block">
{user?.name}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{/* User info header */}
<DropdownMenuLabel className="font-normal">
<div className="flex items-center gap-3 py-1">
<Avatar size="lg">
{user?.avatar && <AvatarImage src={user?.avatar as string} alt={user?.name} />}
<AvatarFallback className="text-base">
{user?.initials ?? user?.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className="text-sm font-medium text-foreground">{user?.name}</span>
{user?.email && (
<span className="text-xs text-muted-foreground">{user?.email}</span>
)}
{user?.role && (
<span className="mt-0.5 text-xs font-medium text-primary">{user?.role}</span>
)}
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<Link href="/profile">
<UserIcon />
Profile
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onSelect={handleLogout}>
<LogOutIcon />
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* )} */}
{/* Search trigger */}
<Button
{/* <Button
variant="outline"
className="hidden h-8 w-56 justify-start gap-2 text-muted-foreground md:flex"
onClick={() => setSearchOpen(true)}
@ -156,7 +74,7 @@ export function DashboardHeader({ actions, className }: DashboardHeaderProps) {
<kbd className="pointer-events-none ms-auto inline-flex h-5 select-none items-center gap-0.5 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
K
</kbd>
</Button>
</Button> */}
{/* Mobile search icon */}
<Button

View File

@ -31,11 +31,11 @@ export function DashboardLayout({
return (
<TooltipProvider>
<SidebarProvider defaultOpen={defaultOpen}>
<AppSidebar navGroups={navGroups} logo={logo} />
<SidebarInset>
<div>
<AppSidebar navGroups={navGroups} logo={logo} user={user} />
<SidebarInset className="min-w-0">
{children}
</div>
</SidebarInset>
</SidebarProvider>
</TooltipProvider>

View File

@ -6,39 +6,25 @@ type DashboardPageProps = {
children: React.ReactNode
header?: React.ReactNode | null
headerProps?: DashboardHeaderProps
toolbar?: React.ReactNode
title?: string
fullscreen?: boolean
}
export default function DashboardPage({ children, header, headerProps, title, fullscreen, toolbar }: DashboardPageProps) {
export default function DashboardPage({ children, header, headerProps, fullscreen, }: DashboardPageProps) {
const resolvedHeader = header !== undefined
? header
: <DashboardHeader {...headerProps} />
return (
<div className='page'>
<div className={cn("flex flex-col gap-4", fullscreen && "gap-0")}>
{resolvedHeader !== null && (
<header>
{resolvedHeader}
</header>
)}
<main className={cn('p-4 w-full h-full ', fullscreen && 'h-screen p-0 lg:p-0')}>
{(title || toolbar) && <div className='flex items-center justify-between gap-4 mb-4'>
{
title &&
<h2 className='text-lg lg:text-2xl font-bold '> {title}</h2>
}
{
toolbar &&
<div className=''>
{toolbar}
</div>
}
</div>}
<main >
<div className={cn(fullscreen ? "w-full px-0" : "p-4")}>
{children}
</div>
</main>
</div>
)

View File

@ -149,6 +149,7 @@ export const navGroups: NavGroup[] = [
{ title: "Shop Calendars", href: "/productivity/shop-calendars", icon: <CalendarDaysIcon /> },
{ title: "Shop Timing", href: "/productivity/shop-timings", icon: <Clock3Icon /> },
{ title: "Holidays", href: "/productivity/holidays", icon: <CalendarIcon /> },
{ title: "Tasks", href: "/productivity/tasks", icon: <ListTodoIcon /> },
],
},
{

View File

@ -70,6 +70,8 @@ export type RhfCustomerSelectFieldProps<
required?: boolean
disabled?: boolean
placeholder?: string
/** Filter customers by customer_type.name (case-insensitive). */
customerType?: string
}
// ── Component ──
@ -84,6 +86,7 @@ export function RhfCustomerSelectField<
required,
disabled,
placeholder = "Search by name, company, or phone...",
customerType,
}: RhfCustomerSelectFieldProps<TValues, TName>) {
const api = useAuthApi()
const anchorRef = useRef<HTMLDivElement>(null)
@ -96,10 +99,16 @@ export function RhfCustomerSelectField<
} = useController({ name, control, disabled })
const { data: options = [], isLoading } = useQuery<CustomerOption[]>({
queryKey: [CUSTOMER_ROUTES.INDEX, "customer-select"],
queryKey: [CUSTOMER_ROUTES.INDEX, "customer-select", customerType ?? "all"],
queryFn: async () => {
const res = await api.customers.list()
return extractItems(res).map(buildCustomerOption)
const items = extractItems(res)
const filtered = customerType
? items.filter((item: any) =>
item.customer_type?.name?.toLowerCase() === customerType.toLowerCase(),
)
: items
return filtered.map(buildCustomerOption)
},
staleTime: 5 * 60 * 1000,
})

View File

@ -0,0 +1,566 @@
"use client"
import { useState } from "react"
import {
useFormContext,
useController,
type FieldValues,
type FieldPath,
} from "react-hook-form"
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
import {
Check,
ChevronFirst,
ChevronLast,
ChevronLeft,
ChevronRight,
Edit2,
MoreHorizontal,
Plus,
RefreshCcw,
Search,
Trash2,
X,
} from "lucide-react"
import { useAuthApi } from "@/shared/useApi"
import { QUICK_REMARK_ROUTES } from "@garage/api"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/shared/components/ui/sheet"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import {
Field,
FieldLabel,
FieldError,
} from "@/shared/components/ui/field"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip"
import { cn } from "@/shared/lib/utils"
// ── Types ──
type QuickRemark = {
id: number
description: string
}
type QuickRemarksPage = {
data: QuickRemark[]
meta: {
current_page: number
last_page: number
per_page: number
total: number
}
}
type RhfCustomerRemarksFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
> = {
name: TName
label?: string
description?: string
required?: boolean
disabled?: boolean
placeholder?: string
}
// ── Helpers ──
function extractPage(response: unknown): QuickRemarksPage {
const r = response as any
return {
data: Array.isArray(r?.data?.data)
? r.data.data
: Array.isArray(r?.data)
? r.data
: [],
meta: r?.data?.meta ?? r?.meta ?? {
current_page: 1,
last_page: 1,
per_page: 15,
total: 0,
},
}
}
// ── QuickRemarksSheet ──
function QuickRemarksSheet({
open,
onOpenChange,
selected,
onToggle,
}: {
open: boolean
onOpenChange: (v: boolean) => void
selected: string[]
onToggle: (description: string) => void
}) {
const api = useAuthApi()
const queryClient = useQueryClient()
const [page, setPage] = useState(1)
const [search, setSearch] = useState("")
const [creating, setCreating] = useState(false)
const [newDescription, setNewDescription] = useState("")
const [editingId, setEditingId] = useState<number | null>(null)
const [editingText, setEditingText] = useState("")
const queryKey = [QUICK_REMARK_ROUTES.INDEX, { page, search }]
const { data, isLoading } = useQuery<QuickRemarksPage>({
queryKey,
queryFn: async () => {
const res = await api.quickRemarks.list({
page,
...(search ? { search } : {}),
})
return extractPage(res)
},
enabled: open,
staleTime: 30_000,
})
const remarks = data?.data ?? []
const meta = data?.meta
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: [QUICK_REMARK_ROUTES.INDEX] })
const createMutation = useMutation({
mutationFn: (description: string) =>
api.quickRemarks.create({ description }),
onSuccess: () => {
invalidate()
setCreating(false)
setNewDescription("")
},
})
const updateMutation = useMutation({
mutationFn: ({ id, description }: { id: number; description: string }) =>
api.quickRemarks.update(String(id), { description }),
onSuccess: () => {
invalidate()
setEditingId(null)
setEditingText("")
},
})
const deleteMutation = useMutation({
mutationFn: (id: number) =>
api.quickRemarks.destroy(String(id)),
onSuccess: () => invalidate(),
})
function handleCreate() {
const text = newDescription.trim()
if (!text) return
createMutation.mutate(text)
}
function handleUpdate(id: number) {
const text = editingText.trim()
if (!text) return
updateMutation.mutate({ id, description: text })
}
function startEdit(remark: QuickRemark) {
setEditingId(remark.id)
setEditingText(remark.description)
}
const totalPages = meta?.last_page ?? 1
const from = meta ? (meta.current_page - 1) * meta.per_page + 1 : 0
const to = meta ? Math.min(meta.current_page * meta.per_page, meta.total) : 0
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="flex w-105 flex-col gap-0 p-0 sm:w-120">
{/* Header */}
<SheetHeader className="flex flex-row items-center justify-between border-b px-4 py-3">
<SheetTitle className="text-base font-semibold">Quick Remarks</SheetTitle>
</SheetHeader>
{/* Search */}
<div className="border-b px-3 py-2 flex gap-1 items-center">
<div className="relative grow">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search..."
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
className="pl-9 h-9"
/>
</div>
<Button
type="button"
size="sm"
onClick={() => {
setCreating(true)
setEditingId(null)
}}
>
<Plus className="h-3.5 w-3.5" />
Add
</Button>
</div>
{/* Inline create form */}
{creating && (
<div className="border-b px-3 py-2">
<div className="flex items-center gap-2">
<Input
autoFocus
placeholder="New quick remark..."
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); handleCreate() }
if (e.key === "Escape") { setCreating(false); setNewDescription("") }
}}
className="h-8 text-sm"
/>
<Button
type="button"
size="sm"
disabled={createMutation.isPending || !newDescription.trim()}
onClick={handleCreate}
>
Save
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => { setCreating(false); setNewDescription("") }}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
{/* List */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Loading...
</div>
) : remarks.length === 0 ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
No quick remarks found.
</div>
) : (
remarks.map((remark) => {
const isSelected = selected.includes(remark.description)
const isEditing = editingId === remark.id
if (isEditing) {
return (
<div key={remark.id} className="flex items-center gap-2 border-b px-4 py-2">
<Input
autoFocus
value={editingText}
onChange={(e) => setEditingText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); handleUpdate(remark.id) }
if (e.key === "Escape") { setEditingId(null); setEditingText("") }
}}
className="h-8 flex-1 text-sm"
/>
<Button
type="button"
size="sm"
disabled={updateMutation.isPending || !editingText.trim()}
onClick={() => handleUpdate(remark.id)}
>
Save
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => { setEditingId(null); setEditingText("") }}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
)
}
return (
<div
key={remark.id}
className={cn(
"flex cursor-pointer items-center justify-between border-b px-4 py-3 transition-colors hover:bg-muted/50",
isSelected && "bg-primary/5",
)}
onClick={() => onToggle(remark.description)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onToggle(remark.description)
}
}}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center",
)}>
{isSelected && (
<Check className="h-4 w-4 text-primary" />
)}
</div>
<span className="truncate text-sm">{remark.description}</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
startEdit(remark)
}}
>
<Edit2 className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation()
deleteMutation.mutate(remark.id)
}}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
})
)}
</div>
{/* Pagination */}
{meta && meta.total > 0 && (
<div className="flex items-center justify-between border-t px-4 py-2 text-sm text-muted-foreground">
<span>
{from}{to} of {meta.total}
</span>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={page <= 1}
onClick={() => setPage(1)}
>
<ChevronFirst className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={page >= totalPages}
onClick={() => setPage(totalPages)}
>
<ChevronLast className="h-4 w-4" />
</Button>
</div>
</div>
)}
</SheetContent>
</Sheet>
)
}
// ── Main Component ──
export function RhfCustomerRemarksField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
>({
name,
label = "Customer Remark",
description,
required,
disabled,
placeholder = "Enter remark...",
}: RhfCustomerRemarksFieldProps<TValues, TName>) {
const { control } = useFormContext<TValues>()
const { field, fieldState: { error } } = useController({ name, control, disabled })
const [sheetOpen, setSheetOpen] = useState(false)
const remarks: string[] = Array.isArray(field.value) ? field.value : []
function updateAt(index: number, value: string) {
const next = [...remarks]
next[index] = value
field.onChange(next)
}
function addLine() {
field.onChange([...remarks, ""])
}
function removeLine(index: number) {
field.onChange(remarks.filter((_, i) => i !== index))
}
function toggleQuickRemark(description: string) {
const exists = remarks.includes(description)
if (exists) {
field.onChange(remarks.filter((r) => r !== description))
} else {
field.onChange([...remarks, description])
}
}
return (
<Field data-invalid={!!error || undefined}>
{label && (
<FieldLabel>
{label}
{required && (
<span className="text-destructive ms-0.5">*</span>
)}
</FieldLabel>
)}
{/* Repeater rows */}
<div className="flex flex-col rounded-md border divide-y overflow-hidden">
{remarks.map((remark, index) => (
<div key={index} className="flex items-center gap-2 px-3 py-2 bg-background">
<input
type="text"
value={remark}
onChange={(e) => updateAt(index, e.target.value)}
placeholder={placeholder}
disabled={disabled}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
<button
type="button"
disabled={disabled}
onClick={() => removeLine(index)}
className="shrink-0 rounded-full p-0.5 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-40"
aria-label="Remove remark"
>
<X className="h-4 w-4" />
</button>
</div>
))}
{/* Empty state */}
{remarks.length === 0 && (
<div className="px-3 py-2 text-sm text-muted-foreground italic">
No remarks added yet.
</div>
)}
{/* Footer row */}
<div className="flex items-center justify-between px-3 py-2 bg-muted/30">
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled}
onClick={addLine}
className="h-7 gap-1.5 text-xs text-primary hover:text-primary"
>
<Plus className="h-3.5 w-3.5" />
New Line
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="default"
size="icon"
disabled={disabled}
onClick={() => setSheetOpen(true)}
className="h-8 w-8 rounded-full"
aria-label="Pick from Quick Remarks"
>
<RefreshCcw className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">Quick Remarks</TooltipContent>
</Tooltip>
</div>
</div>
{error && <FieldError>{error.message}</FieldError>}
{description && !error && (
<p className="text-xs text-muted-foreground">{description}</p>
)}
<QuickRemarksSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
selected={remarks}
onToggle={toggleQuickRemark}
/>
</Field>
)
}

View File

@ -0,0 +1,548 @@
"use client"
import { useState } from "react"
import {
useFormContext,
useController,
type FieldValues,
type FieldPath,
} from "react-hook-form"
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
import {
Check,
ChevronFirst,
ChevronLast,
ChevronLeft,
ChevronRight,
Edit2,
MoreHorizontal,
Plus,
RefreshCcw,
Search,
Trash2,
X,
} from "lucide-react"
import { useAuthApi } from "@/shared/useApi"
import { QUICK_NOTE_ROUTES } from "@garage/api"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/shared/components/ui/sheet"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import {
Field,
FieldLabel,
FieldError,
} from "@/shared/components/ui/field"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip"
import { cn } from "@/shared/lib/utils"
type QuickNote = {
id: number
description: string
}
type QuickNotesPage = {
data: QuickNote[]
meta: {
current_page: number
last_page: number
per_page: number
total: number
}
}
type RhfQuickNotesFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
> = {
name: TName
label?: string
description?: string
required?: boolean
disabled?: boolean
placeholder?: string
}
function extractPage(response: unknown): QuickNotesPage {
const r = response as any
return {
data: Array.isArray(r?.data?.data)
? r.data.data
: Array.isArray(r?.data)
? r.data
: [],
meta: r?.data?.meta ?? r?.meta ?? {
current_page: 1,
last_page: 1,
per_page: 15,
total: 0,
},
}
}
function QuickNotesSheet({
open,
onOpenChange,
selected,
onToggle,
}: {
open: boolean
onOpenChange: (v: boolean) => void
selected: string[]
onToggle: (description: string) => void
}) {
const api = useAuthApi()
const queryClient = useQueryClient()
const [page, setPage] = useState(1)
const [search, setSearch] = useState("")
const [creating, setCreating] = useState(false)
const [newDescription, setNewDescription] = useState("")
const [editingId, setEditingId] = useState<number | null>(null)
const [editingText, setEditingText] = useState("")
const queryKey = [QUICK_NOTE_ROUTES.INDEX, { page, search }]
const { data, isLoading } = useQuery<QuickNotesPage>({
queryKey,
queryFn: async () => {
const res = await api.quickNotes.list({
page,
...(search ? { search } : {}),
})
return extractPage(res)
},
enabled: open,
staleTime: 30_000,
})
const notes = data?.data ?? []
const meta = data?.meta
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: [QUICK_NOTE_ROUTES.INDEX] })
const createMutation = useMutation({
mutationFn: (description: string) =>
api.quickNotes.create({ description }),
onSuccess: () => {
invalidate()
setCreating(false)
setNewDescription("")
},
})
const updateMutation = useMutation({
mutationFn: ({ id, description }: { id: number; description: string }) =>
api.quickNotes.update(String(id), { description }),
onSuccess: () => {
invalidate()
setEditingId(null)
setEditingText("")
},
})
const deleteMutation = useMutation({
mutationFn: (id: number) =>
api.quickNotes.destroy(String(id)),
onSuccess: () => invalidate(),
})
function handleCreate() {
const text = newDescription.trim()
if (!text) return
createMutation.mutate(text)
}
function handleUpdate(id: number) {
const text = editingText.trim()
if (!text) return
updateMutation.mutate({ id, description: text })
}
function startEdit(note: QuickNote) {
setEditingId(note.id)
setEditingText(note.description)
}
const totalPages = meta?.last_page ?? 1
const from = meta ? (meta.current_page - 1) * meta.per_page + 1 : 0
const to = meta ? Math.min(meta.current_page * meta.per_page, meta.total) : 0
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="flex w-105 flex-col gap-0 p-0 sm:w-120">
<SheetHeader className="flex flex-row items-center justify-between border-b px-4 py-3">
<SheetTitle className="text-base font-semibold">Quick Notes</SheetTitle>
</SheetHeader>
<div className="border-b px-3 py-2 flex gap-1 items-center">
<div className="relative grow">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search..."
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
className="pl-9 h-9"
/>
</div>
<Button
type="button"
size="sm"
onClick={() => {
setCreating(true)
setEditingId(null)
}}
>
<Plus className="h-3.5 w-3.5" />
Add
</Button>
</div>
{creating && (
<div className="border-b px-3 py-2">
<div className="flex items-center gap-2">
<Input
autoFocus
placeholder="New quick note..."
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); handleCreate() }
if (e.key === "Escape") { setCreating(false); setNewDescription("") }
}}
className="h-8 text-sm"
/>
<Button
type="button"
size="sm"
disabled={createMutation.isPending || !newDescription.trim()}
onClick={handleCreate}
>
Save
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => { setCreating(false); setNewDescription("") }}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Loading...
</div>
) : notes.length === 0 ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
No quick notes found.
</div>
) : (
notes.map((note) => {
const isSelected = selected.includes(note.description)
const isEditing = editingId === note.id
if (isEditing) {
return (
<div key={note.id} className="flex items-center gap-2 border-b px-4 py-2">
<Input
autoFocus
value={editingText}
onChange={(e) => setEditingText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); handleUpdate(note.id) }
if (e.key === "Escape") { setEditingId(null); setEditingText("") }
}}
className="h-8 flex-1 text-sm"
/>
<Button
type="button"
size="sm"
disabled={updateMutation.isPending || !editingText.trim()}
onClick={() => handleUpdate(note.id)}
>
Save
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => { setEditingId(null); setEditingText("") }}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
)
}
return (
<div
key={note.id}
className={cn(
"flex cursor-pointer items-center justify-between border-b px-4 py-3 transition-colors hover:bg-muted/50",
isSelected && "bg-primary/5",
)}
onClick={() => onToggle(note.description)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onToggle(note.description)
}
}}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center",
)}>
{isSelected && (
<Check className="h-4 w-4 text-primary" />
)}
</div>
<span className="truncate text-sm">{note.description}</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
startEdit(note)
}}
>
<Edit2 className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation()
deleteMutation.mutate(note.id)
}}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
})
)}
</div>
{meta && meta.total > 0 && (
<div className="flex items-center justify-between border-t px-4 py-2 text-sm text-muted-foreground">
<span>
{from}-{to} of {meta.total}
</span>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={page <= 1}
onClick={() => setPage(1)}
>
<ChevronFirst className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={page >= totalPages}
onClick={() => setPage(totalPages)}
>
<ChevronLast className="h-4 w-4" />
</Button>
</div>
</div>
)}
</SheetContent>
</Sheet>
)
}
export function RhfQuickNotesField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
>({
name,
label = "Quick Notes",
description,
required,
disabled,
placeholder = "Enter note...",
}: RhfQuickNotesFieldProps<TValues, TName>) {
const { control } = useFormContext<TValues>()
const { field, fieldState: { error } } = useController({ name, control, disabled })
const [sheetOpen, setSheetOpen] = useState(false)
const notes: string[] = Array.isArray(field.value) ? field.value : []
function updateAt(index: number, value: string) {
const next = [...notes]
next[index] = value
field.onChange(next)
}
function addLine() {
field.onChange([...notes, ""])
}
function removeLine(index: number) {
field.onChange(notes.filter((_, i) => i !== index))
}
function toggleQuickNote(description: string) {
const exists = notes.includes(description)
if (exists) {
field.onChange(notes.filter((r) => r !== description))
} else {
field.onChange([...notes, description])
}
}
return (
<Field data-invalid={!!error || undefined}>
{label && (
<FieldLabel>
{label}
{required && (
<span className="text-destructive ms-0.5">*</span>
)}
</FieldLabel>
)}
<div className="flex flex-col rounded-md border divide-y overflow-hidden">
{notes.map((note, index) => (
<div key={index} className="flex items-center gap-2 px-3 py-2 bg-background">
<input
type="text"
value={note}
onChange={(e) => updateAt(index, e.target.value)}
placeholder={placeholder}
disabled={disabled}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
<button
type="button"
disabled={disabled}
onClick={() => removeLine(index)}
className="shrink-0 rounded-full p-0.5 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-40"
aria-label="Remove note"
>
<X className="h-4 w-4" />
</button>
</div>
))}
{notes.length === 0 && (
<div className="px-3 py-2 text-sm text-muted-foreground italic">
No notes added yet.
</div>
)}
<div className="flex items-center justify-between px-3 py-2 bg-muted/30">
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled}
onClick={addLine}
className="h-7 gap-1.5 text-xs text-primary hover:text-primary"
>
<Plus className="h-3.5 w-3.5" />
New Line
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="default"
size="icon"
disabled={disabled}
onClick={() => setSheetOpen(true)}
className="h-8 w-8 rounded-full"
aria-label="Pick from Quick Notes"
>
<RefreshCcw className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">Quick Notes</TooltipContent>
</Tooltip>
</div>
</div>
{error && <FieldError>{error.message}</FieldError>}
{description && !error && (
<p className="text-xs text-muted-foreground">{description}</p>
)}
<QuickNotesSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
selected={notes}
onToggle={toggleQuickNote}
/>
</Field>
)
}

View File

@ -0,0 +1,548 @@
"use client"
import { useState } from "react"
import {
useFormContext,
useController,
type FieldValues,
type FieldPath,
} from "react-hook-form"
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
import {
Check,
ChevronFirst,
ChevronLast,
ChevronLeft,
ChevronRight,
Edit2,
MoreHorizontal,
Plus,
RefreshCcw,
Search,
Trash2,
X,
} from "lucide-react"
import { useAuthApi } from "@/shared/useApi"
import { SHOP_RECOMMENDATION_ROUTES } from "@garage/api"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/shared/components/ui/sheet"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import {
Field,
FieldLabel,
FieldError,
} from "@/shared/components/ui/field"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip"
import { cn } from "@/shared/lib/utils"
type ShopRecommendation = {
id: number
description: string
}
type ShopRecommendationsPage = {
data: ShopRecommendation[]
meta: {
current_page: number
last_page: number
per_page: number
total: number
}
}
type RhfShopRecommendationsFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
> = {
name: TName
label?: string
description?: string
required?: boolean
disabled?: boolean
placeholder?: string
}
function extractPage(response: unknown): ShopRecommendationsPage {
const r = response as any
return {
data: Array.isArray(r?.data?.data)
? r.data.data
: Array.isArray(r?.data)
? r.data
: [],
meta: r?.data?.meta ?? r?.meta ?? {
current_page: 1,
last_page: 1,
per_page: 15,
total: 0,
},
}
}
function ShopRecommendationsSheet({
open,
onOpenChange,
selected,
onToggle,
}: {
open: boolean
onOpenChange: (v: boolean) => void
selected: string[]
onToggle: (description: string) => void
}) {
const api = useAuthApi()
const queryClient = useQueryClient()
const [page, setPage] = useState(1)
const [search, setSearch] = useState("")
const [creating, setCreating] = useState(false)
const [newDescription, setNewDescription] = useState("")
const [editingId, setEditingId] = useState<number | null>(null)
const [editingText, setEditingText] = useState("")
const queryKey = [SHOP_RECOMMENDATION_ROUTES.INDEX, { page, search }]
const { data, isLoading } = useQuery<ShopRecommendationsPage>({
queryKey,
queryFn: async () => {
const res = await api.shopRecommendations.list({
page,
...(search ? { search } : {}),
})
return extractPage(res)
},
enabled: open,
staleTime: 30_000,
})
const recommendations = data?.data ?? []
const meta = data?.meta
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: [SHOP_RECOMMENDATION_ROUTES.INDEX] })
const createMutation = useMutation({
mutationFn: (description: string) =>
api.shopRecommendations.create({ description }),
onSuccess: () => {
invalidate()
setCreating(false)
setNewDescription("")
},
})
const updateMutation = useMutation({
mutationFn: ({ id, description }: { id: number; description: string }) =>
api.shopRecommendations.update(String(id), { description }),
onSuccess: () => {
invalidate()
setEditingId(null)
setEditingText("")
},
})
const deleteMutation = useMutation({
mutationFn: (id: number) =>
api.shopRecommendations.destroy(String(id)),
onSuccess: () => invalidate(),
})
function handleCreate() {
const text = newDescription.trim()
if (!text) return
createMutation.mutate(text)
}
function handleUpdate(id: number) {
const text = editingText.trim()
if (!text) return
updateMutation.mutate({ id, description: text })
}
function startEdit(recommendation: ShopRecommendation) {
setEditingId(recommendation.id)
setEditingText(recommendation.description)
}
const totalPages = meta?.last_page ?? 1
const from = meta ? (meta.current_page - 1) * meta.per_page + 1 : 0
const to = meta ? Math.min(meta.current_page * meta.per_page, meta.total) : 0
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="flex w-105 flex-col gap-0 p-0 sm:w-120">
<SheetHeader className="flex flex-row items-center justify-between border-b px-4 py-3">
<SheetTitle className="text-base font-semibold">Shop Recommendations</SheetTitle>
</SheetHeader>
<div className="border-b px-3 py-2 flex gap-1 items-center">
<div className="relative grow">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search..."
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
className="pl-9 h-9"
/>
</div>
<Button
type="button"
size="sm"
onClick={() => {
setCreating(true)
setEditingId(null)
}}
>
<Plus className="h-3.5 w-3.5" />
Add
</Button>
</div>
{creating && (
<div className="border-b px-3 py-2">
<div className="flex items-center gap-2">
<Input
autoFocus
placeholder="New shop recommendation..."
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); handleCreate() }
if (e.key === "Escape") { setCreating(false); setNewDescription("") }
}}
className="h-8 text-sm"
/>
<Button
type="button"
size="sm"
disabled={createMutation.isPending || !newDescription.trim()}
onClick={handleCreate}
>
Save
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => { setCreating(false); setNewDescription("") }}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Loading...
</div>
) : recommendations.length === 0 ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
No shop recommendations found.
</div>
) : (
recommendations.map((recommendation) => {
const isSelected = selected.includes(recommendation.description)
const isEditing = editingId === recommendation.id
if (isEditing) {
return (
<div key={recommendation.id} className="flex items-center gap-2 border-b px-4 py-2">
<Input
autoFocus
value={editingText}
onChange={(e) => setEditingText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); handleUpdate(recommendation.id) }
if (e.key === "Escape") { setEditingId(null); setEditingText("") }
}}
className="h-8 flex-1 text-sm"
/>
<Button
type="button"
size="sm"
disabled={updateMutation.isPending || !editingText.trim()}
onClick={() => handleUpdate(recommendation.id)}
>
Save
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => { setEditingId(null); setEditingText("") }}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
)
}
return (
<div
key={recommendation.id}
className={cn(
"flex cursor-pointer items-center justify-between border-b px-4 py-3 transition-colors hover:bg-muted/50",
isSelected && "bg-primary/5",
)}
onClick={() => onToggle(recommendation.description)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onToggle(recommendation.description)
}
}}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center",
)}>
{isSelected && (
<Check className="h-4 w-4 text-primary" />
)}
</div>
<span className="truncate text-sm">{recommendation.description}</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
startEdit(recommendation)
}}
>
<Edit2 className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation()
deleteMutation.mutate(recommendation.id)
}}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
})
)}
</div>
{meta && meta.total > 0 && (
<div className="flex items-center justify-between border-t px-4 py-2 text-sm text-muted-foreground">
<span>
{from}-{to} of {meta.total}
</span>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={page <= 1}
onClick={() => setPage(1)}
>
<ChevronFirst className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={page >= totalPages}
onClick={() => setPage(totalPages)}
>
<ChevronLast className="h-4 w-4" />
</Button>
</div>
</div>
)}
</SheetContent>
</Sheet>
)
}
export function RhfShopRecommendationsField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
>({
name,
label = "Shop Recommendations",
description,
required,
disabled,
placeholder = "Enter recommendation...",
}: RhfShopRecommendationsFieldProps<TValues, TName>) {
const { control } = useFormContext<TValues>()
const { field, fieldState: { error } } = useController({ name, control, disabled })
const [sheetOpen, setSheetOpen] = useState(false)
const recommendations: string[] = Array.isArray(field.value) ? field.value : []
function updateAt(index: number, value: string) {
const next = [...recommendations]
next[index] = value
field.onChange(next)
}
function addLine() {
field.onChange([...recommendations, ""])
}
function removeLine(index: number) {
field.onChange(recommendations.filter((_, i) => i !== index))
}
function toggleRecommendation(description: string) {
const exists = recommendations.includes(description)
if (exists) {
field.onChange(recommendations.filter((r) => r !== description))
} else {
field.onChange([...recommendations, description])
}
}
return (
<Field data-invalid={!!error || undefined}>
{label && (
<FieldLabel>
{label}
{required && (
<span className="text-destructive ms-0.5">*</span>
)}
</FieldLabel>
)}
<div className="flex flex-col rounded-md border divide-y overflow-hidden">
{recommendations.map((recommendation, index) => (
<div key={index} className="flex items-center gap-2 px-3 py-2 bg-background">
<input
type="text"
value={recommendation}
onChange={(e) => updateAt(index, e.target.value)}
placeholder={placeholder}
disabled={disabled}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
<button
type="button"
disabled={disabled}
onClick={() => removeLine(index)}
className="shrink-0 rounded-full p-0.5 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-40"
aria-label="Remove recommendation"
>
<X className="h-4 w-4" />
</button>
</div>
))}
{recommendations.length === 0 && (
<div className="px-3 py-2 text-sm text-muted-foreground italic">
No recommendations added yet.
</div>
)}
<div className="flex items-center justify-between px-3 py-2 bg-muted/30">
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled}
onClick={addLine}
className="h-7 gap-1.5 text-xs text-primary hover:text-primary"
>
<Plus className="h-3.5 w-3.5" />
New Line
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="default"
size="icon"
disabled={disabled}
onClick={() => setSheetOpen(true)}
className="h-8 w-8 rounded-full"
aria-label="Pick from Shop Recommendations"
>
<RefreshCcw className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">Shop Recommendations</TooltipContent>
</Tooltip>
</div>
</div>
{error && <FieldError>{error.message}</FieldError>}
{description && !error && (
<p className="text-xs text-muted-foreground">{description}</p>
)}
<ShopRecommendationsSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
selected={recommendations}
onToggle={toggleRecommendation}
/>
</Field>
)
}

View File

@ -23,6 +23,7 @@ import {
type ExpenseFormValues,
} from "./expense.schema"
import { EXPENSE_ROUTES, JOB_CARD_ROUTES, VENDOR_ROUTES, DEPARTMENT_ROUTES } from "@garage/api"
import { getFullName } from "@/shared/utils/getFullName"
// ── Constants ──
@ -158,7 +159,7 @@ export function ExpenseForm({ resourceId, initialData, onSuccess }: ExpenseFormP
placeholder="Select vendor"
queryKey={[VENDOR_ROUTES.INDEX]}
listFn={() => api.vendors.list()}
mapOption={mapLookupOption}
mapOption={(op: any) => ({ value: String(op.id), label: getFullName(op)})}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField

View File

@ -0,0 +1,33 @@
"use client"
import { CrudDialog } from "@/shared/components/crud-dialog"
import { ColumnHeader } from "@/shared/data-view/table-view"
import { useAuthApi } from "@/shared/useApi"
import { INSURANCE_TYPE_ROUTES } from "@garage/api"
import { InsuranceTypeForm } from "./insurance-type-form"
export function InsuranceTypeCrudDialog() {
const api = useAuthApi()
return (
<CrudDialog
title="Insurance Type"
queryKey={[INSURANCE_TYPE_ROUTES.INDEX]}
getClient={() => api.insuranceTypes}
resourceLabel="insurance type"
columns={() => [
{
accessorKey: "name",
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
},
]}
renderForm={({ resourceId, initialData, onSuccess }) => (
<InsuranceTypeForm
resourceId={resourceId}
initialData={initialData}
onSuccess={onSuccess}
/>
)}
/>
)
}

View File

@ -0,0 +1,87 @@
"use client"
import { z } from "zod"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { Plus, Save } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { FieldGroup } from "@/shared/components/ui/field"
import { Rhform, RhfTextField } from "@/shared/components/form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { useEffect } from "react"
// ── Schema ──
const insuranceTypeSchema = z.object({
name: z.string().min(1, "Name is required"),
})
type InsuranceTypeFormValues = z.infer<typeof insuranceTypeSchema>
// ── Props ──
type InsuranceTypeFormProps = {
resourceId?: string | null
initialData?: any
onSuccess?: () => void
}
// ── Component ──
export function InsuranceTypeForm({ resourceId, initialData, onSuccess }: InsuranceTypeFormProps) {
const api = useAuthApi()
const isEditing = !!resourceId
const form = useForm<InsuranceTypeFormValues>({
resolver: zodResolver(insuranceTypeSchema),
defaultValues: { name: "" },
})
// Pre-fill when editing
useEffect(() => {
if (initialData) {
const d = initialData?.data ?? initialData
form.reset({ name: d.name ?? "" })
}
}, [initialData, form])
const handleSubmit = async (values: InsuranceTypeFormValues) => {
try {
const promise = isEditing
? api.insuranceTypes.update(resourceId!, { title: values.name } as any)
: api.insuranceTypes.create({ title: values.name } as any)
toast.promise(promise, {
loading: isEditing ? "Updating..." : "Creating...",
success: isEditing ? "Updated successfully" : "Created successfully",
error: isEditing ? "Failed to update" : "Failed to create",
})
await promise
form.reset()
onSuccess?.()
} catch {
// toast already shown
}
}
return (
<Rhform form={form} onSubmit={handleSubmit}>
<FieldGroup>
<RhfTextField
name="name"
label="Name"
placeholder="e.g. Comprehensive"
required
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
{isEditing ? <Save className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
{form.formState.isSubmitting
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update" : "Create")}
</Button>
</FieldGroup>
</Rhform>
)
}

View File

@ -27,6 +27,7 @@ type JobCardActionsProps = {
orderDate?: string | null
serviceWriterName?: string | null
salesPersonName?: string | null
primaryTechnicianName?: string | null
}
// ── Informative Action Card ──
@ -104,13 +105,13 @@ function EmployeePickerDialog({
// ── Main Component ──
export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesPersonName }: JobCardActionsProps) {
export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesPersonName, primaryTechnicianName }: JobCardActionsProps) {
const api = useAuthApi()
const router = useRouter()
const [datePickerOpen, setDatePickerOpen] = useState(false)
const [serviceWriterDialogOpen, setServiceWriterDialogOpen] = useState(false)
const [salesPersonDialogOpen, setSalesPersonDialogOpen] = useState(false)
const [primaryTechnicianDialogOpen, setPrimaryTechnicianDialogOpen] = useState(false)
const changeDateMutation = useMutation({
mutationFn: (date: Date) => {
@ -161,6 +162,8 @@ export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesP
},
})
return (
<div className="flex flex-wrap items-stretch gap-2">
{/* Check-in Date Action Card */}
@ -213,6 +216,19 @@ export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesP
isPending={changeSalesPersonMutation.isPending}
/>
</button>
{/* Primary Technician Action Card */}
<button
type="button"
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-lg"
onClick={() => setPrimaryTechnicianDialogOpen(true)}
>
<ActionCard
icon={UserCheck}
label="Primary Technician"
value={primaryTechnicianName ?? null}
isPending={false}
/>
</button>
{/* Edit / Delete Dropdown */}
@ -234,6 +250,24 @@ export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesP
isPending={changeSalesPersonMutation.isPending}
onSelect={(id) => changeSalesPersonMutation.mutate(id)}
/>
<EmployeePickerDialog
open={salesPersonDialogOpen}
onOpenChange={setSalesPersonDialogOpen}
title="Change Sales Person"
description="Search and select an employee to assign as sales person."
isPending={changeSalesPersonMutation.isPending}
onSelect={(id) => changeSalesPersonMutation.mutate(id)}
/>
<EmployeePickerDialog
open={primaryTechnicianDialogOpen}
onOpenChange={setPrimaryTechnicianDialogOpen}
title="Change Primary Technician"
description="Search and select an employee to assign as primary technician."
isPending={false}
onSelect={()=>{}}
// onSelect={(id) => changePrimaryTechnicianMutation.mutate(id)}
/>
</div>
)
}

View File

@ -2,16 +2,16 @@
import { createContext, useContext, useState, useCallback } from "react"
import type { JobCardStatus } from "./job-card.schema"
import { JobCardResponseData } from "@garage/api"
import { JobCardShowData } from "../../../../packages/api/src/clients/job-cards"
const JobCardContext = createContext<JobCardResponseData | null>(null)
const JobCardContext = createContext<JobCardShowData | null>(null)
export function JobCardProvider({
jobCard,
children,
}: {
jobCard: JobCardResponseData
jobCard: JobCardShowData
children: React.ReactNode
}) {
const [status, setStatusState] = useState<JobCardStatus>(jobCard.status as JobCardStatus)
@ -21,7 +21,7 @@ export function JobCardProvider({
}, [])
return (
<JobCardContext.Provider value={{ ...jobCard, status, setStatus } as JobCardResponseData}>
<JobCardContext.Provider value={{ ...jobCard, status, setStatus } as JobCardShowData}>
{children}
</JobCardContext.Provider>
)

View File

@ -4,14 +4,16 @@ import { AlertTriangle, Plus, Save } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
import { FieldGroup } from "@/shared/components/ui/field"
import {
Rhform,
RhfTextField,
RhfTextareaField,
RhfCheckboxField,
RhfSelectField,
RhfAsyncSelectField,
RhfDateField,
RhfTimeField,
RhfAutoGenerateField,
} from "@/shared/components/form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
@ -22,16 +24,15 @@ import { toRelation, toId } from "@/shared/lib/utils"
import {
jobCardFormSchema,
type JobCardFormValues,
TAX_INCLUSIVE_OPTIONS,
DISCOUNT_TYPE_OPTIONS,
DISCOUNT_AT_OPTIONS,
ESTIMATE_TO_OPTIONS,
FUEL_LEVEL_OPTIONS,
JOB_CARD_STATUS_OPTIONS,
} from "./job-card.schema"
import { JOB_CARD_ROUTES, EMPLOYEE_ROUTES, DEPARTMENT_ROUTES } from "@garage/api"
import { JOB_CARD_ROUTES, EMPLOYEE_ROUTES, DEPARTMENT_ROUTES, INSURANCE_TYPE_ROUTES } from "@garage/api"
import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field"
import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
import { RhfLabelPickerField } from "@/modules/labels/rhf-label-picker-field"
import { RhfCustomerRemarksField } from "@/modules/estimates/rhf-customer-remarks-field"
import { InsuranceTypeCrudDialog } from "./insurance-type-crud-dialog"
// ── Props ──
@ -49,15 +50,32 @@ const DEFAULT_VALUES: JobCardFormValues = {
vehicle: null,
department: null,
service_writer: null,
primary_technician: null,
sales_person: null,
insurance_type: null,
insurer: null,
order_number: "",
estimate_number: "",
status: "check_in",
estimate_to: "Customer",
tax_inclusive: "Tax Inclusive",
discount_type: "no",
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")}` })(),
order_date: new Date().toISOString().split("T")[0],
check_in_date: "",
check_in_time: "",
start_date: "",
start_time: "",
delivery_date: "",
delivery_time: "",
km_in: "",
fuel_level: "",
has_insurance: false,
enable_parts_issuing: false,
enable_digital_authorisation: false,
footer: "",
customer_remarks: [],
labels: [],
}
// ── Mapping helpers ──
@ -65,21 +83,56 @@ const DEFAULT_VALUES: JobCardFormValues = {
function mapToFormValues(data: unknown): JobCardFormValues {
const d = (data as any)?.data ?? data ?? {}
const mapTime = (v: unknown): string => {
if (!v || typeof v !== "string") return ""
if (v.includes("T")) return v.split("T")[1]?.slice(0, 8) ?? ""
return v
}
const mapDate = (v: unknown): string => {
if (!v || typeof v !== "string") return ""
return v.split("T")[0]
}
return {
title: d.title || "",
customer: toRelation(d.customer_id, d.customer ? `${d.customer.first_name} ${d.customer.last_name}` : undefined),
vehicle: toRelation(d.vehicle_id, d.vehicle ? `${d.vehicle.make} ${d.vehicle.model}` : undefined),
vehicle: toRelation(d.vehicle_id, d.vehicle ? (d.vehicle.plate_number ?? `${d.vehicle.make ?? ""} ${d.vehicle.model ?? ""}`.trim()) : 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),
primary_technician: toRelation(d.primary_technician_id, d.primary_technician ? `${d.primary_technician.first_name} ${d.primary_technician.last_name}` : undefined),
sales_person: toRelation(d.sales_person_id, d.sales_person ? `${d.sales_person.first_name} ${d.sales_person.last_name}` : undefined),
insurance_type: toRelation(d.insurance_type_id, d.insurance_type?.name),
insurer: toRelation(d.insurer_id, d.insurer ? `${d.insurer.first_name} ${d.insurer.last_name}`.trim() || d.insurer.company_name : undefined),
order_number: d.order_number || "",
estimate_number: d.estimate_number || "",
status: d.status || "draft",
estimate_to: d.estimate_to || "Customer",
tax_inclusive: d.tax_inclusive || "Tax Inclusive",
discount_type: d.discount_type || "no",
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] : "",
order_date: mapDate(d.order_date),
check_in_date: mapDate(d.check_in_date),
check_in_time: mapTime(d.check_in_time),
start_date: mapDate(d.start_date),
start_time: mapTime(d.start_time),
delivery_date: mapDate(d.delivery_date),
delivery_time: mapTime(d.delivery_time),
km_in: d.km_in != null ? String(d.km_in) : "",
fuel_level: d.fuel_level || "",
has_insurance: d.has_insurance ?? false,
enable_parts_issuing: d.enable_parts_issuing ?? false,
enable_digital_authorisation: d.enable_digital_authorisation ?? false,
footer: d.footer || "",
customer_remarks: Array.isArray(d.customer_remarks)
? d.customer_remarks.map((r: any) =>
typeof r === "string" ? r : (r?.remark ?? "")
).filter(Boolean)
: [],
labels: (d.labels ?? []).map((l: any) => ({
id: l.id,
title: l.title,
color_code: l.color_code,
})),
}
}
@ -90,15 +143,32 @@ function mapFormToPayload(values: JobCardFormValues) {
vehicle_id: toId(values.vehicle),
department_id: toId(values.department),
service_writer_id: toId(values.service_writer),
primary_technician_id: toId(values.primary_technician),
sales_person_id: toId(values.sales_person),
insurance_type_id: values.insurance_type ? String(toId(values.insurance_type)) : null,
insurer_id: values.insurer ? String(toId(values.insurer)) : null,
estimate_number: values.estimate_number || undefined,
order_number: values.order_number || undefined,
status: values.status || undefined,
estimate_to: values.estimate_to || undefined,
tax_inclusive: values.tax_inclusive || undefined,
discount_type: values.discount_type || undefined,
discount_at: values.discount_at || undefined,
order_date: values.order_date || undefined,
check_in_date: values.check_in_date || undefined,
check_in_time: values.check_in_time || undefined,
start_date: values.start_date || undefined,
start_time: values.start_time || undefined,
delivery_date: values.delivery_date || undefined,
delivery_time: values.delivery_time || undefined,
km_in: values.km_in ? Number(values.km_in) : undefined,
fuel_level: values.fuel_level || undefined,
has_insurance: values.has_insurance,
enable_parts_issuing: values.enable_parts_issuing,
enable_digital_authorisation: values.enable_digital_authorisation,
footer: values.footer || undefined,
customer_remarks: values.customer_remarks?.filter(Boolean) ?? [],
label_ids: values.labels?.map((l) => l.id),
}
}
@ -130,6 +200,10 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP
mapToFormValues,
})
const hasInsurance = form.watch("has_insurance")
const status = form.watch("status")
const isCheckIn = status === "check_in"
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: JobCardFormValues) => {
const payload = mapFormToPayload(values)
@ -161,7 +235,11 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP
</Alert>
)}
<FieldGroup>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_360px]">
{/* ── Left column ── */}
<div className="space-y-4">
<RhfLabelPickerField name="labels" label="Labels" />
<RhfTextField name="title" label="Title" placeholder="Job Card 001" required />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
@ -169,31 +247,41 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP
<RhfVehicleSelectField name="vehicle" />
</div>
<RhfCheckboxField name="has_insurance" label="Has Insurance Work?" />
{hasInsurance && (
<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}
<RhfCustomerSelectField
name="insurer"
label="Insurer"
placeholder="Search insurer..."
customerType="Insurer"
/>
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium">Insurance Type</span>
<InsuranceTypeCrudDialog />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department"
queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.departments.list()}
name="insurance_type"
label=""
placeholder="Select insurance type"
queryKey={[INSURANCE_TYPE_ROUTES.INDEX]}
listFn={() => api.insuranceTypes.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
</div>
</div>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<RhfDateField name="order_date" label="Order Date" />
<RhfAutoGenerateField autoFetch name="order_number" label="Order#" placeholder="ORD-001" table="job_cards" />
<RhfAutoGenerateField autoFetch name="estimate_number" label="Estimate#" placeholder="EST-001" table="estimates" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="service_writer"
label="Service Writer"
@ -203,51 +291,88 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP
mapOption={mapEmployeeOption}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="primary_technician"
label="Primary Technician"
placeholder="Select technician"
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">
<RhfDateField name="check_in_date" label="Check-in Date" />
<RhfTimeField name="check_in_time" label="Check-in Time" withSeconds />
</div> */}
{/* ── Check-in Details (shown when status is check_in) ── */}
{isCheckIn && (
<div className="space-y-4 rounded-lg border p-4">
<p className="text-sm font-semibold">Check In Details</p>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="km_in" label="KM In" placeholder="50000" type="number" />
<RhfDateField name="check_in_date" label="Check In Date" />
<RhfTimeField name="check_in_time" label="Check In Time" />
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<RhfTextField name="km_in" label="KMs IN" placeholder="0" type="number" />
<RhfSelectField
name="fuel_level"
label="Fuel Level"
placeholder="Select fuel level"
placeholder="Select"
options={FUEL_LEVEL_OPTIONS}
/>
<RhfDateField name="start_date" label="Start Date" />
<RhfTimeField name="start_time" label="Start Time" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<RhfSelectField
name="tax_inclusive"
label="Tax"
placeholder="Select tax type"
options={TAX_INCLUSIVE_OPTIONS}
/>
<RhfSelectField
name="discount_type"
label="Discount Type"
placeholder="Select discount type"
options={DISCOUNT_TYPE_OPTIONS}
/>
<RhfSelectField
name="discount_at"
label="Discount At"
placeholder="Select discount at"
options={DISCOUNT_AT_OPTIONS}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[1fr_1fr_1fr]">
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department"
queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.departments.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
<RhfDateField name="delivery_date" label="Delivery Date" />
<RhfTimeField name="delivery_time" label="Delivery Time" />
</div>
</div>
)}
</div>
{/* ── Right column ── */}
<div className="space-y-4">
<RhfSelectField
name="status"
label="Status"
placeholder="Select status"
options={JOB_CARD_STATUS_OPTIONS}
/>
<RhfCustomerRemarksField name="customer_remarks" />
<RhfTextareaField name="footer" label="Estimate Footer" placeholder="Thank you for your business." rows={6} />
<RhfCheckboxField
name="enable_parts_issuing"
label="Enable Parts Issuing"
description="When off, parts will be auto issued upon authorization. When on, manually request, reserve, and issue parts."
/>
<RhfCheckboxField
name="enable_digital_authorisation"
label="Enable Digital Authorisation"
description="When off, items will be auto authorised. When on, customer approval is required before proceeding."
/>
</div>
</div>
<div className="mt-6">
<Button type="submit" variant="default" disabled={isPending}>
{isEditing ? <Save /> : <Plus />}
{isPending
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update Job Card" : "Create Job Card")}
</Button>
</FieldGroup>
</div>
</Rhform>
)
}

View File

@ -4,7 +4,6 @@ import {
Hash,
Users,
Car,
Building2,
Gauge,
Clock,
UserCheck,
@ -21,15 +20,19 @@ import {
import { Badge } from "@/shared/components/ui/badge"
import { Separator } from "@/shared/components/ui/separator"
import { JobCardActions } from "./job-card-actions"
import { JobCardRemarksList } from "./job-card-remarks-list"
import { JobCardRecommendationsList } from "./job-card-recommendations-list"
import { getVehicleLabel } from "../vehicles/utils/getVehicleLabel"
import { JobCardResponseData } from "@garage/api"
import { getFullName } from "@/shared/utils/getFullName"
import { CrudShowResponse, JobCardsClient, PAYMENT_RECEIVED_ROUTES, PaymentReceivedClient } from "@garage/api"
import { ResourcePage } from "@/shared/data-view/resource-page"
import PaymentReceivedPage from "@/app/(authenticated)/sales/payment-received/page"
import JobCardPaymentsReceived from "./job-card-payments-received"
type JobCardGeneralInfoProps = {
jobCard: JobCardResponseData
}
type JobCard = NonNullable<CrudShowResponse<JobCardsClient>['data']>
function InfoItem({
icon: Icon,
@ -64,7 +67,7 @@ const statusColorMap: Record<string, string> = {
cancelled: "destructive",
}
export function JobCardGeneralInfo({ jobCard }: { jobCard: JobCardResponseData }) {
export function JobCardGeneralInfo({ jobCard }: { jobCard: JobCard }) {
const formatStatus = (status?: string) => {
if (!status) return null
return status
@ -73,16 +76,22 @@ export function JobCardGeneralInfo({ jobCard }: { jobCard: JobCardResponseData }
.join(" ")
}
console.log(jobCard)
return (
<div className="flex flex-col gap-6">
{jobCard.service_writer?.first_name}
<JobCardActions
jobCardId={String(jobCard.id)}
jobCardId={String(jobCard)}
orderDate={jobCard.order_date ?? null}
serviceWriterName={jobCard.service_writer?.first_name}
salesPersonName={jobCard.sales_person?.first_name}
primaryTechnicianName={jobCard.primary_technician?.first_name}
/>
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
@ -141,18 +150,18 @@ export function JobCardGeneralInfo({ jobCard }: { jobCard: JobCardResponseData }
<InfoItem
icon={Users}
label="Customer"
value={getFullName(jobCard.customer)}
value={getFullName(jobCard?.customer)}
/>
<InfoItem
icon={Car}
label="Vehicle"
value={getVehicleLabel(jobCard.vehicle as any)}
value={getVehicleLabel(jobCard?.vehicle as any)}
/>
<InfoItem
{/* <InfoItem
icon={Building2}
label="Department"
value={jobCard.department as any}
/>
value={jobCard?.department?.name as any}
/> */}
<InfoItem
icon={Briefcase}
label="Sales Person"
@ -161,7 +170,7 @@ export function JobCardGeneralInfo({ jobCard }: { jobCard: JobCardResponseData }
<InfoItem
icon={UserCheck}
label="Service Writer"
value={getFullName(jobCard.service_writer as any)}
value={getFullName(jobCard?.service_writer as any)}
/>
</div>
</CardContent>
@ -231,6 +240,20 @@ export function JobCardGeneralInfo({ jobCard }: { jobCard: JobCardResponseData }
</CardContent>
</Card>
</div>
<JobCardPaymentsReceived />
<div className="grid gap-6 md:grid-cols-2">
<JobCardRemarksList
jobCardId={String(jobCard.id)}
remarks={(jobCard as any).customer_remarks ?? []}
/>
<JobCardRecommendationsList
jobCardId={String(jobCard.id)}
recommendations={(jobCard as any).shop_recommendations ?? []}
/>
</div>
</div>
)
}

View File

@ -0,0 +1,244 @@
"use client"
import React from "react"
import { AlertTriangle, Plus, Save } from "lucide-react"
import { z } from "zod"
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 { DepartmentInlineForm } from "@/modules/services/inline-forms/department-inline-form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
// ── Schema ──
const jobCardPartFormSchema = z.object({
part: z
.object({ value: z.string(), label: z.string() })
.nullable(),
department: z
.object({ value: z.string(), label: z.string() })
.nullable()
.optional(),
quantity: z.coerce.number().min(1, "Quantity is required"),
rate: z.coerce.number().min(0, "Rate is required"),
tax: z.string().optional(),
description: z.string().optional(),
})
type JobCardPartFormValues = z.infer<typeof jobCardPartFormSchema>
// ── Props ──
export type JobCardPartFormProps = {
jobCardId: string
jobCardPartId?: number | null
initialData?: unknown
onSuccess?: () => void
onCancel?: () => void
}
const DEFAULT_VALUES: JobCardPartFormValues = {
part: null,
department: null,
quantity: 1,
rate: 0,
tax: "",
description: "",
}
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
function mapToFormValues(data: unknown): JobCardPartFormValues {
const d = (data as any) ?? {}
return {
part: d.part
? { value: String(d.part.id), label: d.part.title ?? String(d.part.id) }
: null,
department: d.department
? { value: String(d.department.id), label: d.department.name ?? String(d.department.id) }
: null,
quantity: d.quantity ?? 1,
rate: d.rate != null ? Number(d.rate) : 0,
tax: d.tax ?? "",
description: d.description ?? "",
}
}
// ── Component ──
export function JobCardPartForm({
jobCardId,
jobCardPartId,
initialData,
onSuccess,
onCancel,
}: JobCardPartFormProps) {
const api = useAuthApi()
const isEditing = !!jobCardPartId
const form = useForm<JobCardPartFormValues>({
resolver: zodResolver(jobCardPartFormSchema) as any,
defaultValues: initialData
? mapToFormValues(initialData)
: DEFAULT_VALUES,
})
const [error, setError] = React.useState<string | null>(null)
const [isPending, setIsPending] = React.useState(false)
async function handleSubmit(values: JobCardPartFormValues) {
setError(null)
setIsPending(true)
try {
if (isEditing && jobCardPartId) {
await toast.promise(
api.jobCards.updatePart(jobCardId, {
job_card_part_id: jobCardPartId,
quantity: values.quantity,
rate: values.rate,
description: values.description || undefined,
}),
{
loading: "Updating part...",
success: "Part updated successfully",
error: "Failed to update part",
}
)
} else {
await toast.promise(
api.jobCards.addPart(jobCardId, {
part_id: values.part ? Number(values.part.value) : undefined,
department_id: values.department ? Number(values.department.value) : undefined,
quantity: values.quantity,
rate: values.rate,
tax: values.tax || undefined,
description: values.description || undefined,
}),
{
loading: "Adding part...",
success: "Part added successfully",
error: "Failed to add part",
}
)
}
form.reset()
onSuccess?.()
} catch (err: any) {
setError(err?.message ?? "An unexpected error occurred")
} finally {
setIsPending(false)
}
}
return (
<Rhform form={form} onSubmit={handleSubmit}>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="me-2 h-4 w-4" />
<AlertTitle>
{isEditing ? "Failed to update part" : "Failed to add part"}
</AlertTitle>
{error}
</Alert>
)}
<FieldGroup>
{!isEditing && (
<RhfAsyncSelectField
name="part"
label="Part"
placeholder="Select part"
required
queryKey={["parts"]}
listFn={() => api.parts.list()}
mapOption={(item: any) => ({
value: String(item.id),
label: item.title ?? String(item.id),
})}
{...STORE_OBJECT}
/>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="quantity"
label="Quantity"
type="number"
placeholder="1"
required
/>
<RhfTextField
name="rate"
label="Rate"
type="number"
placeholder="0.00"
required
/>
</div>
{!isEditing && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department"
queryKey={["departments"]}
listFn={() => api.departments.list()}
mapOption={(item: any) => ({
value: String(item.id),
label: item.name ?? String(item.id),
})}
createForm={(props) => <DepartmentInlineForm {...props} />}
createLabel="Department"
{...STORE_OBJECT}
/>
<RhfTextField
name="tax"
label="Tax"
placeholder="e.g. 5%"
/>
</div>
)}
<RhfTextareaField
name="description"
label="Description"
placeholder="Optional notes"
/>
</FieldGroup>
<div className="flex justify-end gap-2 pt-2">
{onCancel && (
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
)}
<Button type="submit" disabled={isPending}>
{isPending ? (
isEditing ? "Saving..." : "Adding..."
) : isEditing ? (
<>
<Save className="me-2 h-4 w-4" />
Save Changes
</>
) : (
<>
<Plus className="me-2 h-4 w-4" />
Add Part
</>
)}
</Button>
</div>
</Rhform>
)
}

View File

@ -0,0 +1,134 @@
"use client"
import { CrudResource } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { PaymentReceivedForm } from "@/modules/payment-received/payment-received-form"
import { PAYMENT_RECEIVED_ROUTES, PaymentReceivedClient } from "@garage/api"
import {
BadgeDollarSignIcon,
CalendarIcon,
ChevronDown,
CreditCardIcon,
HashIcon,
} from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/components/ui/collapsible"
import { Button } from "@/shared/components/ui/button"
import { useJobCard } from "./job-card-context"
import { formatDate, formatCurrency } from "@/shared/utils/formatters"
export default function JobCardPaymentsReceived() {
const jobCard = useJobCard()
return (
<Collapsible defaultOpen={false} className="group/collapsible">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold">Payments Received</CardTitle>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<ChevronDown className="h-4 w-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-180" />
</Button>
</CollapsibleTrigger>
</div>
</CardHeader>
<CollapsibleContent>
<CardContent className="pt-0">
<CrudResource<PaymentReceivedClient>
extraParams={{ job_card_id: jobCard?.id }}
routeKey={PAYMENT_RECEIVED_ROUTES.INDEX}
getClient={(api) => api.paymentReceived}
tableHeader={({ invalidateQuery }) =>
<div className="p-2">
<FormDialog title="Record Payment">
{(resourceId) => (
<PaymentReceivedForm
resourceId={resourceId}
defaultJobCard={{ id: jobCard?.id, title: jobCard?.title }}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
</div>
}
columns={({ actionsColumn }) => [
{
accessorKey: "payment_number",
header: ({ column }) => <ColumnHeader column={column} title="Payment #" />,
cell: ({ row }) => {
const item = row.original
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: "amount_received",
header: ({ column }) => <ColumnHeader column={column} title="Amount" />,
cell: ({ row }) => {
const item = row.original
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">
{formatCurrency(item.amount_received)}
</span>
</div>
)
},
},
{
accessorKey: "payment_mode_name",
header: ({ column }) => <ColumnHeader column={column} title="Payment Mode" />,
cell: ({ row }) => {
const item = row.original as any
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
return (
<div className="flex items-center gap-2">
<CalendarIcon className="h-4 w-4 text-muted-foreground" />
<span>{formatDate(item.payment_date)}</span>
</div>
)
},
},
{
accessorKey: "note",
header: () => <span>Note</span>,
enableSorting: false,
cell: ({ row }) => {
const item = row.original
const note = item.note
if (!note) return <span className="text-muted-foreground"></span>
return (
<span className="max-w-50 truncate block" title={note}>
{note}
</span>
)
},
},
actionsColumn(),
]}
/>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
)
}

View File

@ -0,0 +1,128 @@
"use client"
import { useState } from "react"
import { useMutation } from "@tanstack/react-query"
import { useRouter } from "next/navigation"
import { Plus, Trash2, Lightbulb } from "lucide-react"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { confirm } from "@/shared/components/confirm-dialog"
import { Button } from "@/shared/components/ui/button"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import { Textarea } from "@/shared/components/ui/textarea"
type ShopRecommendation = {
id: number
recommendation: string
created_at?: string
}
type Props = {
jobCardId: string
recommendations: ShopRecommendation[]
}
export function JobCardRecommendationsList({ jobCardId, recommendations }: Props) {
const api = useAuthApi()
const router = useRouter()
const [newRecommendation, setNewRecommendation] = useState("")
const addMutation = useMutation({
mutationFn: (recommendation: string) =>
api.jobCards.addShopRecommendation(jobCardId, { recommendation }),
onSuccess: () => {
toast.success("Shop recommendation added")
setNewRecommendation("")
router.refresh()
},
onError: () => {
toast.error("Failed to add shop recommendation")
},
})
const deleteMutation = useMutation({
mutationFn: (recommendationId: number) =>
api.jobCards.deleteShopRecommendation(jobCardId, recommendationId),
onSuccess: () => {
toast.success("Shop recommendation deleted")
router.refresh()
},
onError: () => {
toast.error("Failed to delete shop recommendation")
},
})
const handleAdd = () => {
const trimmed = newRecommendation.trim()
if (!trimmed) return
addMutation.mutate(trimmed)
}
const handleDelete = async (recommendation: ShopRecommendation) => {
const confirmed = await confirm({
title: "Delete Shop Recommendation",
description: "Are you sure you want to delete this recommendation?",
confirmLabel: "Delete",
variant: "destructive",
})
if (confirmed) {
deleteMutation.mutate(recommendation.id)
}
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Lightbulb className="size-4" />
Shop Recommendations
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{recommendations.length === 0 && (
<p className="text-sm text-muted-foreground">No shop recommendations yet.</p>
)}
{recommendations.map((rec) => (
<div
key={rec.id}
className="flex items-start justify-between gap-3 rounded-lg border px-4 py-3"
>
<p className="text-sm whitespace-pre-wrap">{rec.recommendation}</p>
<Button
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(rec)}
disabled={deleteMutation.isPending}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
<div className="flex flex-col gap-2">
<Textarea
placeholder="Add a shop recommendation..."
value={newRecommendation}
onChange={(e) => setNewRecommendation(e.target.value)}
rows={2}
/>
<Button
size="sm"
className="self-end"
onClick={handleAdd}
disabled={addMutation.isPending || !newRecommendation.trim()}
>
<Plus className="size-4" />
{addMutation.isPending ? "Adding..." : "Add Recommendation"}
</Button>
</div>
</CardContent>
</Card>
)
}

View File

@ -0,0 +1,128 @@
"use client"
import { useState } from "react"
import { useMutation } from "@tanstack/react-query"
import { useRouter } from "next/navigation"
import { Plus, Trash2, MessageSquare } from "lucide-react"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { confirm } from "@/shared/components/confirm-dialog"
import { Button } from "@/shared/components/ui/button"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import { Textarea } from "@/shared/components/ui/textarea"
type CustomerRemark = {
id: number
remark: string
created_at?: string
}
type Props = {
jobCardId: string
remarks: CustomerRemark[]
}
export function JobCardRemarksList({ jobCardId, remarks }: Props) {
const api = useAuthApi()
const router = useRouter()
const [newRemark, setNewRemark] = useState("")
const addMutation = useMutation({
mutationFn: (remark: string) =>
api.jobCards.addCustomerRemark(jobCardId, { remark }),
onSuccess: () => {
toast.success("Customer remark added")
setNewRemark("")
router.refresh()
},
onError: () => {
toast.error("Failed to add customer remark")
},
})
const deleteMutation = useMutation({
mutationFn: (remarkId: number) =>
api.jobCards.deleteCustomerRemark(jobCardId, remarkId),
onSuccess: () => {
toast.success("Customer remark deleted")
router.refresh()
},
onError: () => {
toast.error("Failed to delete customer remark")
},
})
const handleAdd = () => {
const trimmed = newRemark.trim()
if (!trimmed) return
addMutation.mutate(trimmed)
}
const handleDelete = async (remark: CustomerRemark) => {
const confirmed = await confirm({
title: "Delete Customer Remark",
description: "Are you sure you want to delete this remark?",
confirmLabel: "Delete",
variant: "destructive",
})
if (confirmed) {
deleteMutation.mutate(remark.id)
}
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageSquare className="size-4" />
Customer Remarks
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{remarks.length === 0 && (
<p className="text-sm text-muted-foreground">No customer remarks yet.</p>
)}
{remarks.map((remark) => (
<div
key={remark.id}
className="flex items-start justify-between gap-3 rounded-lg border px-4 py-3"
>
<p className="text-sm whitespace-pre-wrap">{remark.remark}</p>
<Button
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(remark)}
disabled={deleteMutation.isPending}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
<div className="flex flex-col gap-2">
<Textarea
placeholder="Add a customer remark..."
value={newRemark}
onChange={(e) => setNewRemark(e.target.value)}
rows={2}
/>
<Button
size="sm"
className="self-end"
onClick={handleAdd}
disabled={addMutation.isPending || !newRemark.trim()}
>
<Plus className="size-4" />
{addMutation.isPending ? "Adding..." : "Add Remark"}
</Button>
</div>
</CardContent>
</Card>
)
}

View File

@ -55,19 +55,56 @@ const jobCardFormSchema = z.object({
vehicle: relationFieldSchema,
department: relationFieldSchema,
service_writer: relationFieldSchema,
primary_technician: relationFieldSchema,
sales_person: relationFieldSchema,
insurance_type: relationFieldSchema,
insurer: relationFieldSchema,
// ── Settings ──
// ── Numbers & identifiers ──
order_number: z.string().optional(),
estimate_number: z.string().optional(),
// ── Status & settings ──
status: z.string().optional(),
estimate_to: z.string().optional(),
tax_inclusive: z.string().optional(),
discount_type: z.string().optional(),
discount_at: z.string().optional(),
// ── Check-in details ──
// ── Dates & times ──
order_date: z.string().optional(),
check_in_date: z.string().optional(),
check_in_time: z.string().optional(),
start_date: z.string().optional(),
start_time: z.string().optional(),
delivery_date: z.string().optional(),
delivery_time: z.string().optional(),
// ── Vehicle state ──
km_in: z.string().optional(),
fuel_level: z.string().optional(),
// ── Boolean options ──
has_insurance: z.boolean().optional(),
enable_parts_issuing: z.boolean().optional(),
enable_digital_authorisation: z.boolean().optional(),
// ── Notes ──
footer: z.string().optional(),
// ── Customer Remarks ──
customer_remarks: z.array(z.string()).optional(),
// ── Labels ──
labels: z
.array(
z.object({
id: z.number(),
title: z.string(),
color_code: z.string(),
}),
)
.optional(),
})
type JobCardFormValues = z.infer<typeof jobCardFormSchema>
@ -80,7 +117,6 @@ export {
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

View File

@ -0,0 +1,337 @@
"use client"
import { useState } from "react"
import {
useFormContext,
useController,
type FieldValues,
type FieldPath,
} from "react-hook-form"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { Check, PlusIcon, X } from "lucide-react"
import { useAuthApi } from "@/shared/useApi"
import { LABEL_ROUTES } from "@garage/api"
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/components/ui/popover"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import {
Field,
FieldLabel,
FieldError,
FieldDescription,
} from "@/shared/components/ui/field"
import { cn } from "@/shared/lib/utils"
// ── Types ──
export type LabelItem = {
id: number
title: string
color_code: string
}
type RhfLabelPickerFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
> = {
name: TName
label?: string
description?: string
required?: boolean
disabled?: boolean
placeholder?: string
}
// ── Helpers ──
function extractLabels(response: unknown): LabelItem[] {
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 []
}
// ── Component ──
export function RhfLabelPickerField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
>({
name,
label,
description,
required,
disabled,
placeholder = "Select labels...",
}: RhfLabelPickerFieldProps<TValues, TName>) {
const api = useAuthApi()
const queryClient = useQueryClient()
const { control } = useFormContext<TValues>()
const { field, fieldState: { error } } = useController({ name, control, disabled })
const [open, setOpen] = useState(false)
const [search, setSearch] = useState("")
const [creating, setCreating] = useState(false)
const [newTitle, setNewTitle] = useState("")
const [newColor, setNewColor] = useState("#6366f1")
const [isSubmitting, setIsSubmitting] = useState(false)
const { data: allLabels = [] } = useQuery<LabelItem[]>({
queryKey: [LABEL_ROUTES.INDEX],
queryFn: async () => {
const res = await api.labels.list()
return extractLabels(res)
},
staleTime: 5 * 60 * 1000,
})
const selected: LabelItem[] = field.value ?? []
const filtered = search
? allLabels.filter((l) =>
l.title.toLowerCase().includes(search.toLowerCase()),
)
: allLabels
function toggle(lbl: LabelItem) {
const isSelected = selected.some((s) => s.id === lbl.id)
if (isSelected) {
field.onChange(selected.filter((s) => s.id !== lbl.id))
} else {
field.onChange([...selected, lbl])
}
}
function remove(id: number, e: React.MouseEvent) {
e.stopPropagation()
field.onChange(selected.filter((s) => s.id !== id))
}
async function handleCreate() {
if (!newTitle.trim()) return
setIsSubmitting(true)
try {
const res = await api.labels.create({
title: newTitle.trim(),
color_code: newColor,
}) as any
const created = res?.data ?? res
queryClient.invalidateQueries({ queryKey: [LABEL_ROUTES.INDEX] })
if (created?.id) {
field.onChange([
...selected,
{
id: created.id,
title: created.title ?? newTitle.trim(),
color_code: created.color_code ?? newColor,
},
])
}
setNewTitle("")
setNewColor("#6366f1")
setCreating(false)
} catch {
// silent toast handled upstream if desired
} finally {
setIsSubmitting(false)
}
}
return (
<Field data-invalid={!!error || undefined}>
{label && (
<FieldLabel>
{label}
{required && (
<span className="text-destructive ms-0.5">*</span>
)}
</FieldLabel>
)}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div
role="combobox"
aria-expanded={open}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
setOpen((v) => !v)
}
}}
className={cn(
"flex min-h-9 w-full cursor-pointer flex-wrap items-center gap-1.5 rounded-md border border-input bg-background px-3 py-1.5 text-sm transition-colors hover:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
disabled && "cursor-not-allowed opacity-50",
error && "border-destructive",
)}
>
{selected.length === 0 && (
<span className="text-muted-foreground">
{placeholder}
</span>
)}
{selected.map((s) => (
<span
key={s.id}
className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium"
style={{
backgroundColor: s.color_code + "28",
borderColor: s.color_code + "80",
color: s.color_code,
}}
>
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: s.color_code }}
/>
{s.title}
<button
type="button"
className="ml-0.5 rounded-full opacity-70 hover:opacity-100"
onClick={(e) => remove(s.id, e)}
tabIndex={-1}
aria-label={`Remove ${s.title}`}
>
<X className="h-2.5 w-2.5" />
</button>
</span>
))}
</div>
</PopoverTrigger>
<PopoverContent
className="flex w-64 flex-col overflow-hidden p-0"
align="start"
style={{
maxHeight:
"var(--radix-popover-content-available-height, 320px)",
}}
>
{/* Search */}
<div className="shrink-0 p-2">
<Input
placeholder="Search labels..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8"
/>
</div>
{/* List */}
<ScrollArea className="min-h-0 flex-1">
<div className="px-1 pb-1">
{filtered.length === 0 && (
<p className="py-4 text-center text-xs text-muted-foreground">
No labels found
</p>
)}
{filtered.map((lbl) => {
const isSelected = selected.some(
(s) => s.id === lbl.id,
)
return (
<button
key={lbl.id}
type="button"
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
onClick={() => toggle(lbl)}
>
<span
className="h-3 w-3 shrink-0 rounded-full"
style={{
backgroundColor: lbl.color_code,
}}
/>
<span className="flex-1 text-start">
{lbl.title}
</span>
{isSelected && (
<Check className="h-3.5 w-3.5 text-primary" />
)}
</button>
)
})}
</div>
</ScrollArea>
{/* Footer: create */}
<div className="shrink-0 border-t p-2">
{creating ? (
<div className="flex flex-col gap-2">
<Input
placeholder="Label name"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
className="h-8"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleCreate()
}
if (e.key === "Escape") setCreating(false)
}}
autoFocus
/>
<div className="flex items-center gap-2">
<input
type="color"
value={newColor}
onChange={(e) =>
setNewColor(e.target.value)
}
className="h-8 w-8 cursor-pointer rounded border p-0.5"
title="Pick a color"
/>
<span className="flex-1 text-xs text-muted-foreground">
Color
</span>
<Button
type="button"
size="sm"
className="h-7 text-xs"
onClick={handleCreate}
disabled={
isSubmitting || !newTitle.trim()
}
>
{isSubmitting ? "..." : "Create"}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 text-xs"
onClick={() => setCreating(false)}
>
Cancel
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-full justify-start gap-1.5 text-xs"
onClick={() => setCreating(true)}
>
<PlusIcon className="h-3.5 w-3.5" />
Create label
</Button>
)}
</div>
</PopoverContent>
</Popover>
{description && (
<FieldDescription>{description}</FieldDescription>
)}
{error && <FieldError>{error.message}</FieldError>}
</Field>
)
}

View File

@ -24,7 +24,7 @@ import {
} from "./payment-made.schema"
import {
PAYMENT_MADE_ROUTES,
PAYMENT_ROUTES,
PAYMENT_MODE_ROUTES,
VENDOR_ROUTES,
EMPLOYEE_ROUTES,
PaymentFor,
@ -222,8 +222,8 @@ export function PaymentMadeForm({ resourceId, initialData, onSuccess }: PaymentM
name="payment_mode"
label="Payment Mode"
placeholder="Select payment mode"
queryKey={[PAYMENT_ROUTES.MODES]}
listFn={() => api.payments.listModes()}
queryKey={[PAYMENT_MODE_ROUTES.INDEX]}
listFn={() => api.paymentModes.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>

View File

@ -1,5 +1,6 @@
"use client"
import { useMemo } from "react"
import { AlertTriangle, Plus, Save } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
@ -21,7 +22,7 @@ import {
paymentReceivedFormSchema,
type PaymentReceivedFormValues,
} from "./payment-received.schema"
import { PAYMENT_ROUTES, CUSTOMER_ROUTES, JOB_CARD_ROUTES } from "@garage/api"
import { PAYMENT_MODE_ROUTES, CUSTOMER_ROUTES, JOB_CARD_ROUTES } from "@garage/api"
// ── Props ──
@ -29,6 +30,7 @@ export type PaymentReceivedFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
defaultJobCard?: { id?: number | null; title?: string | null } | null
}
// ── Default values ──
@ -82,14 +84,24 @@ const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) =
// ── Component ──
export function PaymentReceivedForm({ resourceId, initialData, onSuccess }: PaymentReceivedFormProps) {
export function PaymentReceivedForm({ resourceId, initialData, onSuccess, defaultJobCard }: PaymentReceivedFormProps) {
const api = useAuthApi()
const resolvedInitialData = useMemo(() => {
if (!resourceId && defaultJobCard?.id != null) {
return {
...(initialData as any),
job_card: toRelation(defaultJobCard.id, defaultJobCard.title ?? undefined),
}
}
return initialData
}, [resourceId, defaultJobCard, initialData])
const { form, isEditing } = useResourceForm<PaymentReceivedFormValues, any>({
schema: paymentReceivedFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
initialData: resolvedInitialData,
mapToFormValues,
})
@ -97,8 +109,8 @@ export function PaymentReceivedForm({ resourceId, initialData, onSuccess }: Paym
mutationFn: (values: PaymentReceivedFormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing && resourceId
? api.payments.updateReceived(resourceId, payload as any)
: api.payments.createReceived(payload as any)
? api.paymentReceived.update(resourceId, payload as any)
: api.paymentReceived.create(payload as any)
toast.promise(promise, {
loading: isEditing ? "Updating payment..." : "Recording payment...",
success: isEditing ? "Payment updated successfully" : "Payment recorded successfully",
@ -148,7 +160,7 @@ export function PaymentReceivedForm({ resourceId, initialData, onSuccess }: Paym
listFn={() => api.jobCards.list()}
mapOption={(item: any) => ({
value: String(item.id),
label: item.job_card_number || item.name || `#${item.id}`,
label: item.title,
})}
{...STORE_OBJECT}
/>
@ -166,8 +178,8 @@ export function PaymentReceivedForm({ resourceId, initialData, onSuccess }: Paym
name="payment_mode"
label="Payment Mode"
placeholder="Select payment mode"
queryKey={[PAYMENT_ROUTES.MODES]}
listFn={() => api.payments.listModes()}
queryKey={[PAYMENT_MODE_ROUTES.INDEX]}
listFn={() => api.paymentModes.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>

View File

@ -10,6 +10,7 @@ import {
RhfTextField,
RhfTextareaField,
RhfAsyncSelectField,
RhfDateField,
} from "@/shared/components/form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
@ -21,7 +22,8 @@ import {
purchaseOrderFormSchema,
type PurchaseOrderFormValues,
} from "./purchase-order.schema"
import { PURCHASE_ORDER_ROUTES, VENDOR_ROUTES, JOB_CARD_ROUTES, DEPARTMENT_ROUTES } from "@garage/api"
import { VENDOR_ROUTES, JOB_CARD_ROUTES, DEPARTMENT_ROUTES } from "@garage/api"
import { getFullName } from "@/shared/utils/getFullName"
// ── Props ──
@ -38,7 +40,7 @@ const DEFAULT_VALUES: PurchaseOrderFormValues = {
job_card: null,
department: null,
title: "",
order_date: "",
order_date: new Date().toISOString().split("T")[0],
delivery_date: "",
notes: "",
}
@ -128,8 +130,8 @@ export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: Purcha
<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" />
<RhfDateField name="order_date" label="Order Date" />
<RhfDateField name="delivery_date" label="Delivery Date" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
@ -139,7 +141,7 @@ export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: Purcha
placeholder="Select vendor"
queryKey={[VENDOR_ROUTES.INDEX]}
listFn={() => api.vendors.list()}
mapOption={mapLookupOption}
mapOption={(op: any) => ({ label: getFullName(op as any), value: String(op.id) })}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
@ -161,7 +163,7 @@ export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: Purcha
listFn={() => api.jobCards.list()}
mapOption={(item: any) => ({
value: String(item.id),
label: item.job_card_number || item.name || `#${item.id}`,
label: item.title || `#${item.id}`,
})}
{...STORE_OBJECT}
/>

View File

@ -0,0 +1,266 @@
"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,
RhfDateField,
} 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 { taskFormSchema, type TaskFormValues } from "./task.schema"
import { TASK_ROUTES, TASK_TYPE_ROUTES, TASK_SECTION_ROUTES, EMPLOYEE_ROUTES, DEPARTMENT_ROUTES, JOB_CARD_ROUTES } from "@garage/api"
import { TaskStatus, TaskPriority } from "@garage/api"
import { TaskTypeCrudDialog } from "./task-type-crud-dialog"
import { TaskSectionCrudDialog } from "./task-section-crud-dialog"
// ── Constants ──
const STATUS_OPTIONS = TaskStatus.map((s) => ({
value: s,
label: s.charAt(0).toUpperCase() + s.slice(1),
}))
const PRIORITY_OPTIONS = TaskPriority.map((p,i) => ({
value: i,
label: p.charAt(0).toUpperCase() + p.slice(1),
}))
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
// ── Props ──
export type TaskFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
// ── Default values ──
const DEFAULT_VALUES: TaskFormValues = {
subject: "",
description: "",
task_type: null,
task_section: null,
owner: null,
department: null,
priority: "medium",
due_date: "",
status: "pending",
job_card: null,
}
// ── Mapping helpers ──
function mapToFormValues(data: unknown): TaskFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
subject: d.subject || "",
description: d.description || "",
task_type: toRelation(d.task_type_id, d.task_type?.title),
task_section: toRelation(d.task_section_id, d.task_section?.title),
owner: toRelation(
d.owner_id,
d.owner ? `${d.owner.first_name ?? ""} ${d.owner.last_name ?? ""}`.trim() : undefined,
),
department: toRelation(d.department_id, d.department?.name),
priority: d.priority || "medium",
due_date: d.due_date ? d.due_date.split("T")[0] : "",
status: d.status || "pending",
job_card: toRelation(d.job_card_id, d.job_card?.title),
}
}
function mapFormToPayload(values: TaskFormValues) {
return {
subject: values.subject,
description: values.description || undefined,
task_type_id: toId(values.task_type),
task_section_id: toId(values.task_section),
owner_id: toId(values.owner),
department_id: toId(values.department),
priority: values.priority || undefined,
due_date: values.due_date || undefined,
status: values.status || undefined,
job_card_id: toId(values.job_card),
}
}
// ── Component ──
export function TaskForm({ resourceId, initialData, onSuccess }: TaskFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<TaskFormValues, any>({
schema: taskFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
mapToFormValues,
queryKey: [TASK_ROUTES.BY_ID, resourceId],
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: TaskFormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing && resourceId
? api.tasks.update(resourceId, payload as any)
: api.tasks.create(payload as any)
toast.promise(promise, {
loading: isEditing ? "Updating task..." : "Creating task...",
success: isEditing ? "Task updated successfully" : "Task created successfully",
error: isEditing ? "Failed to update task" : "Failed to create task",
})
return promise
},
onSuccess: () => {
form.reset()
onSuccess?.()
},
})
return (
<Rhform form={form} onSubmit={(values) => mutate(values)} className="space-y-6">
{error && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>
{isEditing ? "Failed to update task" : "Failed to create task"}
</AlertTitle>
</Alert>
)}
<FieldGroup>
<RhfTextField
name="subject"
label="Subject"
placeholder="e.g. Inspect brake pads"
required
/>
<RhfTextareaField
name="description"
label="Description"
placeholder="Optional description..."
/>
</FieldGroup>
<FieldGroup>
<div className="grid grid-cols-2 gap-4">
{/* Task Type with inline CRUD */}
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium">Task Type</span>
<TaskTypeCrudDialog />
</div>
<RhfAsyncSelectField
name="task_type"
label=""
placeholder="Select task type..."
queryKey={[TASK_TYPE_ROUTES.INDEX]}
listFn={() => api.taskTypes.list()}
mapOption={(item: any) => ({ value: String(item.id), label: item.title })}
{...STORE_OBJECT}
/>
</div>
{/* Task Section with inline CRUD */}
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium">Task Section</span>
<TaskSectionCrudDialog />
</div>
<RhfAsyncSelectField
name="task_section"
label=""
placeholder="Select task section..."
queryKey={[TASK_SECTION_ROUTES.INDEX]}
listFn={() => api.taskSections.list()}
mapOption={(item: any) => ({ value: String(item.id), label: item.title })}
{...STORE_OBJECT}
/>
</div>
</div>
</FieldGroup>
<FieldGroup>
<div className="grid grid-cols-2 gap-4">
<RhfAsyncSelectField
name="owner"
label="Assigned To"
placeholder="Select employee..."
queryKey={[EMPLOYEE_ROUTES.INDEX]}
listFn={() => api.employees.list()}
mapOption={(item: any) => ({
value: String(item.id),
label: `${item.first_name ?? ""} ${item.last_name ?? ""}`.trim(),
})}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department..."
queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.departments.list()}
mapOption={(item: any) => ({ value: String(item.id), label: item.name })}
{...STORE_OBJECT}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<RhfSelectField
name="priority"
label="Priority"
placeholder="Select priority..."
options={PRIORITY_OPTIONS as any}
/>
<RhfSelectField
name="status"
label="Status"
placeholder="Select status..."
options={STATUS_OPTIONS}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<RhfDateField name="due_date" label="Due Date" />
<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.title || `#${item.id}`,
})}
{...STORE_OBJECT}
/>
</div>
</FieldGroup>
<Button type="submit" disabled={isPending} className="w-full">
{isEditing ? <Save className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
{isPending
? isEditing ? "Updating..." : "Creating..."
: isEditing ? "Update Task" : "Add Task"}
</Button>
</Rhform>
)
}

View File

@ -0,0 +1,43 @@
"use client"
import { CrudDialog } from "@/shared/components/crud-dialog"
import { ColumnHeader } from "@/shared/data-view/table-view"
import { useAuthApi } from "@/shared/useApi"
import { TASK_SECTION_ROUTES } from "@garage/api"
import { TaskSectionForm } from "./task-section-form"
export function TaskSectionCrudDialog() {
const api = useAuthApi()
return (
<CrudDialog
title="Task Section"
queryKey={[TASK_SECTION_ROUTES.INDEX]}
getClient={() => api.taskSections}
resourceLabel="task section"
columns={() => [
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
},
{
accessorKey: "arrangement",
header: ({ column }) => <ColumnHeader column={column} title="Order" />,
},
{
accessorKey: "is_default",
header: () => <span>Default</span>,
cell: ({ row }) => ((row.original as any).is_default ? "Yes" : "No"),
enableSorting: false,
},
]}
renderForm={({ resourceId, initialData, onSuccess }) => (
<TaskSectionForm
resourceId={resourceId}
initialData={initialData}
onSuccess={onSuccess}
/>
)}
/>
)
}

View File

@ -0,0 +1,107 @@
"use client"
import { z } from "zod"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { Plus, Save } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { FieldGroup } from "@/shared/components/ui/field"
import { Rhform, RhfTextField, RhfCheckboxField } from "@/shared/components/form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { useEffect } from "react"
// ── Schema ──
const taskSectionSchema = z.object({
title: z.string().min(1, "Title is required"),
arrangement: z.string().optional(),
is_default: z.boolean().optional(),
})
type TaskSectionFormValues = z.infer<typeof taskSectionSchema>
// ── Props ──
type TaskSectionFormProps = {
resourceId?: string | null
initialData?: any
onSuccess?: () => void
}
// ── Component ──
export function TaskSectionForm({ resourceId, initialData, onSuccess }: TaskSectionFormProps) {
const api = useAuthApi()
const isEditing = !!resourceId
const form = useForm<TaskSectionFormValues>({
resolver: zodResolver(taskSectionSchema),
defaultValues: { title: "", arrangement: "", is_default: false },
})
useEffect(() => {
if (initialData) {
const d = initialData?.data ?? initialData
form.reset({
title: d.title ?? "",
arrangement: d.arrangement != null ? String(d.arrangement) : "",
is_default: d.is_default ?? false,
})
}
}, [initialData, form])
const handleSubmit = async (values: TaskSectionFormValues) => {
try {
const promise = isEditing
? api.taskSections.update(resourceId!, {
title: values.title,
arrangement: values.arrangement ? Number(values.arrangement) : undefined,
is_default: values.is_default,
})
: api.taskSections.create({
title: values.title,
arrangement: values.arrangement ? Number(values.arrangement) : undefined,
is_default: values.is_default,
})
toast.promise(promise, {
loading: isEditing ? "Updating..." : "Creating...",
success: isEditing ? "Updated successfully" : "Created successfully",
error: isEditing ? "Failed to update" : "Failed to create",
})
await promise
form.reset()
onSuccess?.()
} catch {
// toast already shown
}
}
return (
<Rhform form={form} onSubmit={handleSubmit}>
<FieldGroup>
<RhfTextField
name="title"
label="Title"
placeholder="e.g. Pre-Work"
required
/>
<RhfTextField
name="arrangement"
label="Arrangement Order"
placeholder="e.g. 1"
type="number"
/>
<RhfCheckboxField name="is_default" label="Set as default" />
<Button type="submit" disabled={form.formState.isSubmitting}>
{isEditing ? <Save className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
{form.formState.isSubmitting
? isEditing ? "Updating..." : "Creating..."
: isEditing ? "Update" : "Create"}
</Button>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,39 @@
"use client"
import { CrudDialog } from "@/shared/components/crud-dialog"
import { ColumnHeader } from "@/shared/data-view/table-view"
import { useAuthApi } from "@/shared/useApi"
import { TASK_TYPE_ROUTES } from "@garage/api"
import { TaskTypeForm } from "./task-type-form"
export function TaskTypeCrudDialog() {
const api = useAuthApi()
return (
<CrudDialog
title="Task Type"
queryKey={[TASK_TYPE_ROUTES.INDEX]}
getClient={() => api.taskTypes}
resourceLabel="task type"
columns={() => [
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
},
{
accessorKey: "is_default",
header: () => <span>Default</span>,
cell: ({ row }) => ((row.original as any).is_default ? "Yes" : "No"),
enableSorting: false,
},
]}
renderForm={({ resourceId, initialData, onSuccess }) => (
<TaskTypeForm
resourceId={resourceId}
initialData={initialData}
onSuccess={onSuccess}
/>
)}
/>
)
}

View File

@ -0,0 +1,91 @@
"use client"
import { z } from "zod"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { Plus, Save } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { FieldGroup } from "@/shared/components/ui/field"
import { Rhform, RhfTextField, RhfCheckboxField } from "@/shared/components/form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { useEffect } from "react"
// ── Schema ──
const taskTypeSchema = z.object({
title: z.string().min(1, "Title is required"),
is_default: z.boolean().optional(),
})
type TaskTypeFormValues = z.infer<typeof taskTypeSchema>
// ── Props ──
type TaskTypeFormProps = {
resourceId?: string | null
initialData?: any
onSuccess?: () => void
}
// ── Component ──
export function TaskTypeForm({ resourceId, initialData, onSuccess }: TaskTypeFormProps) {
const api = useAuthApi()
const isEditing = !!resourceId
const form = useForm<TaskTypeFormValues>({
resolver: zodResolver(taskTypeSchema),
defaultValues: { title: "", is_default: false },
})
useEffect(() => {
if (initialData) {
const d = initialData?.data ?? initialData
form.reset({
title: d.title ?? "",
is_default: d.is_default ?? false,
})
}
}, [initialData, form])
const handleSubmit = async (values: TaskTypeFormValues) => {
try {
const promise = isEditing
? api.taskTypes.update(resourceId!, values)
: api.taskTypes.create(values)
toast.promise(promise, {
loading: isEditing ? "Updating..." : "Creating...",
success: isEditing ? "Updated successfully" : "Created successfully",
error: isEditing ? "Failed to update" : "Failed to create",
})
await promise
form.reset()
onSuccess?.()
} catch {
// toast already shown
}
}
return (
<Rhform form={form} onSubmit={handleSubmit}>
<FieldGroup>
<RhfTextField
name="title"
label="Title"
placeholder="e.g. Maintenance"
required
/>
<RhfCheckboxField name="is_default" label="Set as default" />
<Button type="submit" disabled={form.formState.isSubmitting}>
{isEditing ? <Save className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
{form.formState.isSubmitting
? isEditing ? "Updating..." : "Creating..."
: isEditing ? "Update" : "Create"}
</Button>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,20 @@
import { z } from "zod"
export const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
export const taskFormSchema = z.object({
subject: z.string().min(1, "Subject is required"),
description: z.string().optional(),
task_type: relationFieldSchema,
task_section: relationFieldSchema,
owner: relationFieldSchema,
department: relationFieldSchema,
priority: z.string().optional(),
due_date: z.string().optional(),
status: z.string().optional(),
job_card: relationFieldSchema,
})
export type TaskFormValues = z.infer<typeof taskFormSchema>

View File

@ -0,0 +1,149 @@
"use client"
import React, { useState } from "react"
import type { ColumnDef } from "@tanstack/react-table"
import { Settings2, Plus, ArrowLeft } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { DataTable } from "@/shared/data-view/table-view"
import { createActionsColumn } from "@/shared/data-view/table-view"
import { useCrudDialog, type CrudDialogClient, type UseCrudDialogOptions } from "./use-crud-dialog"
// ── Types ──
export type CrudDialogProps<TClient extends CrudDialogClient> = UseCrudDialogOptions<TClient> & {
/** Dialog title shown in the header */
title: string
/** Table columns (receives `openEdit` and `handleDelete` helpers) */
columns: (helpers: {
openEdit: (row: any) => void
handleDelete: (row: any) => Promise<void>
}) => ColumnDef<any>[]
/** Render the create/edit form */
renderForm: (props: {
resourceId: string | null
initialData: any
onSuccess: () => void
}) => React.ReactNode
/** Optional trigger button; defaults to a settings icon button */
trigger?: React.ReactNode
/** Custom trigger class */
triggerClassName?: string
}
// ── Component ──
export function CrudDialog<TClient extends CrudDialogClient>({
title,
columns: columnsFn,
renderForm,
trigger,
triggerClassName,
...hookOptions
}: CrudDialogProps<TClient>) {
const [isOpen, setIsOpen] = useState(false)
const crud = useCrudDialog(hookOptions)
const columns = columnsFn({
openEdit: crud.openEdit,
handleDelete: crud.handleDelete,
})
// Add actions column
const allColumns: ColumnDef<any>[] = [
...columns,
createActionsColumn({
onEdit: crud.openEdit,
onDelete: crud.handleDelete,
}),
]
const handleClose = () => {
setIsOpen(false)
crud.closeForm()
}
return (
<>
{trigger ? (
<div onClick={() => setIsOpen(true)}>{trigger}</div>
) : (
<Button
type="button"
size="icon"
variant="ghost"
className={triggerClassName ?? "h-5 w-5"}
onClick={() => setIsOpen(true)}
title={`Manage ${title}`}
>
<Settings2 className="h-3.5 w-3.5" />
</Button>
)}
<Dialog open={isOpen} onOpenChange={(v) => { if (!v) handleClose() }}>
<DialogContent className="min-w-2xl max-w-3xl">
<DialogHeader>
<div className="flex items-center gap-2">
{crud.isFormOpen && (
<Button
type="button"
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={crud.closeForm}
>
<ArrowLeft className="h-4 w-4" />
</Button>
)}
<DialogTitle className="text-xl font-bold">
{crud.isFormOpen
? (crud.editingId ? `Edit ${title}` : `New ${title}`)
: title}
</DialogTitle>
</div>
</DialogHeader>
<ScrollArea className="max-h-[75vh]">
{crud.isFormOpen ? (
<div className="px-1">
{renderForm({
resourceId: crud.editingId,
initialData: crud.editingItem,
onSuccess: crud.handleFormSuccess,
})}
</div>
) : (
<div className="space-y-3 px-1">
<div className="flex justify-end">
<Button
type="button"
size="sm"
onClick={crud.openCreate}
>
<Plus className="h-4 w-4" />
Add {title}
</Button>
</div>
<DataTable
columns={allColumns}
data={crud.items}
pagination={crud.pagination}
sorting={crud.sorting}
onChange={crud.handleChange}
isLoading={crud.isLoading}
/>
</div>
)}
</ScrollArea>
</DialogContent>
</Dialog>
</>
)
}

View File

@ -0,0 +1,2 @@
export { CrudDialog, type CrudDialogProps } from "./crud-dialog"
export { useCrudDialog, type CrudDialogClient, type UseCrudDialogOptions } from "./use-crud-dialog"

View File

@ -0,0 +1,154 @@
"use client"
import { useState } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import { confirm } from "@/shared/components/confirm-dialog"
import type { DataViewChangeEvent, DataViewPaginationState, DataViewSorting } from "@/shared/data-view/table-view"
// ── Types ──
export type CrudDialogClient = {
list(query?: any): Promise<any>
create(payload: any): Promise<any>
update(id: string, payload: any): Promise<any>
destroy(id: string): Promise<any>
}
export type UseCrudDialogOptions<TClient extends CrudDialogClient> = {
queryKey: string[]
getClient: () => TClient
resourceLabel?: string
}
// ── Hook ──
export function useCrudDialog<TClient extends CrudDialogClient>({
queryKey,
getClient,
resourceLabel = "item",
}: UseCrudDialogOptions<TClient>) {
const client = getClient()
const queryClient = useQueryClient()
// ── Local pagination state (no URL pollution) ──
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [sortBy, setSortBy] = useState<string | null>(null)
const [sortOrder, setSortOrder] = useState<"asc" | "desc" | null>(null)
// ── Form dialog state ──
const [isFormOpen, setIsFormOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [editingItem, setEditingItem] = useState<any>(null)
const fullQueryKey = [...queryKey, { page, pageSize, sortBy, sortOrder }]
const { data, isLoading } = useQuery({
queryKey: fullQueryKey,
queryFn: () => {
const params: Record<string, unknown> = { page, per_page: pageSize }
if (sortBy) params.sort_by = sortBy
if (sortOrder) params.sort_order = sortOrder
return client.list(params)
},
})
const responseData = (data as any)?.data ?? []
const items = Array.isArray(responseData) ? responseData : []
const meta = (data as any)?.meta
const pagination: DataViewPaginationState = {
page,
pageSize,
pageCount: meta?.last_page ?? 1,
total: meta?.total ?? 0,
}
const sorting: DataViewSorting = sortBy
? [{ id: sortBy, desc: sortOrder === "desc" }]
: []
const handleChange = (event: DataViewChangeEvent) => {
switch (event.type) {
case "pagination":
setPage(event.pagination.page)
setPageSize(event.pagination.pageSize)
break
case "sorting": {
const sort = event.sorting[0]
setSortBy(sort?.id ?? null)
setSortOrder(sort ? (sort.desc ? "desc" : "asc") : null)
setPage(1)
break
}
}
}
const invalidateQuery = () => {
queryClient.invalidateQueries({ queryKey })
}
const { mutateAsync: deleteItem } = useMutation({
mutationFn: (id: string) => {
const promise = client.destroy(id)
toast.promise(promise, {
loading: `Deleting ${resourceLabel}...`,
success: `${resourceLabel} deleted`,
error: `Failed to delete ${resourceLabel}`,
})
return promise
},
onSuccess: invalidateQuery,
})
const openCreate = () => {
setEditingId(null)
setEditingItem(null)
setIsFormOpen(true)
}
const openEdit = (row: any) => {
setEditingId(String(row.id))
setEditingItem(row)
setIsFormOpen(true)
}
const closeForm = () => {
setIsFormOpen(false)
setEditingId(null)
setEditingItem(null)
}
const handleDelete = async (row: any) => {
const confirmed = await confirm({
title: `Delete this ${resourceLabel}?`,
description: "This action cannot be undone.",
confirmLabel: "Delete",
variant: "destructive",
})
if (confirmed) await deleteItem(String(row.id))
}
const handleFormSuccess = () => {
invalidateQuery()
closeForm()
}
return {
items,
isLoading,
pagination,
sorting,
handleChange,
isFormOpen,
editingId,
editingItem,
openCreate,
openEdit,
closeForm,
handleDelete,
handleFormSuccess,
invalidateQuery,
}
}

View File

@ -4,6 +4,7 @@ import { Button } from '@/shared/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/components/ui/dialog'
import { ScrollArea } from '@/shared/components/ui/scroll-area'
import { Plus } from 'lucide-react'
import { cn } from '../lib/utils'
export const formDialogParams = {
dialog: parseAsBoolean.withDefault(false),
@ -61,23 +62,29 @@ export default function FormDialog(props: {
children: (resourceId: string | null) => React.ReactNode
title: string
paramKey?: string
classNames?: {
trigger?: string
dialogContent?: string
scrollArea?: string
}
}) {
const { isOpen, resourceId, open, close } = useFormDialog(props.paramKey)
return (
<>
<Button size='lg' onClick={() => open()}>
<Button size='sm' className={cn(props.classNames?.trigger)} onClick={() => open()}>
<Plus />
{props.title}
</Button>
<Dialog open={isOpen} onOpenChange={(v) => { if (!v) close() }}>
<DialogContent className='min-w-xl'>
<DialogContent className={`min-w-xl ${cn(props.classNames?.dialogContent)}`}>
<DialogHeader>
<DialogTitle className='text-2xl font-bold'>
{props.title}
</DialogTitle>
</DialogHeader>
<ScrollArea className='max-h-[80vh] px-4'>
<ScrollArea className={`max-h-[80vh] px-4 ${cn(props.classNames?.scrollArea)}`}>
{props.children(resourceId)}
</ScrollArea>
</DialogContent>

View File

@ -54,6 +54,12 @@ export function AsyncSelectField<TOption = AsyncOption>({
value={value}
onValueChange={(val) => onChange(val)}
disabled={disabled}
isItemEqualToValue={(item, val) => {
if (item !== null && typeof item === "object" && "value" in (item as object)) {
return (item as any).value === (val as any)?.value
}
return item === val
}}
onInputValueChange={(val, { reason }) => {
if (reason === "input-change") {
onInputValueChange?.(val)

View File

@ -0,0 +1,100 @@
"use client"
import { useState } from "react"
import { useFormContext, type FieldValues, type FieldPath } from "react-hook-form"
import { useQuery } from "@tanstack/react-query"
import { RefreshCw } from "lucide-react"
import { useAuthApi } from "@/shared/useApi"
import { AUTO_GENERATE_ROUTES } from "@garage/api"
import { FieldShell } from "../field-shell"
import { Input } from "@/shared/components/ui/input"
import { Button } from "@/shared/components/ui/button"
type RhfAutoGenerateFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
> = {
name: TName
label?: string
description?: string
required?: boolean
disabled?: boolean
placeholder?: string
table: string
/** When true, fetches the next code immediately on mount */
autoFetch?: boolean
}
export function RhfAutoGenerateField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
>({
name,
label,
description,
required,
disabled,
placeholder,
table,
autoFetch = false,
}: RhfAutoGenerateFieldProps<TValues, TName>) {
const api = useAuthApi()
const { setValue, watch, formState: { errors } } = useFormContext<TValues>()
const value = watch(name)
const error = errors[name]
const [enabled, setEnabled] = useState(autoFetch)
const { isFetching } = useQuery({
queryKey: [AUTO_GENERATE_ROUTES.BY_TABLE, table],
queryFn: async () => {
const res = await api.autoGenerate.generate(table)
const generated = (res as any)?.data
if (generated) {
setValue(name, generated as any, { shouldValidate: true })
}
return res
},
enabled,
refetchOnWindowFocus: false,
staleTime: 0,
gcTime: 0,
})
const handleGenerate = () => {
setEnabled(false)
// Reset and re-enable to trigger a fresh fetch
setTimeout(() => setEnabled(true), 0)
}
return (
<FieldShell
label={label}
error={error?.message as string | undefined}
description={description}
required={required}
>
<div className="flex gap-2">
<Input
value={value ?? ""}
onChange={(e) => setValue(name, e.target.value as any, { shouldValidate: true })}
name={name}
disabled={disabled}
aria-invalid={!!error || undefined}
placeholder={placeholder}
/>
<Button
type="button"
variant="outline"
size="icon"
disabled={disabled || isFetching}
onClick={handleGenerate}
title="Auto-generate"
>
<RefreshCw className={isFetching ? "animate-spin" : ""} />
</Button>
</div>
</FieldShell>
)
}

View File

@ -39,3 +39,4 @@ export { RhfAsyncSelectField, RhfAsyncMultiSelectField, type InlineCreateFormPro
export { SimpleTitleForm, type SimpleTitleFormProps } from "./fields/simple-title-form"
export { RhfDateField } from "./fields/rhf-date-field"
export { RhfTimeField } from "./fields/rhf-time-field"
export { RhfAutoGenerateField } from "./fields/rhf-auto-generate-field"

View File

@ -137,7 +137,7 @@ function SidebarProvider({
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-card",
className
)}
{...props}
@ -168,7 +168,7 @@ function Sidebar({
<div
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
"flex h-full w-(--sidebar-width) flex-col bg-card text-sidebar-foreground",
className
)}
{...props}
@ -186,7 +186,7 @@ function Sidebar({
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
className="w-(--sidebar-width) bg-card p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
@ -241,7 +241,7 @@ function Sidebar({
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
className="flex size-full flex-col bg-card group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
>
{children}
</div>
@ -288,10 +288,10 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-card-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize rtl:in-data-[side=left]:cursor-e-resize in-data-[side=right]:cursor-e-resize rtl:in-data-[side=right]:cursor-w-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize rtl:[[data-side=left][data-state=collapsed]_&]:cursor-w-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize rtl:[[data-side=right][data-state=collapsed]_&]:cursor-e-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 rtl:group-data-[collapsible=offcanvas]:-translate-x-0 group-data-[collapsible=offcanvas]:after:start-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"group-data-[collapsible=offcanvas]:translate-x-0 rtl:group-data-[collapsible=offcanvas]:-translate-x-0 group-data-[collapsible=offcanvas]:after:start-full hover:group-data-[collapsible=offcanvas]:bg-card",
"[[data-side=left][data-collapsible=offcanvas]_&]:-end-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-start-2",
className
@ -358,7 +358,7 @@ function SidebarSeparator({
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
className={cn("mx-2 w-auto bg-card-border", className)}
{...props}
/>
)
@ -421,7 +421,7 @@ function SidebarGroupAction({
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"absolute top-3.5 end-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
"absolute top-3.5 end-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-card-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className
)}
{...props}
@ -466,13 +466,13 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-start text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pe-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-start text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pe-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-card-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-card-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-card-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-card-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
default: "hover:bg-card-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-card-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm " ,
@ -553,7 +553,7 @@ function SidebarMenuAction({
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"absolute top-1.5 end-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
"absolute top-1.5 end-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-card-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className
@ -666,7 +666,7 @@ function SidebarMenuSubButton({
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px rtl:translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"flex h-7 min-w-0 -translate-x-px rtl:translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-card-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-card-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-card-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
className
)}
{...props}

View File

@ -0,0 +1,95 @@
"use client"
import React from "react"
import { DataTable, type ActionsColumnOptions } from "@/shared/data-view/table-view"
import { useResourcePage, type UseResourcePageOptions, type ResourceItem, type ResourcePageClient } from "./use-resource-page"
import type { ColumnDef } from "@tanstack/react-table"
export type CrudResourceColumnHelpers<TClient extends ResourcePageClient> = {
actionsColumn: (options?: Partial<ActionsColumnOptions<ResourceItem<TClient>>>) => ColumnDef<ResourceItem<TClient>, unknown>
openEdit: (row: ResourceItem<TClient>) => void
deleteItem: (id: string) => Promise<unknown>
}
export type CrudResourceContext<TClient extends ResourcePageClient> = {
selectedItem: ResourceItem<TClient> | null
isDialogOpen: boolean
dialogResourceId: string | null
isLoading: boolean
data: ResourceItem<TClient>[]
openCreate: () => void
openEdit: (row: ResourceItem<TClient>) => void
closeDialog: () => void
deleteItem: (id: string) => Promise<unknown>
invalidateQuery: () => void
}
type ReactNodeOrRender<TClient extends ResourcePageClient> =
| React.ReactNode
| ((context: CrudResourceContext<TClient>) => React.ReactNode)
export type CrudResourceProps<TClient extends ResourcePageClient> = UseResourcePageOptions<TClient> & {
columns: ColumnDef<ResourceItem<TClient>>[] | ((helpers: CrudResourceColumnHelpers<TClient>) => ColumnDef<ResourceItem<TClient>>[])
onRowClick?: (row: ResourceItem<TClient>) => void
tableHeader?: ReactNodeOrRender<TClient>
render?: (table: React.ReactElement, context: CrudResourceContext<TClient>) => React.ReactElement
}
export function CrudResource<TClient extends ResourcePageClient>({
columns: columnsProp,
routeKey,
getClient,
queryOptions,
paramKey,
extraParams,
onRowClick,
tableHeader,
render,
}: CrudResourceProps<TClient>) {
type TItem = ResourceItem<TClient>
const page = useResourcePage<TClient>({ routeKey, getClient, queryOptions, paramKey, extraParams })
const columns = typeof columnsProp === "function"
? columnsProp({
actionsColumn: page.actionsColumn,
openEdit: page.openEdit,
deleteItem: page.deleteItem,
})
: columnsProp
type ListResponse = { data?: TItem[] }
const responseData = page.data as ListResponse | undefined
const items = (responseData?.data ?? []) as TItem[]
const context: CrudResourceContext<TClient> = {
selectedItem: page.selectedItem,
isDialogOpen: page.isDialogOpen,
dialogResourceId: page.dialogResourceId,
isLoading: page.isLoading,
data: items,
openCreate: page.openCreate,
openEdit: page.openEdit,
closeDialog: page.closeDialog,
deleteItem: page.deleteItem,
invalidateQuery: () => page.invalidateQuery(),
}
const table = (
<>
{tableHeader && (typeof tableHeader === "function" ? tableHeader(context) : tableHeader)}
<DataTable
columns={columns}
data={items}
pagination={page.pagination}
sorting={page.sorting}
onChange={page.handleChange}
isLoading={page.isLoading}
onRowClick={onRowClick}
/>
</>
)
if (render) return render(table, context)
return table
}

View File

@ -1,5 +1,6 @@
export { useResourcePage } from "./use-resource-page"
export { ResourcePage } from "./resource-page"
export { CrudResource } from "./crud-resource"
export type {
ResourcePageClient,
@ -14,3 +15,9 @@ export type {
ResourcePageContext,
ResourcePageProps,
} from "./resource-page"
export type {
CrudResourceColumnHelpers,
CrudResourceContext,
CrudResourceProps,
} from "./crud-resource"

View File

@ -4,9 +4,12 @@ import React from "react"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
import type { DashboardHeaderProps } from "@/base/components/layout/dashboard"
import { Card, CardContent } from "@/shared/components/ui/card"
import { DataTable, type ActionsColumnOptions } from "@/shared/data-view/table-view"
import { useResourcePage, type UseResourcePageOptions, type ResourceItem, type ResourcePageClient } from "./use-resource-page"
import type { ColumnDef } from "@tanstack/react-table"
import type { ResourcePageClient, ResourceItem } from "./use-resource-page"
import { CrudResource, type CrudResourceContext, type CrudResourceColumnHelpers, type CrudResourceProps } from "./crud-resource"
// Re-exported for backward compatibility
export type ResourcePageColumnHelpers<TClient extends ResourcePageClient> = CrudResourceColumnHelpers<TClient>
export type ResourcePageContext<TClient extends ResourcePageClient> = CrudResourceContext<TClient>
export type ResourceFormProps<TClient extends ResourcePageClient> = {
resourceId: string | null
@ -19,114 +22,47 @@ export type ResourcePageHeaderHelpers<TClient extends ResourcePageClient> = {
invalidateQuery: () => void
}
export type ResourcePageColumnHelpers<TClient extends ResourcePageClient> = {
actionsColumn: (options?: Partial<ActionsColumnOptions<ResourceItem<TClient>>>) => ColumnDef<ResourceItem<TClient>, unknown>
openEdit: (row: ResourceItem<TClient>) => void
deleteItem: (id: string) => Promise<unknown>
}
export type ResourcePageContext<TClient extends ResourcePageClient> = {
selectedItem: ResourceItem<TClient> | null
isDialogOpen: boolean
dialogResourceId: string | null
isLoading: boolean
data: ResourceItem<TClient>[]
openCreate: () => void
openEdit: (row: ResourceItem<TClient>) => void
closeDialog: () => void
deleteItem: (id: string) => Promise<unknown>
invalidateQuery: () => void
}
type ReactNodeOrRender<TClient extends ResourcePageClient> =
| React.ReactNode
| ((context: ResourcePageContext<TClient>) => React.ReactNode)
export type ResourcePageProps<TClient extends ResourcePageClient> = UseResourcePageOptions<TClient> & {
columns: ColumnDef<ResourceItem<TClient>>[] | ((helpers: ResourcePageColumnHelpers<TClient>) => ColumnDef<ResourceItem<TClient>>[])
export type ResourcePageProps<TClient extends ResourcePageClient> = Omit<CrudResourceProps<TClient>, "render"> & {
headerProps?: DashboardHeaderProps | ((helpers: ResourcePageHeaderHelpers<TClient>) => DashboardHeaderProps)
header?: ReactNodeOrRender<TClient> | null
pageTitle?: string
paramKey?: string
onRowClick?: (row: ResourceItem<TClient>) => void
toolbar?: ReactNodeOrRender<TClient>
tableHeader?: ReactNodeOrRender<TClient>
}
export function ResourcePage<TClient extends ResourcePageClient>({
columns: columnsProp,
headerProps: headerPropsProp,
header,
pageTitle,
routeKey,
getClient,
queryOptions,
paramKey,
onRowClick,
toolbar,
tableHeader,
extraParams,
...crudResourceProps
}: ResourcePageProps<TClient>) {
type TItem = ResourceItem<TClient>
const page = useResourcePage<TClient>({ routeKey, getClient, queryOptions, paramKey, extraParams })
const columns = typeof columnsProp === "function"
? columnsProp({
actionsColumn: page.actionsColumn,
openEdit: page.openEdit,
deleteItem: page.deleteItem,
})
: columnsProp
type ListResponse = { data?: TItem[] }
const responseData = page.data as ListResponse | undefined
const items = (responseData?.data ?? []) as TItem[]
const context: ResourcePageContext<TClient> = {
selectedItem: page.selectedItem,
isDialogOpen: page.isDialogOpen,
dialogResourceId: page.dialogResourceId,
isLoading: page.isLoading,
data: items,
openCreate: page.openCreate,
openEdit: page.openEdit,
closeDialog: page.closeDialog,
deleteItem: page.deleteItem,
invalidateQuery: () => page.invalidateQuery(),
}
return (
<CrudResource<TClient>
{...crudResourceProps}
render={(table, context) => {
const resolvedHeaderProps = typeof headerPropsProp === "function"
? headerPropsProp({
selectedItem: page.selectedItem,
invalidateQuery: () => page.invalidateQuery(),
selectedItem: context.selectedItem,
invalidateQuery: context.invalidateQuery,
})
: headerPropsProp
const resolvedHeader = typeof header === "function" ? header(context) : header
const resolvedToolbar = typeof toolbar === "function" ? toolbar(context) : toolbar
return (
<DashboardPage
header={resolvedHeader}
headerProps={resolvedHeaderProps}
title={pageTitle}
toolbar={resolvedToolbar}
fullscreen
>
<Card>
<Card className="rounded-none">
<CardContent className="space-y-4">
{tableHeader && (typeof tableHeader === "function" ? tableHeader(context) : tableHeader)}
<DataTable
columns={columns}
data={items}
pagination={page.pagination}
sorting={page.sorting}
onChange={page.handleChange}
isLoading={page.isLoading}
onRowClick={onRowClick}
/>
{table}
</CardContent>
</Card>
</DashboardPage>
)
}}
/>
)
}

View File

@ -0,0 +1,82 @@
/**
* Format a date string or Date to a long readable date: "Jan 6, 2026"
*/
export function formatDate(value?: string | Date | null): string {
if (!value) return "—"
const date = value instanceof Date ? value : new Date(value)
if (isNaN(date.getTime())) return "—"
return date.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })
}
/**
* Format a date string or Date to date + time: "Jan 6, 2026, 2:30 PM"
*/
export function formatDateTime(value?: string | Date | null): string {
if (!value) return "—"
const date = value instanceof Date ? value : new Date(value)
if (isNaN(date.getTime())) return "—"
return date.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})
}
/**
* Format a date string or Date to a short numeric date: "04/06/2026"
*/
export function formatDateShort(value?: string | Date | null): string {
if (!value) return "—"
const date = value instanceof Date ? value : new Date(value)
if (isNaN(date.getTime())) return "—"
return date.toLocaleDateString(undefined, { year: "numeric", month: "2-digit", day: "2-digit" })
}
/**
* Format a time string or Date to a readable time: "2:30 PM"
*/
export function formatTime(value?: string | Date | null): string {
if (!value) return "—"
const date = value instanceof Date ? value : new Date(value)
if (isNaN(date.getTime())) return "—"
return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })
}
/**
* Format a snake_case or underscore-separated string to Title Case words.
* e.g. "in_progress" "In Progress"
*/
export function formatEnum(value?: string | null): string {
if (!value) return "—"
return value
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ")
}
/**
* Format a number with locale-aware thousand separators.
* e.g. 150000 "150,000"
*/
export function formatNumber(value?: number | string | null): string {
if (value == null || value === "") return "—"
const num = typeof value === "string" ? Number(value) : value
if (isNaN(num)) return "—"
return num.toLocaleString()
}
/**
* Format a numeric value as currency: "$1,500.00"
*/
export function formatCurrency(
value?: number | string | null,
currency = "USD",
locale?: string,
): string {
if (value == null || value === "") return "—"
const num = typeof value === "string" ? Number(value) : value
if (isNaN(num)) return "—"
return new Intl.NumberFormat(locale, { style: "currency", currency }).format(num)
}

View File

@ -1,5 +1,5 @@
export const getFullName = <T extends { first_name?: string | undefined; last_name?: string | undefined }>(user: T) => {
const firstName = user.first_name ?? ""
const lastName = user.last_name ?? ""
export const getFullName = <T extends { first_name?: string | undefined; last_name?: string | undefined }>(user?: T) => {
const firstName = user?.first_name ?? ""
const lastName = user?.last_name ?? ""
return `${firstName} ${lastName}`.trim()
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -16,14 +16,20 @@ import { InspectionsClient } from "./clients/inspections"
import { LabelsClient } from "./clients/labels"
import { InsuranceTypesClient } from "./clients/insurance-types"
import { EstimatesClient } from "./clients/estimates"
import { QuickRemarksClient } from "./clients/quick-remarks"
import { QuickNotesClient } from "./clients/quick-notes"
import { ShopRecommendationsClient } from "./clients/shop-recommendations"
import { JobCardsClient } from "./clients/job-cards"
import { PaymentsClient } from "./clients/payments"
import { PaymentModesClient } from "./clients/payment-modes"
import { PaymentReceivedClient } from "./clients/payment-received"
import { PartsClient } from "./clients/parts"
import { PurchaseOrdersClient } from "./clients/purchase-orders"
import { ServicesClient } from "./clients/services"
import { ServiceGroupsClient } from "./clients/service-groups"
import { ExpensesClient } from "./clients/expenses"
import { TasksClient } from "./clients/tasks"
import { TaskTypesClient } from "./clients/task-types"
import { TaskSectionsClient } from "./clients/task-sections"
import { AppointmentsClient } from "./clients/appointments"
import { ShopTimingsClient } from "./clients/shop-timings"
import { ShopCalendarsClient } from "./clients/shop-calendars"
@ -47,6 +53,7 @@ import { ServiceGroupServicesClient } from "./clients/service-group-services"
import { ServiceGroupPartsClient } from "./clients/service-group-parts"
import { SettingsClient } from "./clients/settings"
import { ConfigurationsClient } from "./clients/configurations"
import { AutoGenerateClient } from "./clients/auto-generate"
export function createApi(options?: ApiClientOptions) {
return {
@ -67,14 +74,20 @@ export function createApi(options?: ApiClientOptions) {
labels: new LabelsClient(undefined, options),
insuranceTypes: new InsuranceTypesClient(undefined, options),
estimates: new EstimatesClient(undefined, options),
quickRemarks: new QuickRemarksClient(undefined, options),
quickNotes: new QuickNotesClient(undefined, options),
shopRecommendations: new ShopRecommendationsClient(undefined, options),
jobCards: new JobCardsClient(undefined, options),
payments: new PaymentsClient(undefined, options),
paymentModes: new PaymentModesClient(undefined, options),
paymentReceived: new PaymentReceivedClient(undefined, options),
parts: new PartsClient(undefined, options),
purchaseOrders: new PurchaseOrdersClient(undefined, options),
services: new ServicesClient(undefined, options),
serviceGroups: new ServiceGroupsClient(undefined, options),
expenses: new ExpensesClient(undefined, options),
tasks: new TasksClient(undefined, options),
taskTypes: new TaskTypesClient(undefined, options),
taskSections: new TaskSectionsClient(undefined, options),
appointments: new AppointmentsClient(undefined, options),
shopTimings: new ShopTimingsClient(undefined, options),
shopCalendars: new ShopCalendarsClient(undefined, options),
@ -98,6 +111,7 @@ export function createApi(options?: ApiClientOptions) {
serviceGroupParts: new ServiceGroupPartsClient(undefined, options),
settings: new SettingsClient(undefined, options),
configurations: new ConfigurationsClient(undefined, options),
autoGenerate: new AutoGenerateClient(undefined, options),
}
}

View File

@ -1,6 +1,6 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
export const APPOINTMENT_ROUTES = {
INDEX: "/api/appointments",
@ -9,25 +9,12 @@ export const APPOINTMENT_ROUTES = {
CHANGE_STATUS: "/api/appointments/{id}/change-status",
} as const satisfies Record<string, ApiPath>
export class AppointmentsClient extends ApiClient {
export class AppointmentsClient extends CrudClient<
typeof APPOINTMENT_ROUTES.INDEX,
typeof APPOINTMENT_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
async list(query?: ApiListQueryParams) {
return this.get(APPOINTMENT_ROUTES.INDEX, query ? { query } as never : undefined)
}
async create(payload: ApiRequestBody<typeof APPOINTMENT_ROUTES.INDEX, "post">) {
return this.post(APPOINTMENT_ROUTES.INDEX, payload)
}
async update(id: string, payload: ApiRequestBody<typeof APPOINTMENT_ROUTES.BY_ID, "put">) {
return this.put(APPOINTMENT_ROUTES.BY_ID, payload, { params: { id } })
}
async destroy(id: string) {
return this.delete(APPOINTMENT_ROUTES.BY_ID, { params: { id } })
super(baseUrl, defaultOptions, APPOINTMENT_ROUTES.INDEX, APPOINTMENT_ROUTES.BY_ID)
}
async unlinkJobCard(id: string, payload: ApiRequestBody<typeof APPOINTMENT_ROUTES.UNLINK_JOB_CARD, "post">) {

View File

@ -0,0 +1,14 @@
import type { ApiPath } from "../infra/types"
import { ApiClient } from "../infra/client"
export const AUTO_GENERATE_ROUTES = {
BY_TABLE: "/api/auto-generate/{table}",
} as const satisfies Record<string, ApiPath>
export class AutoGenerateClient extends ApiClient {
async generate(table: string) {
return this.get(AUTO_GENERATE_ROUTES.BY_TABLE, {
params: { table },
} as never)
}
}

View File

@ -1,6 +1,6 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
export const DEPARTMENT_ROUTES = {
INDEX: "/api/departments",
@ -9,25 +9,12 @@ export const DEPARTMENT_ROUTES = {
REMOVE_FAVORITE: "/api/remove-favorite-department",
} as const satisfies Record<string, ApiPath>
export class DepartmentsClient extends ApiClient {
export class DepartmentsClient extends CrudClient<
typeof DEPARTMENT_ROUTES.INDEX,
typeof DEPARTMENT_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
async list(query?: ApiListQueryParams) {
return this.get(DEPARTMENT_ROUTES.INDEX, query ? { query } as never : undefined)
}
async create(payload: ApiRequestBody<typeof DEPARTMENT_ROUTES.INDEX, "post">) {
return this.post(DEPARTMENT_ROUTES.INDEX, payload)
}
async update(id: string, payload: ApiRequestBody<typeof DEPARTMENT_ROUTES.BY_ID, "put">) {
return this.put(DEPARTMENT_ROUTES.BY_ID, payload, { params: { id } })
}
async destroy(id: string) {
return this.delete(DEPARTMENT_ROUTES.BY_ID, { params: { id } })
super(baseUrl, defaultOptions, DEPARTMENT_ROUTES.INDEX, DEPARTMENT_ROUTES.BY_ID)
}
async setFavorite(payload: ApiRequestBody<typeof DEPARTMENT_ROUTES.SET_FAVORITE, "post">) {

View File

@ -1,24 +1,18 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiPath } from "../infra/types"
export const ESTIMATE_ROUTES = {
INDEX: "/api/estimates",
BY_ID: "/api/estimates/{id}",
QUICK_REMARKS: "/api/quick-remark",
QUICK_REMARK_BY_ID: "/api/quick-remark/{id}",
QUICK_NOTES: "/api/quick-notes",
QUICK_NOTE_BY_ID: "/api/quick-notes/{id}",
} as const satisfies Record<string, ApiPath>
export class EstimatesClient extends ApiClient {
export class EstimatesClient extends CrudClient<
typeof ESTIMATE_ROUTES.INDEX,
typeof ESTIMATE_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
// ── Estimates ──
async list(query?: ApiListQueryParams) {
return this.get(ESTIMATE_ROUTES.INDEX, query ? { query } as never : undefined)
super(baseUrl, defaultOptions, ESTIMATE_ROUTES.INDEX, ESTIMATE_ROUTES.BY_ID)
}
// Note: GET /api/estimates/{id} is not in the OpenAPI schema.
@ -27,50 +21,4 @@ export class EstimatesClient extends ApiClient {
const data = await this.get(ESTIMATE_ROUTES.INDEX, { query: { id } } as never)
return {...data, data: (data as any)?.data?.[0] ?? null }
}
async create(payload: ApiRequestBody<typeof ESTIMATE_ROUTES.INDEX, "post">) {
return this.post(ESTIMATE_ROUTES.INDEX, payload)
}
async update(id: string, payload: ApiRequestBody<typeof ESTIMATE_ROUTES.BY_ID, "put">) {
return this.put(ESTIMATE_ROUTES.BY_ID, payload, { params: { id } })
}
async destroy(id: string) {
return this.delete(ESTIMATE_ROUTES.BY_ID, { params: { id } })
}
// ── Quick Remarks ──
async listQuickRemarks(query?: ApiListQueryParams) {
return this.get(ESTIMATE_ROUTES.QUICK_REMARKS, query ? { query } as never : undefined)
}
async createQuickRemark(payload: ApiRequestBody<typeof ESTIMATE_ROUTES.QUICK_REMARKS, "post">) {
return this.post(ESTIMATE_ROUTES.QUICK_REMARKS, payload)
}
async updateQuickRemark(id: string, payload: ApiRequestBody<typeof ESTIMATE_ROUTES.QUICK_REMARK_BY_ID, "put">) {
return this.put(ESTIMATE_ROUTES.QUICK_REMARK_BY_ID, payload, { params: { id } })
}
async destroyQuickRemark(id: string) {
return this.delete(ESTIMATE_ROUTES.QUICK_REMARK_BY_ID, { params: { id } })
}
// ── Quick Notes ──
async listQuickNotes(query?: ApiListQueryParams) {
return this.get(ESTIMATE_ROUTES.QUICK_NOTES, query ? { query } as never : undefined)
}
async createQuickNote(payload: ApiRequestBody<typeof ESTIMATE_ROUTES.QUICK_NOTES, "post">) {
return this.post(ESTIMATE_ROUTES.QUICK_NOTES, payload)
}
async updateQuickNote(id: string, payload: ApiRequestBody<typeof ESTIMATE_ROUTES.QUICK_NOTE_BY_ID, "put">) {
return this.put(ESTIMATE_ROUTES.QUICK_NOTE_BY_ID, payload, { params: { id } })
}
async destroyQuickNote(id: string) {
return this.delete(ESTIMATE_ROUTES.QUICK_NOTE_BY_ID, { params: { id } })
}
}

View File

@ -1,4 +1,5 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
@ -14,9 +15,12 @@ export const EXPENSE_ROUTES = {
EXPENSE_BY_ID: "/api/expenses/{id}",
} as const satisfies Record<string, ApiPath>
export class ExpensesClient extends ApiClient {
export class ExpensesClient extends CrudClient<
typeof EXPENSE_ROUTES.EXPENSES,
typeof EXPENSE_ROUTES.EXPENSE_BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
super(baseUrl, defaultOptions, EXPENSE_ROUTES.EXPENSES, EXPENSE_ROUTES.EXPENSE_BY_ID)
}
// ── Expense Items ──
@ -59,35 +63,18 @@ export class ExpensesClient extends ApiClient {
// ── Expenses ──
async listExpenses(query?: ApiListQueryParams) {
return this.get(EXPENSE_ROUTES.EXPENSES, query ? { query } as never : undefined)
return this.list(query)
}
async createExpense(payload: ApiRequestBody<typeof EXPENSE_ROUTES.EXPENSES, "post">) {
return this.post(EXPENSE_ROUTES.EXPENSES, payload)
return this.create(payload)
}
async updateExpense(id: string, payload: ApiRequestBody<typeof EXPENSE_ROUTES.EXPENSE_BY_ID, "put">) {
return this.put(EXPENSE_ROUTES.EXPENSE_BY_ID, payload, { params: { id } })
return this.update(id, payload)
}
async destroyExpense(id: string) {
return this.delete(EXPENSE_ROUTES.EXPENSE_BY_ID, { params: { id } })
}
// ── Standard CRUD aliases (for ResourcePage compatibility) ──
async list(query?: ApiListQueryParams) {
return this.listExpenses(query)
}
async create(payload: ApiRequestBody<typeof EXPENSE_ROUTES.EXPENSES, "post">) {
return this.createExpense(payload)
}
async update(id: string, payload: ApiRequestBody<typeof EXPENSE_ROUTES.EXPENSE_BY_ID, "put">) {
return this.updateExpense(id, payload)
}
async destroy(id: string) {
return this.destroyExpense(id)
return this.destroy(id)
}
}

View File

@ -15,14 +15,20 @@ export { InspectionsClient, INSPECTION_ROUTES } from "./inspections"
export { LabelsClient, LABEL_ROUTES } from "./labels"
export { InsuranceTypesClient, INSURANCE_TYPE_ROUTES } from "./insurance-types"
export { EstimatesClient, ESTIMATE_ROUTES } from "./estimates"
export { QuickRemarksClient, QUICK_REMARK_ROUTES } from "./quick-remarks"
export { QuickNotesClient, QUICK_NOTE_ROUTES } from "./quick-notes"
export { ShopRecommendationsClient, SHOP_RECOMMENDATION_ROUTES } from "./shop-recommendations"
export { JobCardsClient, JOB_CARD_ROUTES } from "./job-cards"
export { PaymentsClient, PAYMENT_ROUTES } from "./payments"
export { PaymentModesClient, PAYMENT_MODE_ROUTES } from "./payment-modes"
export { PaymentReceivedClient, PAYMENT_RECEIVED_ROUTES } from "./payment-received"
export { PartsClient, PARTS_ROUTES } from "./parts"
export { PurchaseOrdersClient, PURCHASE_ORDER_ROUTES } from "./purchase-orders"
export { ServicesClient, SERVICE_ROUTES } from "./services"
export { ServiceGroupsClient, SERVICE_GROUP_ROUTES } from "./service-groups"
export { ExpensesClient, EXPENSE_ROUTES } from "./expenses"
export { TasksClient, TASK_ROUTES } from "./tasks"
export { TaskTypesClient, TASK_TYPE_ROUTES } from "./task-types"
export { TaskSectionsClient, TASK_SECTION_ROUTES } from "./task-sections"
export { AppointmentsClient, APPOINTMENT_ROUTES } from "./appointments"
export { ShopTimingsClient, SHOP_TIMING_ROUTES } from "./shop-timings"
export { ShopCalendarsClient, SHOP_CALENDAR_ROUTES } from "./shop-calendars"
@ -46,3 +52,4 @@ export { ServiceGroupServicesClient, SERVICE_GROUP_SERVICE_ROUTES } from "./serv
export { ServiceGroupPartsClient, SERVICE_GROUP_PART_ROUTES } from "./service-group-parts"
export { SettingsClient, SETTINGS_ROUTES } from "./settings"
export { ConfigurationsClient, CONFIGURATION_ROUTES } from "./configurations"
export { AutoGenerateClient, AUTO_GENERATE_ROUTES } from "./auto-generate"

View File

@ -1,4 +1,5 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
@ -19,9 +20,12 @@ export const INSPECTION_ROUTES = {
CHECKPOINT_MEDIA: "/api/inspection-check-points/{id}/media",
} as const satisfies Record<string, ApiPath>
export class InspectionsClient extends ApiClient {
export class InspectionsClient extends CrudClient<
typeof INSPECTION_ROUTES.INDEX,
typeof INSPECTION_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
super(baseUrl, defaultOptions, INSPECTION_ROUTES.INDEX, INSPECTION_ROUTES.BY_ID)
}
// ── Categories ──
@ -41,28 +45,11 @@ export class InspectionsClient extends ApiClient {
return this.delete(INSPECTION_ROUTES.CATEGORY_BY_ID, { params: { id } })
}
// ── Inspections ──
async list(query?: ApiListQueryParams) {
return this.get(INSPECTION_ROUTES.INDEX, query ? { query } as never : undefined)
}
async getById(id: string) {
const res = await this.list({ query: { id } } as never)
const res = await super.list({ query: { id } } as never)
return {...res, data: res.data[0] }
}
async create(payload: ApiRequestBody<typeof INSPECTION_ROUTES.INDEX, "post">) {
return this.post(INSPECTION_ROUTES.INDEX, payload)
}
async update(id: string, payload: ApiRequestBody<typeof INSPECTION_ROUTES.BY_ID, "put">) {
return this.put(INSPECTION_ROUTES.BY_ID, payload, { params: { id } })
}
async destroy(id: string) {
return this.delete(INSPECTION_ROUTES.BY_ID, { params: { id } })
}
async changeStatus(payload: ApiRequestBody<typeof INSPECTION_ROUTES.CHANGE_STATUS, "post">) {
return this.post(INSPECTION_ROUTES.CHANGE_STATUS, payload)
}

View File

@ -1,30 +1,17 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiPath } from "../infra/types"
export const INSURANCE_TYPE_ROUTES = {
INDEX: "/api/insurance-types",
BY_ID: "/api/insurance-types/{id}",
} as const satisfies Record<string, ApiPath>
export class InsuranceTypesClient extends ApiClient {
export class InsuranceTypesClient extends CrudClient<
typeof INSURANCE_TYPE_ROUTES.INDEX,
typeof INSURANCE_TYPE_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
async list(query?: ApiListQueryParams) {
return this.get(INSURANCE_TYPE_ROUTES.INDEX, query ? { query } as never : undefined)
}
async create(payload: ApiRequestBody<typeof INSURANCE_TYPE_ROUTES.INDEX, "post">) {
return this.post(INSURANCE_TYPE_ROUTES.INDEX, payload)
}
async update(id: string, payload: ApiRequestBody<typeof INSURANCE_TYPE_ROUTES.BY_ID, "put">) {
return this.put(INSURANCE_TYPE_ROUTES.BY_ID, payload, { params: { id } })
}
async destroy(id: string) {
return this.delete(INSURANCE_TYPE_ROUTES.BY_ID, { params: { id } })
super(baseUrl, defaultOptions, INSURANCE_TYPE_ROUTES.INDEX, INSURANCE_TYPE_ROUTES.BY_ID)
}
}

View File

@ -1,5 +1,5 @@
import { CrudClient } from "../infra/crud-client"
import { ApiClient, type ApiClientOptions } from "../infra/client"
import { type ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
@ -16,30 +16,12 @@ export const INVOICE_ROUTES = {
LABEL_BY_ID: "/api/invoice-labels/{id}",
} as const satisfies Record<string, ApiPath>
export class InvoicesClient extends ApiClient {
export class InvoicesClient extends CrudClient<
typeof INVOICE_ROUTES.INDEX,
typeof INVOICE_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
// ── Invoices ──
async list(query?: ApiListQueryParams) {
return this.get(INVOICE_ROUTES.INDEX, query ? { query } as never : undefined)
}
async show(id: string) {
return this.get(INVOICE_ROUTES.BY_ID, { params: { id } } as never)
}
async create(payload: ApiRequestBody<typeof INVOICE_ROUTES.INDEX, "post">) {
return this.post(INVOICE_ROUTES.INDEX, payload)
}
async update(id: string, payload: ApiRequestBody<typeof INVOICE_ROUTES.BY_ID, "put">) {
return this.put(INVOICE_ROUTES.BY_ID, payload, { params: { id } })
}
async destroy(id: string) {
return this.delete(INVOICE_ROUTES.BY_ID, { params: { id } })
super(baseUrl, defaultOptions, INVOICE_ROUTES.INDEX, INVOICE_ROUTES.BY_ID)
}
// ── Attachments ──

View File

@ -1,6 +1,7 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiOperationResponse, ApiPath, ApiRequestBody, ApiResponse } from "../infra/types"
import { ApiBaseResponse } from "src/contracts/types"
export const JOB_CARD_ROUTES = {
INDEX: "/api/job-cards",
@ -17,32 +18,31 @@ export const JOB_CARD_ROUTES = {
DELETE_ATTACHMENT: "/api/job-cards/{id}/delete-attachment",
CHANGE_SERVICE_WRITER: "/api/job-cards/{id}/change-service-writer-id",
CHANGE_SALES_PERSON: "/api/job-cards/{id}/change-sales-person-id",
GET_PARTS: "/api/job-cards/{id}/get-parts",
ADD_PART: "/api/job-cards/{id}/add-part",
UPDATE_PART: "/api/job-cards/{id}/update-part",
DELETE_PART: "/api/job-cards/{id}/delete-part",
} as const satisfies Record<string, ApiPath>
export class JobCardsClient extends ApiClient {
export type JobCardShowData = ApiResponse<typeof JOB_CARD_ROUTES.BY_ID, "get">['data'] & {
purchase_orders_count: number
bills_count: number
expenses_count: number
tasks_count: number
appointments_count: number
parts_count: number
}
export class JobCardsClient extends CrudClient<
typeof JOB_CARD_ROUTES.INDEX,
typeof JOB_CARD_ROUTES.BY_ID,
{
showResponse: ApiBaseResponse<JobCardShowData>
}
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
async list(query?: ApiListQueryParams) {
return this.get(JOB_CARD_ROUTES.INDEX, query ? { query } as never : undefined)
}
async show(id: string) {
const res = await this.get(JOB_CARD_ROUTES.INDEX, { params: { id }, query: { id } } as never)
return { ...res, data: res.data?.[0] ?? null, }
}
async create(payload: ApiRequestBody<typeof JOB_CARD_ROUTES.INDEX, "post">) {
return this.post(JOB_CARD_ROUTES.INDEX, payload)
}
async update(id: string, payload: ApiRequestBody<typeof JOB_CARD_ROUTES.BY_ID, "put">) {
return this.put(JOB_CARD_ROUTES.BY_ID, payload, { params: { id } })
}
async destroy(id: string) {
return this.delete(JOB_CARD_ROUTES.BY_ID, { params: { id } })
super(baseUrl, defaultOptions, JOB_CARD_ROUTES.INDEX, JOB_CARD_ROUTES.BY_ID)
}
async changeDate(id: string, payload: ApiRequestBody<typeof JOB_CARD_ROUTES.CHANGE_DATE, "post">) {
@ -61,8 +61,8 @@ export class JobCardsClient extends ApiClient {
return this.post(JOB_CARD_ROUTES.EDIT_CUSTOMER_REMARK, payload, { params: { id } })
}
async deleteCustomerRemark(id: string) {
return this.delete(JOB_CARD_ROUTES.DELETE_CUSTOMER_REMARK, { params: { id } })
async deleteCustomerRemark(id: string, customerRemarkId: number) {
return this.delete(JOB_CARD_ROUTES.DELETE_CUSTOMER_REMARK, { params: { id }, body: { customer_remark_id: customerRemarkId } } as never)
}
async addShopRecommendation(id: string, payload: ApiRequestBody<typeof JOB_CARD_ROUTES.ADD_SHOP_RECOMMENDATION, "post">) {
@ -73,8 +73,8 @@ export class JobCardsClient extends ApiClient {
return this.post(JOB_CARD_ROUTES.EDIT_SHOP_RECOMMENDATION, payload, { params: { id } })
}
async deleteShopRecommendation(id: string) {
return this.delete(JOB_CARD_ROUTES.DELETE_SHOP_RECOMMENDATION, { params: { id } })
async deleteShopRecommendation(id: string, shopRecommendationId: number) {
return this.delete(JOB_CARD_ROUTES.DELETE_SHOP_RECOMMENDATION, { params: { id }, body: { shop_recommendation_id: shopRecommendationId } } as never)
}
async addAttachment(id: string, files: File[]) {
@ -96,4 +96,20 @@ export class JobCardsClient extends ApiClient {
async changeSalesPerson(id: string, payload: ApiRequestBody<typeof JOB_CARD_ROUTES.CHANGE_SALES_PERSON, "post">) {
return this.post(JOB_CARD_ROUTES.CHANGE_SALES_PERSON, payload, { params: { id } })
}
async getParts(id: string, params?: Record<string, unknown>) {
return this.get(JOB_CARD_ROUTES.GET_PARTS, { params: { id }, query: params as any })
}
async addPart(id: string, payload: ApiRequestBody<typeof JOB_CARD_ROUTES.ADD_PART, "post">) {
return this.post(JOB_CARD_ROUTES.ADD_PART, payload, { params: { id } })
}
async updatePart(id: string, payload: ApiRequestBody<typeof JOB_CARD_ROUTES.UPDATE_PART, "put">) {
return this.put(JOB_CARD_ROUTES.UPDATE_PART, payload, { params: { id } })
}
async deletePart(id: string, jobCardPartId: number) {
return this.delete(JOB_CARD_ROUTES.DELETE_PART, { params: { id }, body: { job_card_part_id: jobCardPartId } } as never)
}
}

View File

@ -1,30 +1,17 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiPath } from "../infra/types"
export const LABEL_ROUTES = {
INDEX: "/api/labels",
BY_ID: "/api/labels/{id}",
} as const satisfies Record<string, ApiPath>
export class LabelsClient extends ApiClient {
export class LabelsClient extends CrudClient<
typeof LABEL_ROUTES.INDEX,
typeof LABEL_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
async list(query?: ApiListQueryParams) {
return this.get(LABEL_ROUTES.INDEX, query ? { query } as never : undefined)
}
async create(payload: ApiRequestBody<typeof LABEL_ROUTES.INDEX, "post">) {
return this.post(LABEL_ROUTES.INDEX, payload)
}
async update(id: string, payload: ApiRequestBody<typeof LABEL_ROUTES.BY_ID, "put">) {
return this.put(LABEL_ROUTES.BY_ID, payload, { params: { id } })
}
async destroy(id: string) {
return this.delete(LABEL_ROUTES.BY_ID, { params: { id } })
super(baseUrl, defaultOptions, LABEL_ROUTES.INDEX, LABEL_ROUTES.BY_ID)
}
}

View File

@ -1,6 +1,6 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
export const PARTS_ROUTES = {
INDEX: "/api/parts",
@ -10,25 +10,12 @@ export const PARTS_ROUTES = {
TOGGLE_STATUS: "/api/toggle-part-status",
} as const satisfies Record<string, ApiPath>
export class PartsClient extends ApiClient {
export class PartsClient extends CrudClient<
typeof PARTS_ROUTES.INDEX,
typeof PARTS_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
async list(query?: ApiListQueryParams) {
return this.get(PARTS_ROUTES.INDEX, query ? { query } as never : undefined)
}
async create(payload: ApiRequestBody<typeof PARTS_ROUTES.INDEX, "post">) {
return this.post(PARTS_ROUTES.INDEX, payload)
}
async update(id: string, payload: ApiRequestBody<typeof PARTS_ROUTES.BY_ID, "put">) {
return this.put(PARTS_ROUTES.BY_ID, payload, { params: { id } })
}
async destroy(id: string) {
return this.delete(PARTS_ROUTES.BY_ID, { params: { id } })
super(baseUrl, defaultOptions, PARTS_ROUTES.INDEX, PARTS_ROUTES.BY_ID)
}
async import(payload: ApiRequestBody<typeof PARTS_ROUTES.IMPORT, "post">) {

View File

@ -0,0 +1,17 @@
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiPath } from "../infra/types"
export const PAYMENT_MODE_ROUTES = {
INDEX: "/api/payment-mode",
BY_ID: "/api/payment-mode/{id}",
} as const satisfies Record<string, ApiPath>
export class PaymentModesClient extends CrudClient<
typeof PAYMENT_MODE_ROUTES.INDEX,
typeof PAYMENT_MODE_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions, PAYMENT_MODE_ROUTES.INDEX, PAYMENT_MODE_ROUTES.BY_ID)
}
}

View File

@ -0,0 +1,17 @@
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiPath } from "../infra/types"
export const PAYMENT_RECEIVED_ROUTES = {
INDEX: "/api/payment-recieved",
BY_ID: "/api/payment-recieved/{id}",
} as const satisfies Record<string, ApiPath>
export class PaymentReceivedClient extends CrudClient<
typeof PAYMENT_RECEIVED_ROUTES.INDEX,
typeof PAYMENT_RECEIVED_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions, PAYMENT_RECEIVED_ROUTES.INDEX, PAYMENT_RECEIVED_ROUTES.BY_ID)
}
}

View File

@ -1,6 +1,6 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
export const PAYMENT_TERM_ROUTES = {
INDEX: "/api/payment-terms",
@ -8,25 +8,12 @@ export const PAYMENT_TERM_ROUTES = {
SET_DEFAULT: "/api/set-default-payment-term",
} as const satisfies Record<string, ApiPath>
export class PaymentTermsClient extends ApiClient {
export class PaymentTermsClient extends CrudClient<
typeof PAYMENT_TERM_ROUTES.INDEX,
typeof PAYMENT_TERM_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
async list(query?: ApiListQueryParams) {
return this.get(PAYMENT_TERM_ROUTES.INDEX, query ? { query } as never : undefined)
}
async create(payload: ApiRequestBody<typeof PAYMENT_TERM_ROUTES.INDEX, "post">) {
return this.post(PAYMENT_TERM_ROUTES.INDEX, payload)
}
async update(id: string, payload: ApiRequestBody<typeof PAYMENT_TERM_ROUTES.BY_ID, "put">) {
return this.put(PAYMENT_TERM_ROUTES.BY_ID, payload, { params: { id } })
}
async destroy(id: string) {
return this.delete(PAYMENT_TERM_ROUTES.BY_ID, { params: { id } })
super(baseUrl, defaultOptions, PAYMENT_TERM_ROUTES.INDEX, PAYMENT_TERM_ROUTES.BY_ID)
}
async setDefault(payload: ApiRequestBody<typeof PAYMENT_TERM_ROUTES.SET_DEFAULT, "post">) {

View File

@ -1,50 +1,3 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
export const PAYMENT_ROUTES = {
MODES: "/api/payment-mode",
MODE_BY_ID: "/api/payment-mode/{id}",
RECEIVED: "/api/payment-recieved",
RECEIVED_BY_ID: "/api/payment-recieved/{id}",
} as const satisfies Record<string, ApiPath>
export class PaymentsClient extends ApiClient {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
// ── Payment Modes ──
async listModes(query?: ApiListQueryParams) {
return this.get(PAYMENT_ROUTES.MODES, query ? { query } as never : undefined)
}
async createMode(payload: ApiRequestBody<typeof PAYMENT_ROUTES.MODES, "post">) {
return this.post(PAYMENT_ROUTES.MODES, payload)
}
async updateMode(id: string, payload: ApiRequestBody<typeof PAYMENT_ROUTES.MODE_BY_ID, "put">) {
return this.put(PAYMENT_ROUTES.MODE_BY_ID, payload, { params: { id } })
}
async destroyMode(id: string) {
return this.delete(PAYMENT_ROUTES.MODE_BY_ID, { params: { id } })
}
// ── Payment Received ──
async listReceived(query?: ApiListQueryParams) {
return this.get(PAYMENT_ROUTES.RECEIVED, query ? { query } as never : undefined)
}
async createReceived(payload: ApiRequestBody<typeof PAYMENT_ROUTES.RECEIVED, "post">) {
return this.post(PAYMENT_ROUTES.RECEIVED, payload)
}
async updateReceived(id: string, payload: ApiRequestBody<typeof PAYMENT_ROUTES.RECEIVED_BY_ID, "post">) {
return this.post(PAYMENT_ROUTES.RECEIVED_BY_ID, payload, { params: { id } })
}
async destroyReceived(id: string) {
return this.delete(PAYMENT_ROUTES.RECEIVED_BY_ID, { params: { id } })
}
}
// Re-exports for backward compatibility
export { PaymentModesClient, PAYMENT_MODE_ROUTES } from "./payment-modes"
export { PaymentReceivedClient, PAYMENT_RECEIVED_ROUTES } from "./payment-received"

View File

@ -0,0 +1,17 @@
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiPath } from "../infra/types"
export const QUICK_NOTE_ROUTES = {
INDEX: "/api/quick-notes",
BY_ID: "/api/quick-notes/{id}",
} as const satisfies Record<string, ApiPath>
export class QuickNotesClient extends CrudClient<
typeof QUICK_NOTE_ROUTES.INDEX,
typeof QUICK_NOTE_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions, QUICK_NOTE_ROUTES.INDEX, QUICK_NOTE_ROUTES.BY_ID)
}
}

View File

@ -0,0 +1,17 @@
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiPath } from "../infra/types"
export const QUICK_REMARK_ROUTES = {
INDEX: "/api/quick-remark",
BY_ID: "/api/quick-remark/{id}",
} as const satisfies Record<string, ApiPath>
export class QuickRemarksClient extends CrudClient<
typeof QUICK_REMARK_ROUTES.INDEX,
typeof QUICK_REMARK_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions, QUICK_REMARK_ROUTES.INDEX, QUICK_REMARK_ROUTES.BY_ID)
}
}

View File

@ -1,6 +1,6 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
export const REFERRAL_SOURCE_ROUTES = {
INDEX: "/api/referral-sources",
@ -8,25 +8,12 @@ export const REFERRAL_SOURCE_ROUTES = {
SET_DEFAULT: "/api/set-default-referral-source",
} as const satisfies Record<string, ApiPath>
export class ReferralSourcesClient extends ApiClient {
export class ReferralSourcesClient extends CrudClient<
typeof REFERRAL_SOURCE_ROUTES.INDEX,
typeof REFERRAL_SOURCE_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
async list(query?: ApiListQueryParams) {
return this.get(REFERRAL_SOURCE_ROUTES.INDEX, query ? { query } as never : undefined)
}
async create(payload: ApiRequestBody<typeof REFERRAL_SOURCE_ROUTES.INDEX, "post">) {
return this.post(REFERRAL_SOURCE_ROUTES.INDEX, payload)
}
async update(id: string, payload: ApiRequestBody<typeof REFERRAL_SOURCE_ROUTES.BY_ID, "put">) {
return this.put(REFERRAL_SOURCE_ROUTES.BY_ID, payload, { params: { id } })
}
async destroy(id: string) {
return this.delete(REFERRAL_SOURCE_ROUTES.BY_ID, { params: { id } })
super(baseUrl, defaultOptions, REFERRAL_SOURCE_ROUTES.INDEX, REFERRAL_SOURCE_ROUTES.BY_ID)
}
async setDefault(payload: ApiRequestBody<typeof REFERRAL_SOURCE_ROUTES.SET_DEFAULT, "post">) {

View File

@ -1,6 +1,6 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
export const SERVICE_ROUTES = {
INDEX: "/api/services",
@ -9,25 +9,12 @@ export const SERVICE_ROUTES = {
EXPORT: "/api/export-services",
} as const satisfies Record<string, ApiPath>
export class ServicesClient extends ApiClient {
export class ServicesClient extends CrudClient<
typeof SERVICE_ROUTES.INDEX,
typeof SERVICE_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
async list(query?: ApiListQueryParams) {
return this.get(SERVICE_ROUTES.INDEX, query ? { query } as never : undefined)
}
async create(payload: ApiRequestBody<typeof SERVICE_ROUTES.INDEX, "post">) {
return this.post(SERVICE_ROUTES.INDEX, payload)
}
async update(id: string, payload: ApiRequestBody<typeof SERVICE_ROUTES.BY_ID, "put">) {
return this.put(SERVICE_ROUTES.BY_ID, payload, { params: { id } })
}
async destroy(id: string) {
return this.delete(SERVICE_ROUTES.BY_ID, { params: { id } })
super(baseUrl, defaultOptions, SERVICE_ROUTES.INDEX, SERVICE_ROUTES.BY_ID)
}
async import(payload: ApiRequestBody<typeof SERVICE_ROUTES.IMPORT, "post">) {

View File

@ -0,0 +1,17 @@
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiPath } from "../infra/types"
export const SHOP_RECOMMENDATION_ROUTES = {
INDEX: "/api/shop-recommendations",
BY_ID: "/api/shop-recommendations/{id}",
} as const satisfies Record<string, ApiPath>
export class ShopRecommendationsClient extends CrudClient<
typeof SHOP_RECOMMENDATION_ROUTES.INDEX,
typeof SHOP_RECOMMENDATION_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions, SHOP_RECOMMENDATION_ROUTES.INDEX, SHOP_RECOMMENDATION_ROUTES.BY_ID)
}
}

View File

@ -1,6 +1,6 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiPath } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
export const SHOP_TYPE_ROUTES = {
INDEX: "/api/shop-types",
@ -31,13 +31,12 @@ function buildShopTypeFormData(payload: ShopTypeCreatePayload | ShopTypeUpdatePa
return fd
}
export class ShopTypesClient extends ApiClient {
export class ShopTypesClient extends CrudClient<
typeof SHOP_TYPE_ROUTES.INDEX,
typeof SHOP_TYPE_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
async list(query?: ApiListQueryParams) {
return this.get(SHOP_TYPE_ROUTES.INDEX, query ? { query } as never : undefined)
super(baseUrl, defaultOptions, SHOP_TYPE_ROUTES.INDEX, SHOP_TYPE_ROUTES.BY_ID)
}
async create(payload: ShopTypeCreatePayload) {
@ -52,9 +51,6 @@ export class ShopTypesClient extends ApiClient {
return this.postFormData(url, fd)
}
async destroy(id: string) {
return this.delete(SHOP_TYPE_ROUTES.BY_ID, { params: { id } })
}
}

View File

@ -0,0 +1,32 @@
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
export const TASK_SECTION_ROUTES = {
INDEX: "/api/task-sections",
BY_ID: "/api/task-sections/{id}",
SET_DEFAULT: "/api/set-default-task-section",
REMOVE_DEFAULT: "/api/remove-default-task-section",
CHANGE_ARRANGEMENT: "/api/change-task-section-arrangement",
} as const satisfies Record<string, ApiPath>
export class TaskSectionsClient extends CrudClient<
typeof TASK_SECTION_ROUTES.INDEX,
typeof TASK_SECTION_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions, TASK_SECTION_ROUTES.INDEX, TASK_SECTION_ROUTES.BY_ID)
}
async setDefault(payload: ApiRequestBody<typeof TASK_SECTION_ROUTES.SET_DEFAULT, "post">) {
return this.post(TASK_SECTION_ROUTES.SET_DEFAULT, payload)
}
async removeDefault(payload: ApiRequestBody<typeof TASK_SECTION_ROUTES.REMOVE_DEFAULT, "post">) {
return this.post(TASK_SECTION_ROUTES.REMOVE_DEFAULT, payload)
}
async changeSectionArrangement(payload: ApiRequestBody<typeof TASK_SECTION_ROUTES.CHANGE_ARRANGEMENT, "post">) {
return this.post(TASK_SECTION_ROUTES.CHANGE_ARRANGEMENT, payload)
}
}

View File

@ -0,0 +1,27 @@
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
export const TASK_TYPE_ROUTES = {
INDEX: "/api/task-types",
BY_ID: "/api/task-types/{id}",
SET_DEFAULT: "/api/set-default-task-type",
REMOVE_DEFAULT: "/api/remove-default-task-type",
} as const satisfies Record<string, ApiPath>
export class TaskTypesClient extends CrudClient<
typeof TASK_TYPE_ROUTES.INDEX,
typeof TASK_TYPE_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions, TASK_TYPE_ROUTES.INDEX, TASK_TYPE_ROUTES.BY_ID)
}
async setDefault(payload: ApiRequestBody<typeof TASK_TYPE_ROUTES.SET_DEFAULT, "post">) {
return this.post(TASK_TYPE_ROUTES.SET_DEFAULT, payload)
}
async removeDefault(payload: ApiRequestBody<typeof TASK_TYPE_ROUTES.REMOVE_DEFAULT, "post">) {
return this.post(TASK_TYPE_ROUTES.REMOVE_DEFAULT, payload)
}
}

View File

@ -1,96 +1,19 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
export const TASK_ROUTES = {
TYPES: "/api/task-types",
TYPE_BY_ID: "/api/task-types/{id}",
SET_DEFAULT_TYPE: "/api/set-default-task-type",
REMOVE_DEFAULT_TYPE: "/api/remove-default-task-type",
SECTIONS: "/api/task-sections",
SECTION_BY_ID: "/api/task-sections/{id}",
SET_DEFAULT_SECTION: "/api/set-default-task-section",
REMOVE_DEFAULT_SECTION: "/api/remove-default-task-section",
CHANGE_SECTION_ARRANGEMENT: "/api/change-task-section-arrangement",
TASKS: "/api/tasks",
TASK_BY_ID: "/api/tasks/{id}",
INDEX: "/api/tasks",
BY_ID: "/api/tasks/{id}",
COMPLETE: "/api/tasks/{id}/complete",
} as const satisfies Record<string, ApiPath>
export class TasksClient extends ApiClient {
export class TasksClient extends CrudClient<
typeof TASK_ROUTES.INDEX,
typeof TASK_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
// ── Task Types ──
async listTypes(query?: ApiListQueryParams) {
return this.get(TASK_ROUTES.TYPES, query ? { query } as never : undefined)
}
async createType(payload: ApiRequestBody<typeof TASK_ROUTES.TYPES, "post">) {
return this.post(TASK_ROUTES.TYPES, payload)
}
async updateType(id: string, payload: ApiRequestBody<typeof TASK_ROUTES.TYPE_BY_ID, "put">) {
return this.put(TASK_ROUTES.TYPE_BY_ID, payload, { params: { id } })
}
async destroyType(id: string) {
return this.delete(TASK_ROUTES.TYPE_BY_ID, { params: { id } })
}
async setDefaultType(payload: ApiRequestBody<typeof TASK_ROUTES.SET_DEFAULT_TYPE, "post">) {
return this.post(TASK_ROUTES.SET_DEFAULT_TYPE, payload)
}
async removeDefaultType(payload: ApiRequestBody<typeof TASK_ROUTES.REMOVE_DEFAULT_TYPE, "post">) {
return this.post(TASK_ROUTES.REMOVE_DEFAULT_TYPE, payload)
}
// ── Task Sections ──
async listSections(query?: ApiListQueryParams) {
return this.get(TASK_ROUTES.SECTIONS, query ? { query } as never : undefined)
}
async createSection(payload: ApiRequestBody<typeof TASK_ROUTES.SECTIONS, "post">) {
return this.post(TASK_ROUTES.SECTIONS, payload)
}
async updateSection(id: string, payload: ApiRequestBody<typeof TASK_ROUTES.SECTION_BY_ID, "put">) {
return this.put(TASK_ROUTES.SECTION_BY_ID, payload, { params: { id } })
}
async destroySection(id: string) {
return this.delete(TASK_ROUTES.SECTION_BY_ID, { params: { id } })
}
async setDefaultSection(payload: ApiRequestBody<typeof TASK_ROUTES.SET_DEFAULT_SECTION, "post">) {
return this.post(TASK_ROUTES.SET_DEFAULT_SECTION, payload)
}
async removeDefaultSection(payload: ApiRequestBody<typeof TASK_ROUTES.REMOVE_DEFAULT_SECTION, "post">) {
return this.post(TASK_ROUTES.REMOVE_DEFAULT_SECTION, payload)
}
async changeSectionArrangement(payload: ApiRequestBody<typeof TASK_ROUTES.CHANGE_SECTION_ARRANGEMENT, "post">) {
return this.post(TASK_ROUTES.CHANGE_SECTION_ARRANGEMENT, payload)
}
// ── Tasks ──
async list(query?: ApiListQueryParams) {
return this.get(TASK_ROUTES.TASKS, query ? { query } as never : undefined)
}
async create(payload: ApiRequestBody<typeof TASK_ROUTES.TASKS, "post">) {
return this.post(TASK_ROUTES.TASKS, payload)
}
async update(id: string, payload: ApiRequestBody<typeof TASK_ROUTES.TASK_BY_ID, "put">) {
return this.put(TASK_ROUTES.TASK_BY_ID, payload, { params: { id } })
}
async destroy(id: string) {
return this.delete(TASK_ROUTES.TASK_BY_ID, { params: { id } })
super(baseUrl, defaultOptions, TASK_ROUTES.INDEX, TASK_ROUTES.BY_ID)
}
async complete(id: string, payload: ApiRequestBody<typeof TASK_ROUTES.COMPLETE, "post">) {

View File

@ -1,6 +1,6 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
export const TAX_ROUTES = {
INDEX: "/api/taxes",
@ -9,25 +9,12 @@ export const TAX_ROUTES = {
REMOVE_DEFAULT: "/api/remove-default-tax",
} as const satisfies Record<string, ApiPath>
export class TaxesClient extends ApiClient {
export class TaxesClient extends CrudClient<
typeof TAX_ROUTES.INDEX,
typeof TAX_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
async list(query?: ApiListQueryParams) {
return this.get(TAX_ROUTES.INDEX, query ? { query } as never : undefined)
}
async create(payload: ApiRequestBody<typeof TAX_ROUTES.INDEX, "post">) {
return this.post(TAX_ROUTES.INDEX, payload)
}
async update(id: string, payload: ApiRequestBody<typeof TAX_ROUTES.BY_ID, "put">) {
return this.put(TAX_ROUTES.BY_ID, payload, { params: { id } })
}
async destroy(id: string) {
return this.delete(TAX_ROUTES.BY_ID, { params: { id } })
super(baseUrl, defaultOptions, TAX_ROUTES.INDEX, TAX_ROUTES.BY_ID)
}
async setDefault(payload: ApiRequestBody<typeof TAX_ROUTES.SET_DEFAULT, "post">) {

View File

@ -1,4 +1,5 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
@ -25,13 +26,12 @@ function buildVehicleFormData(payload: Record<string, any>): FormData {
return fd
}
export class VehiclesClient extends ApiClient {
export class VehiclesClient extends CrudClient<
typeof VEHICLE_ROUTES.INDEX,
typeof VEHICLE_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
async list(query?: ApiListQueryParams) {
return this.get(VEHICLE_ROUTES.INDEX, query ? { query } as never : undefined)
super(baseUrl, defaultOptions, VEHICLE_ROUTES.INDEX, VEHICLE_ROUTES.BY_ID)
}
@ -53,10 +53,6 @@ export class VehiclesClient extends ApiClient {
return this.postFormData(url, fd)
}
async destroy(id: string) {
return this.delete(VEHICLE_ROUTES.BY_ID, { params: { id } })
}
async export() {
return this.get(VEHICLE_ROUTES.EXPORT)
}

View File

@ -1,6 +1,6 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types"
export const VENDOR_ROUTES = {
INDEX: "/api/vendors",
@ -10,25 +10,12 @@ export const VENDOR_ROUTES = {
ADDRESS_BY_ID: "/api/vendor-address/{id}",
} as const satisfies Record<string, ApiPath>
export class VendorsClient extends ApiClient {
export class VendorsClient extends CrudClient<
typeof VENDOR_ROUTES.INDEX,
typeof VENDOR_ROUTES.BY_ID
> {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
async list(query?: ApiListQueryParams) {
return this.get(VENDOR_ROUTES.INDEX, query ? { query } as never : undefined)
}
async create(payload: ApiRequestBody<typeof VENDOR_ROUTES.INDEX, "post">) {
return this.post(VENDOR_ROUTES.INDEX, payload)
}
async update(id: string, payload: ApiRequestBody<typeof VENDOR_ROUTES.BY_ID, "put">) {
return this.put(VENDOR_ROUTES.BY_ID, payload, { params: { id } })
}
async destroy(id: string) {
return this.delete(VENDOR_ROUTES.BY_ID, { params: { id } })
super(baseUrl, defaultOptions, VENDOR_ROUTES.INDEX, VENDOR_ROUTES.BY_ID)
}
async toggleStatus(payload: ApiRequestBody<typeof VENDOR_ROUTES.TOGGLE_STATUS, "post">) {

View File

@ -59,6 +59,9 @@ export type ExpenseStatus = (typeof ExpenseStatus)[number];
export const TaskStatus = ['pending', 'completed'] as const;
export type TaskStatus = (typeof TaskStatus)[number];
export const TaskPriority = ['low', 'medium', 'high'] as const;
export type TaskPriority = (typeof TaskPriority)[number];
// 📅 Appointments
export const AppointmentStatus = ['requested', 'confirmed', 'no_show', 'cancelled', 'completed'] as const;
export type AppointmentStatus = (typeof AppointmentStatus)[number];

View File

@ -10,5 +10,3 @@ export * from "./clients/index"
// ── Factory ──
export { createApi, api } from "./api"
export * from "./types"

View File

@ -7,9 +7,22 @@ export const DEFAULT_PER_PAGE = 10
type CrudIndexRoute = ApiPathByMethod<"get"> & ApiPathByMethod<"post">
type CrudByIdRoute = ApiPathByMethod<"put"> & ApiPathByMethod<"delete">
export type CrudTypeOverrides = {
listResponse?: unknown
showResponse?: unknown
createPayload?: unknown
createResponse?: unknown
updatePayload?: unknown
updateResponse?: unknown
destroyResponse?: unknown
}
type Resolve<T, K extends string, Default> = T extends Record<K, infer V> ? V : Default
export abstract class CrudClient<
IndexRoute extends CrudIndexRoute,
ByIdRoute extends CrudByIdRoute,
Overrides extends CrudTypeOverrides = {},
> extends ApiClient {
@ -22,24 +35,24 @@ export abstract class CrudClient<
}
async list(query?: ApiListQueryParams): Promise<ApiResponse<IndexRoute, "get">> {
return this.get(this.indexRoute as IndexRoute, query ? { query } as never : undefined)
async list(query?: ApiListQueryParams): Promise<Resolve<Overrides, 'listResponse', ApiResponse<IndexRoute, "get">>> {
return this.get(this.indexRoute as IndexRoute, query ? { query } as never : undefined) as never
}
async show(id: string) {
return this.get(this.byIdRoute as ByIdRoute & ApiPathByMethod<"get">, { params: { id } } as never)
async show(id: string): Promise<Resolve<Overrides, 'showResponse', ApiResponse<ByIdRoute & ApiPathByMethod<"get">, "get">>> {
return this.get(this.byIdRoute as ByIdRoute & ApiPathByMethod<"get">, { params: { id } } as never) as never
}
async create(payload: ApiRequestBody<IndexRoute, "post">) {
return this.post<IndexRoute>(this.indexRoute as IndexRoute, payload as never)
async create(payload: Resolve<Overrides, 'createPayload', ApiRequestBody<IndexRoute, "post">>): Promise<Resolve<Overrides, 'createResponse', ApiResponse<IndexRoute, "post">>> {
return this.post<IndexRoute>(this.indexRoute as IndexRoute, payload as never) as never
}
async update(id: string, payload: ApiRequestBody<ByIdRoute, "put">) {
return this.put<ByIdRoute>(this.byIdRoute as ByIdRoute, payload as never, { params: { id } } as never)
async update(id: string, payload: Resolve<Overrides, 'updatePayload', ApiRequestBody<ByIdRoute, "put">>): Promise<Resolve<Overrides, 'updateResponse', ApiResponse<ByIdRoute, "put">>> {
return this.put<ByIdRoute>(this.byIdRoute as ByIdRoute, payload as never, { params: { id } } as never) as never
}
async destroy(id: string) {
return this.delete<ByIdRoute>(this.byIdRoute as ByIdRoute, { params: { id } } as never)
async destroy(id: string): Promise<Resolve<Overrides, 'destroyResponse', ApiResponse<ByIdRoute, "delete">>> {
return this.delete<ByIdRoute>(this.byIdRoute as ByIdRoute, { params: { id } } as never) as never
}
}

View File

@ -110,3 +110,6 @@ export type ApiOperationResponse<OperationId extends ApiOperationId> =
>
>
>

View File

@ -1 +0,0 @@
export * from './job-cards'

View File

@ -1,4 +0,0 @@
import { JOB_CARD_ROUTES } from "../clients";
import { ApiResponse } from "../infra";
export type JobCardResponseData = NonNullable<ApiResponse<typeof JOB_CARD_ROUTES['INDEX'], 'get'>['data']>[number]

File diff suppressed because it is too large Load Diff