fix build

This commit is contained in:
Mohammad Khyata 2026-04-15 04:59:05 +03:00
parent 020ffccfd6
commit 973149e974
28 changed files with 2352 additions and 89 deletions

View File

@ -111,13 +111,44 @@ Create `apps/dashboard/app/(authenticated)/<section>/<feature>/page.tsx`:
| Form component | `<PascalFeature>Form` | `JobCardForm` | | Form component | `<PascalFeature>Form` | `JobCardForm` |
| Page component | `<PascalFeature>Page` (default export) | `JobCardsPage` | | Page component | `<PascalFeature>Page` (default export) | `JobCardsPage` |
### Relation Fields (Foreign Keys) ### Relation Fields — Choosing the Right Component
**Two patterns exist. Pick the right one before building any relational field.**
#### Simple FK → `RhfAsyncSelectField`
Use for single-record foreign keys within the same domain or to a simple lookup/reference entity.
Examples: invoice → customer, bill → vendor, part → category, service → unit type, job card → vehicle, PO → department, any `*_type` or `*_terms` relation.
- Stored in form as `{ value: string, label: string } | null` - Stored in form as `{ value: string, label: string } | null`
- Use `toRelation(id, name)` to convert API data → form value - Use `toRelation(id, name)` to convert API data → form value
- Use `toId(relation)` to convert form value → API payload - Use `toId(relation)` to convert form value → API payload
- Schema uses `relationFieldSchema = z.object({ value: z.string(), label: z.string() }).nullable()` - Schema uses `relationFieldSchema = z.object({ value: z.string(), label: z.string() }).nullable()`
- Rendered with `<RhfAsyncSelectField>` (fetches options via React Query)
#### Cross-Domain Line Items → `RhfResourceField` (resource-selector skill)
Use when the relationship involves a **join table with extra fields** (quantity, rate, description) or the related items are from a **fully separate domain with its own CRUD page** and multiple records can be linked.
Examples: adding parts to a bill, linking services to an invoice, attaching expense items to a PO.
**→ Read the `resource-selector` SKILL before implementing these fields.**
Ready-to-use selector fields (import directly — do not re-implement):
| Component | Import path | For |
|---|---|---|
| `PartsSelectorField` | `@/modules/parts/parts-selector-field` | Parts line items |
| `ServicesSelectorField` | `@/modules/services/services-selector-field` | Service line items |
| `ExpenseItemsSelectorField` | `@/modules/expense-items/expense-items-selector-field` | Expense line items |
```tsx
// Schema: use an array sub-schema with part_id / service_id / expense_id
part_items: z.array(z.object({ part_id: z.number(), title: z.string(), quantity: z.number(), rate: z.number(), description: z.string().optional() })).optional()
// Form: render inside <FieldGroup>
<PartsSelectorField<MyFormValues, "part_items"> name="part_items" />
```
### Async Select Pattern ### Async Select Pattern
@ -144,8 +175,11 @@ const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) =
| `RhfTextareaField` | Multi-line text | | `RhfTextareaField` | Multi-line text |
| `RhfCheckboxField` | Boolean toggles | | `RhfCheckboxField` | Boolean toggles |
| `RhfSelectField` | Static option dropdowns | | `RhfSelectField` | Static option dropdowns |
| `RhfAsyncSelectField` | Server-fetched single-select combobox | | `RhfAsyncSelectField` | Server-fetched single FK combobox (same-domain relation) |
| `RhfAsyncMultiSelectField` | Server-fetched multi-select combobox | | `RhfAsyncMultiSelectField` | Server-fetched multi-select combobox |
| `RhfResourceField` | Cross-domain multi-select with join table extra fields (parts, services, expenses) — see resource-selector skill |
| `RhfDateField` | Date picker — see date-time-pickers skill |
| `RhfTimeField` | Time picker — see date-time-pickers skill |
### Imports Cheat Sheet ### Imports Cheat Sheet

View File

@ -147,7 +147,7 @@ export function FeatureFilterFields() {
name="some_relation_id" name="some_relation_id"
label="Some Relation" label="Some Relation"
queryKey={[SOME_ROUTES.INDEX]} queryKey={[SOME_ROUTES.INDEX]}
listFn={() => api.someResource.list({ per_page: 100 })} listFn={() => api.someResource.list( )}
mapOption={(item: any) => ({ value: String(item.id), label: item.name })} mapOption={(item: any) => ({ value: String(item.id), label: item.name })}
placeholder="All" placeholder="All"
/> />

View File

@ -0,0 +1,315 @@
---
name: resource-selector
description: "Use RhfResourceField and ResourceSelectorDialog for cross-domain relational line-item fields in forms. Use when: linking parts/services/expense-items to invoices, bills, purchase orders, or estimates; adding line-item tables with qty/rate/description; picking multiple records from a separate domain entity. Do NOT use for simple FK relations like 'customer type' or 'payment terms' — use RhfAsyncSelectField for those."
---
# Resource Selector (Cross-Domain Relations)
Use `RhfResourceField` + `ResourceSelectorDialog` when a form needs to pick **one or more records from a different domain** and store them as an **editable line-item array with extra data** (quantity, rate, description, etc.).
## Decision: RhfResourceField vs RhfAsyncSelectField
| Situation | Use |
|---|---|
| Linking parts / services / expense items to a bill, invoice, PO, or estimate | **`RhfResourceField`** |
| Picking multiple cross-domain items that need per-row extra fields (qty, rate) | **`RhfResourceField`** |
| Simple FK: invoice → customer, PO → vendor, job card → vehicle | **`RhfAsyncSelectField`** |
| Simple FK: insurance type, payment terms, category, department, unit type | **`RhfAsyncSelectField`** |
| Single-record lookup within the same domain | **`RhfAsyncSelectField`** |
**Rule:** If the relationship involves a **join table with extra fields** (quantity, rate, description, chart_of_account) or the related items are from a **fully separate domain with its own CRUD page**, use `RhfResourceField`. Otherwise use `RhfAsyncSelectField`.
### Domain Boundary Examples
```
Cross-domain (use RhfResourceField): Simple FK (use RhfAsyncSelectField):
Invoice ←→ Parts Invoice → Customer
Invoice ←→ Services Invoice → Payment Terms
Bill ←→ Parts Bill → Vendor
Bill ←→ Services Bill → Department
Bill ←→ Expense Items Part → Category
PO ←→ Parts Service → Unit Type
Estimate ←→ Services Job Card → Vehicle
```
## Architecture
| File | Purpose |
|---|---|
| `shared/components/resource-selector/resource-selector-dialog.tsx` | Generic dialog wrapping `CrudResource` with multi-row selection + Confirm/Cancel |
| `shared/components/resource-selector/rhf-resource-field.tsx` | RHF field: Card with trigger → opens dialog → renders items via `renderItems` callback |
| `modules/<domain>/<domain>-columns.tsx` | Shared column definitions reused by both the domain page and selector dialogs |
| `modules/<domain>/<domain>-selector-field.tsx` | Self-contained wrapper for a specific domain (e.g. `PartsSelectorField`) |
## Procedure
### Step 1: Create / Reuse Domain Columns File
If the domain already has a page, extract shared columns to `modules/<domain>/<domain>-columns.tsx`. This avoids duplication between the list page and the selector dialog.
```tsx
// modules/parts/parts-columns.tsx
import { ColumnHeader } from "@/shared/data-view/table-view"
import type { ColumnDef } from "@tanstack/react-table"
export const partColumns = {
title: {
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
cell: ({ row }) => <span className="font-medium">{(row.original as any).title || "—"}</span>,
},
purchasePrice: {
accessorKey: "purchase_price",
header: ({ column }) => <ColumnHeader column={column} title="Purchase Price" />,
cell: ({ row }) => {
const val = (row.original as any).purchase_price
return val != null ? `$${Number(val).toFixed(2)}` : "—"
},
},
// ... more columns
} satisfies Record<string, ColumnDef<any, any>>
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// CRITICAL: use `satisfies Record<string, ColumnDef<any, any>>`
// NOT `as ColumnDef<unknown>` — the latter causes type errors in ResourceSelectorDialog
```
### Step 2: Define the Item Shape in the Schema
Add a sub-schema for the line items inside the form schema:
```tsx
// In <resource>.schema.ts
const billPartItemSchema = z.object({
part_id: z.number(),
title: z.string(), // display only, not sent to API
quantity: z.number().min(1),
rate: z.number().min(0),
description: z.string().optional(),
})
const billFormSchema = z.object({
// ... other fields
part_items: z.array(billPartItemSchema).optional(),
service_items: z.array(billServiceItemSchema).optional(),
})
```
### Step 3: Create the Selector Field Component
Create `modules/<domain>/<domain>-selector-field.tsx`:
```tsx
"use client"
import type { FieldValues, FieldPath } from "react-hook-form"
import { Trash2 } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import {
Table, TableHeader, TableBody,
TableHead, TableRow, TableCell,
} from "@/shared/components/ui/table"
import { RhfResourceField } from "@/shared/components/resource-selector"
import { partColumns } from "./parts-columns"
import { PARTS_ROUTES } from "@garage/api"
import type { PartsClient } from "@garage/api"
type PartItem = {
part_id: number
title: string
quantity: number
rate: number
description?: string
}
type Constraint = PartItem[] | undefined
export type PartsSelectorFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
> = {
name: TName & (TValues[TName] extends Constraint ? TName : never)
label?: string
triggerLabel?: string
}
export function PartsSelectorField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
>({ name, label = "Parts", triggerLabel = "Add Parts" }: PartsSelectorFieldProps<TValues, TName>) {
return (
<RhfResourceField<TValues, TName, PartsClient>
name={name}
label={label}
triggerLabel={triggerLabel}
itemKey="part_id" // deduplicate by this key when re-selecting
dialogProps={{
title: "Select Parts",
crudProps: {
routeKey: PARTS_ROUTES.INDEX,
getClient: (api) => api.parts,
columns: [
partColumns.title,
partColumns.partNumber,
partColumns.purchasePrice,
partColumns.stock,
],
},
}}
mapSelected={(row) => {
const r = row as any
return {
part_id: r.id,
title: r.title || "",
quantity: 1,
rate: Number(r.purchase_price) || 0,
description: "",
} as any
}}
renderItems={(items, { remove, update }) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Part</TableHead>
<TableHead className="w-24">Qty</TableHead>
<TableHead className="w-28">Rate</TableHead>
<TableHead>Description</TableHead>
<TableHead className="w-12" />
</TableRow>
</TableHeader>
<TableBody>
{((items as PartItem[] | undefined) ?? []).map((item, index) => (
<TableRow key={item.part_id}>
<TableCell className="font-medium">{item.title}</TableCell>
<TableCell>
<Input type="number" min={1} value={item.quantity}
onChange={(e) => update(index, { ...item, quantity: Number(e.target.value) || 1 } as any)}
className="h-8 w-20" />
</TableCell>
<TableCell>
<Input type="number" min={0} step={0.01} value={item.rate}
onChange={(e) => update(index, { ...item, rate: Number(e.target.value) || 0 } as any)}
className="h-8 w-24" />
</TableCell>
<TableCell>
<Input value={item.description ?? ""}
onChange={(e) => update(index, { ...item, description: e.target.value } as any)}
placeholder="Optional description" className="h-8" />
</TableCell>
<TableCell>
<Button type="button" variant="ghost" size="icon-sm" onClick={() => remove(index)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
/>
)
}
```
### Step 4: Wire into the Form
```tsx
// In <resource>-form.tsx
// 1. Import
import { PartsSelectorField } from "@/modules/parts/parts-selector-field"
import { ServicesSelectorField } from "@/modules/services/services-selector-field"
// 2. Add to DEFAULT_VALUES
const DEFAULT_VALUES = {
// ...
part_items: [],
service_items: [],
}
// 3. Map from API → form (in mapToFormValues)
part_items: (d.parts ?? []).map((p: any) => ({
part_id: p.part_id ?? p.id,
title: p.part?.title ?? p.title ?? "",
quantity: Number(p.quantity) || 1,
rate: Number(p.rate) || 0,
description: p.description ?? "",
})),
// 4. Map from form → API payload (in mapFormToPayload)
part_items: (values.part_items ?? []).map((item) => ({
part_id: item.part_id,
quantity: item.quantity,
rate: item.rate,
description: item.description || undefined,
})),
// 5. Render inside <FieldGroup>
<PartsSelectorField<MyFormValues, "part_items"> name="part_items" />
<ServicesSelectorField<MyFormValues, "service_items"> name="service_items" />
```
## Existing Selector Fields (Ready to Use)
These are already built. Import and use directly — do NOT re-implement from scratch:
| Component | File | `itemKey` | Notes |
|---|---|---|---|
| `PartsSelectorField` | `modules/parts/parts-selector-field.tsx` | `part_id` | qty + rate + description editable |
| `ServicesSelectorField` | `modules/services/services-selector-field.tsx` | `service_id` | qty + rate + description editable |
| `ExpenseItemsSelectorField` | `modules/expense-items/expense-items-selector-field.tsx` | `expense_id` | qty + rate + description editable |
**Usage:**
```tsx
import { PartsSelectorField } from "@/modules/parts/parts-selector-field"
import { ServicesSelectorField } from "@/modules/services/services-selector-field"
import { ExpenseItemsSelectorField } from "@/modules/expense-items/expense-items-selector-field"
// Inside <FieldGroup>:
<PartsSelectorField<MyFormValues, "part_items"> name="part_items" />
<ServicesSelectorField<MyFormValues, "service_items"> name="service_items" />
<ExpenseItemsSelectorField<MyFormValues, "expense_items"> name="expense_items" />
```
## API Props Reference
### `RhfResourceField`
| Prop | Type | Required | Description |
|---|---|---|---|
| `name` | `FieldPath<TValues>` | ✅ | RHF field name; value is an array |
| `label` | `string` | ✅ | Card heading |
| `triggerLabel` | `string` | | Button label (defaults to `label`) |
| `mapSelected` | `(row) => ItemShape` | ✅ | Maps a selected table row to the form item shape |
| `renderItems` | `(items, helpers) => ReactNode` | ✅ | Renders the editable item table |
| `dialogProps` | object | ✅ | Config for the selector dialog (see below) |
| `itemKey` | `string` | | Field used for deduplication. Defaults to `"id"` |
### `dialogProps`
```ts
{
title: string // Dialog heading
crudProps: {
routeKey: ApiPath // e.g. PARTS_ROUTES.INDEX
getClient: (api) => client // e.g. (api) => api.parts
columns: ColumnDef[] // Subset of domain columns to show
}
rowKey?: keyof Item // Selection identity key (defaults to "id")
}
```
### `renderItems` helpers
| Helper | Signature | Description |
|---|---|---|
| `remove` | `(index: number) => void` | Remove item at index |
| `update` | `(index: number, item) => void` | Replace item at index |
| `replace` | `(items) => void` | Replace entire array |
## Common Pitfalls
- **Column type error**: Always use `satisfies Record<string, ColumnDef<any, any>>` on the columns object. Using `as ColumnDef<unknown>` per-entry causes type errors in the selector dialog.
- **itemKey mismatch**: The `itemKey` on `RhfResourceField` must match the field name in your item shape (e.g. `"part_id"`, not `"id"`).
- **Pre-fill in create mode**: `useResourceForm` uses a shallow spread for initial data. Pre-fill the field with already-shaped items (not raw API shape) to avoid empty arrays. See the `mapToFormValues` pattern above.
- **Selector field generic parameters**: Always pass both type params when using a selector field in a form: `<PartsSelectorField<MyFormValues, "part_items"> name="part_items" />`.

View File

@ -2,6 +2,7 @@ import { DashboardDetailsPage } from '@/base/components/layout/dashboard'
import { getServerApi } from '@garage/api/server' import { getServerApi } from '@garage/api/server'
import { EstimateActions } from '@/modules/estimates/estimate-actions' import { EstimateActions } from '@/modules/estimates/estimate-actions'
import { EstimateProvider } from '@/modules/estimates/estimate-context' import { EstimateProvider } from '@/modules/estimates/estimate-context'
import { CreateInvoiceFromEstimateButton } from '@/modules/estimates/create-invoice-from-estimate-button'
import { FileTextIcon } from 'lucide-react' import { FileTextIcon } from 'lucide-react'
import React from 'react' import React from 'react'
@ -20,7 +21,7 @@ export default async function layout(props: {
: title : title
return ( return (
<EstimateProvider estimate={{ id, label: estimateLabel }}> <EstimateProvider estimate={{ id, label: estimateLabel, data: estimateData }}>
<DashboardDetailsPage <DashboardDetailsPage
className="p-0 lg:p-0" className="p-0 lg:p-0"
icon={<FileTextIcon className="size-5" />} icon={<FileTextIcon className="size-5" />}
@ -29,7 +30,12 @@ export default async function layout(props: {
estimateData?.date ? `Date: ${estimateData.date}` : undefined estimateData?.date ? `Date: ${estimateData.date}` : undefined
} }
backHref="/sales/estimates" backHref="/sales/estimates"
actions={<EstimateActions estimateId={id} />} actions={
<div className="flex items-center gap-2">
<CreateInvoiceFromEstimateButton />
<EstimateActions estimateId={id} />
</div>
}
tabs={[ tabs={[
{ href: `/sales/estimates/${id}`, label: 'Details' }, { href: `/sales/estimates/${id}`, label: 'Details' },
]} ]}

View File

@ -1,5 +1,8 @@
import { getServerApi } from '@garage/api/server' import { getServerApi } from '@garage/api/server'
import { EstimateGeneralInfo } from '@/modules/estimates/estimate-general-info' import { EstimateGeneralInfo } from '@/modules/estimates/estimate-general-info'
import { EstimateServicesSection } from '@/modules/estimates/estimate-services-section'
import { EstimatePartsSection } from '@/modules/estimates/estimate-parts-section'
import { EstimateExpenseItemsSection } from '@/modules/estimates/estimate-expense-items-section'
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page' import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
export default async function page(props: { params: Promise<{ id: string }> }) { export default async function page(props: { params: Promise<{ id: string }> }) {
@ -15,7 +18,13 @@ export default async function page(props: { params: Promise<{ id: string }> }) {
return ( return (
<DashboardPage header={null}> <DashboardPage header={null}>
<div className="grid gap-6">
<EstimateGeneralInfo estimate={estimateData} /> <EstimateGeneralInfo estimate={estimateData} />
<EstimateServicesSection estimateId={id} />
<EstimatePartsSection estimateId={id} />
<EstimateExpenseItemsSection estimateId={id} />
</div>
</DashboardPage> </DashboardPage>
) )
} }

View File

@ -1,8 +1,22 @@
"use client" "use client"
import { useState } from "react"
import { useFormContext, useController, type FieldValues, type FieldPath } from "react-hook-form" import { useFormContext, useController, type FieldValues, type FieldPath } from "react-hook-form"
import { useQueryClient } from "@tanstack/react-query"
import { PlusIcon } from "lucide-react"
import { EMPLOYEE_ROUTES } from "@garage/api"
import { Field, FieldLabel, FieldError, FieldDescription } from "@/shared/components/ui/field"
import { FieldShell } from "@/shared/components/form/field-shell" import { FieldShell } from "@/shared/components/form/field-shell"
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 { EmployeeCombobox } from "./employee-combobox" import { EmployeeCombobox } from "./employee-combobox"
import { EmployeeForm } from "./employee-form"
// ── Props ── // ── Props ──
@ -16,6 +30,8 @@ export type RhfEmployeeSelectFieldProps<
required?: boolean required?: boolean
disabled?: boolean disabled?: boolean
placeholder?: string placeholder?: string
/** Show a "+" button to create a new employee inline */
showCreate?: boolean
} }
// ── Component ── // ── Component ──
@ -30,6 +46,7 @@ export function RhfEmployeeSelectField<
required, required,
disabled, disabled,
placeholder = "Search by name or email...", placeholder = "Search by name or email...",
showCreate,
}: RhfEmployeeSelectFieldProps<TValues, TName>) { }: RhfEmployeeSelectFieldProps<TValues, TName>) {
const { control } = useFormContext<TValues>() const { control } = useFormContext<TValues>()
const { const {
@ -37,13 +54,15 @@ export function RhfEmployeeSelectField<
fieldState: { error }, fieldState: { error },
} = useController({ name, control, disabled }) } = useController({ name, control, disabled })
return ( const queryClient = useQueryClient()
<FieldShell const [isCreateOpen, setIsCreateOpen] = useState(false)
label={label}
error={error?.message} const handleCreateSuccess = () => {
description={description} queryClient.invalidateQueries({ queryKey: [EMPLOYEE_ROUTES.INDEX] })
required={required} setIsCreateOpen(false)
> }
const combobox = (
<EmployeeCombobox <EmployeeCombobox
value={field.value} value={field.value}
onValueChange={(emp) => { onValueChange={(emp) => {
@ -55,6 +74,57 @@ export function RhfEmployeeSelectField<
onBlur={field.onBlur} onBlur={field.onBlur}
aria-invalid={!!error || undefined} aria-invalid={!!error || undefined}
/> />
)
if (showCreate) {
return (
<>
<Field data-invalid={!!error || undefined}>
{label && (
<div className="flex items-center justify-between">
<FieldLabel>
{label}
{required && <span className="text-destructive ms-0.5">*</span>}
</FieldLabel>
<Button
type="button"
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={() => setIsCreateOpen(true)}
title="Add new employee"
>
<PlusIcon className="h-3.5 w-3.5" />
</Button>
</div>
)}
{combobox}
{description && <FieldDescription>{description}</FieldDescription>}
{error && <FieldError>{error.message}</FieldError>}
</Field>
<Dialog open={isCreateOpen} onOpenChange={(v) => { if (!v) setIsCreateOpen(false) }}>
<DialogContent className="min-w-xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">Add Employee</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[80vh] px-4">
<EmployeeForm onSuccess={handleCreateSuccess} />
</ScrollArea>
</DialogContent>
</Dialog>
</>
)
}
return (
<FieldShell
label={label}
error={error?.message}
description={description}
required={required}
>
{combobox}
</FieldShell> </FieldShell>
) )
} }

View File

@ -0,0 +1,70 @@
"use client"
import { useState } from "react"
import { FileText } 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 { InvoiceForm } from "@/modules/invoices/invoice-form"
import { toRelation } from "@/shared/lib/utils"
import { useEstimate } from "./estimate-context"
/**
* Maps an Estimate data object to an InvoiceFormValues shape so that
* useResourceForm's shallow spread correctly pre-fills all relational fields.
*/
function mapEstimateToInvoiceInitialData(estimate: Record<string, any>) {
return {
subject: estimate.title ?? "",
notes: estimate.footer ?? "",
// Relation fields — must be { value, label } objects for RhfAsyncSelectField
customer: toRelation(estimate.customer_id, estimate.customer_name),
vehicle: toRelation(estimate.vehicle_id, estimate.vehicle_name),
department: toRelation(estimate.department_id, estimate.department_name),
invoice_number: "",
invoice_date: estimate.date ?? "",
due_date: "",
status: "draft" as const,
}
}
export function CreateInvoiceFromEstimateButton() {
const [open, setOpen] = useState(false)
const estimateContext = useEstimate()
if (!estimateContext) return null
const initialData = estimateContext.data
? mapEstimateToInvoiceInitialData(estimateContext.data)
: undefined
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<FileText className="me-2 size-4" />
Generate Invoice
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="min-w-2xl">
<DialogHeader>
<DialogTitle>Generate Invoice from Estimate</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[75vh] px-1">
<InvoiceForm
initialData={initialData}
onSuccess={() => setOpen(false)}
/>
</ScrollArea>
</DialogContent>
</Dialog>
</>
)
}

View File

@ -14,26 +14,197 @@ import {
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogFooter,
} from "@/shared/components/ui/dialog" } from "@/shared/components/ui/dialog"
import { ScrollArea } from "@/shared/components/ui/scroll-area" import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Ellipsis, Pencil, Trash2 } from "lucide-react" import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { Ellipsis, Pencil, Trash2, ShieldCheck, Check, X } from "lucide-react"
import { useState } from "react" import { useState } from "react"
import { useMutation, useQuery } from "@tanstack/react-query"
import { toast } from "sonner"
import { EstimateForm } from "./estimate-form" import { EstimateForm } from "./estimate-form"
import { ESTIMATE_ROUTES } from "@garage/api"
import { DatePickerField, TimePickerField } from "@/shared/components/form"
import { cn } from "@/shared/lib/utils"
import { EmployeeCombobox, type EmployeeOption } from "../employees/employee-combobox"
type EstimateActionsProps = { type EstimateActionsProps = {
estimateId: string estimateId: string
} }
const AUTHORISATION_METHOD_OPTIONS = [
{ value: "in_person", label: "In Person" },
{ value: "phone", label: "Phone" },
{ value: "email", label: "Email" },
{ value: "online", label: "Online" },
]
type ServiceLine = { id: number; labor_name?: string; title?: string; quantity: number; rate: number | string; description?: string }
type PartLine = { id: number; title?: string; quantity: number; rate: number | string; description?: string }
type ExpenseLine = { id: number; item_name?: string; title?: string; quantity: number; rate: number | string; description?: string }
function toggleStatus(current: string, action: "accepted" | "rejected"): string {
return current === action ? "pending" : action
}
function ItemStatusRow({
name,
rate,
quantity,
description,
status,
onToggle,
}: {
name: string
rate: number | string
quantity: number
description?: string
status: string
onToggle: (action: "accepted" | "rejected") => void
}) {
const rateNum = Number(rate)
const amount = rateNum * quantity
return (
<div className="flex items-center gap-4 py-2.5 border-b last:border-0">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{name}</p>
<div className="flex items-center gap-3 mt-1">
<span className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">{quantity}</span> qty
</span>
<span className="text-muted-foreground/40">·</span>
<span className="text-xs text-muted-foreground">
Rate <span className="font-medium text-foreground">{rateNum.toFixed(2)}</span>
</span>
<span className="text-muted-foreground/40">·</span>
<span className="text-xs font-semibold text-foreground">{amount.toFixed(2)}</span>
</div>
{description && (
<p className="text-xs text-muted-foreground/70 truncate mt-0.5">{description}</p>
)}
</div>
<div className="flex items-center gap-1.5 shrink-0">
<Button
type="button"
size="sm"
variant="outline"
className={cn(
"h-8 gap-1.5 px-3",
status === "rejected" && "bg-destructive text-destructive-foreground border-destructive hover:bg-destructive/90",
)}
onClick={() => onToggle("rejected")}
>
<X className="size-3.5" />
Reject
</Button>
<Button
type="button"
size="sm"
variant="outline"
className={cn(
"h-8 gap-1.5 px-3",
status === "accepted" && "bg-primary text-primary-foreground border-primary hover:bg-primary/90",
)}
onClick={() => onToggle("accepted")}
>
<Check className="size-3.5" />
Accept
</Button>
</div>
</div>
)
}
function SectionHeading({ title }: { title: string }) {
return (
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-1">{title}</p>
)
}
export function EstimateActions({ estimateId }: EstimateActionsProps) { export function EstimateActions({ estimateId }: EstimateActionsProps) {
const api = useAuthApi() const api = useAuthApi()
const router = useRouter() const router = useRouter()
const [editOpen, setEditOpen] = useState(false) const [editOpen, setEditOpen] = useState(false)
const [authOpen, setAuthOpen] = useState(false)
const [itemStatuses, setItemStatuses] = useState<Record<string, string>>({})
const [authMethod, setAuthMethod] = useState("in_person")
const [employee, setEmployee] = useState<EmployeeOption | null>(null)
const [authDate, setAuthDate] = useState("")
const [authTime, setAuthTime] = useState("")
const { data: servicesData = [], isLoading: loadingServices } = useQuery({
queryKey: [ESTIMATE_ROUTES.SERVICES, estimateId, "auth"],
queryFn: async () => {
const res = await api.estimates.listServices(estimateId)
return ((res as any)?.data ?? []) as ServiceLine[]
},
enabled: authOpen,
})
const { data: partsData = [], isLoading: loadingParts } = useQuery({
queryKey: [ESTIMATE_ROUTES.PARTS, estimateId, "auth"],
queryFn: async () => {
const res = await api.estimates.listParts(estimateId)
return ((res as any)?.data ?? []) as PartLine[]
},
enabled: authOpen,
})
const { data: expenseItemsData = [], isLoading: loadingExpenseItems } = useQuery({
queryKey: [ESTIMATE_ROUTES.EXPENSE_ITEMS, estimateId, "auth"],
queryFn: async () => {
const res = await api.estimates.listExpenseItems(estimateId)
return ((res as any)?.data ?? []) as ExpenseLine[]
},
enabled: authOpen,
})
const isLoading = loadingServices || loadingParts || loadingExpenseItems
const hasItems = servicesData.length > 0 || partsData.length > 0 || expenseItemsData.length > 0
const getStatus = (key: string) => itemStatuses[key] ?? "pending"
const handleToggle = (key: string, action: "accepted" | "rejected") =>
setItemStatuses((prev) => ({ ...prev, [key]: toggleStatus(prev[key] ?? "pending", action) }))
const handleDelete = async () => { const handleDelete = async () => {
await api.estimates.destroy(estimateId) await api.estimates.destroy(estimateId)
router.push("/sales/estimates") router.push("/sales/estimates")
} }
const authMutation = useMutation({
mutationFn: () =>
api.estimates.storeAuthorisation(estimateId, {
estimate_services: servicesData.map((s) => ({ id: s.id, status: getStatus(`s-${s.id}`) })),
estimate_parts: partsData.map((p) => ({ id: p.id, status: getStatus(`p-${p.id}`) })),
estimate_expense_items: expenseItemsData.map((e) => ({ id: e.id, status: getStatus(`e-${e.id}`) })),
authorisation_method: authMethod,
employee_id: employee ? Number(employee.value) : undefined,
}),
onSuccess: () => {
toast.success("Authorisation stored successfully")
setAuthOpen(false)
router.refresh()
},
onError: () => toast.error("Failed to store authorisation"),
})
const openAuthDialog = () => {
setItemStatuses({})
setAuthMethod("in_person")
setEmployee(null)
const n = new Date()
setAuthDate(n.toISOString().split("T")[0])
setAuthTime(`${String(n.getHours()).padStart(2, "0")}:${String(n.getMinutes()).padStart(2, "0")}:00`)
setAuthOpen(true)
}
return ( return (
<> <>
<DropdownMenu> <DropdownMenu>
@ -47,6 +218,10 @@ export function EstimateActions({ estimateId }: EstimateActionsProps) {
<Pencil className="size-4" /> <Pencil className="size-4" />
Edit Edit
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={openAuthDialog}>
<ShieldCheck className="size-4" />
Store Authorisation
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={handleDelete}> <DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" /> <Trash2 className="size-4" />
Delete Delete
@ -54,6 +229,7 @@ export function EstimateActions({ estimateId }: EstimateActionsProps) {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{/* Edit Dialog */}
<Dialog open={editOpen} onOpenChange={setEditOpen}> <Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="min-w-xl"> <DialogContent className="min-w-xl">
<DialogHeader> <DialogHeader>
@ -70,6 +246,122 @@ export function EstimateActions({ estimateId }: EstimateActionsProps) {
</ScrollArea> </ScrollArea>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Authorisation Dialog */}
<Dialog open={authOpen} onOpenChange={setAuthOpen}>
<DialogContent className="sm:max-w-7xl">
<DialogHeader>
<DialogTitle className="text-xl font-bold">Store Authorisation</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[55vh]">
<div className="grid gap-5 px-1 py-1">
{isLoading && (
<p className="text-sm text-muted-foreground">Loading line items</p>
)}
{!isLoading && !hasItems && (
<p className="text-sm text-muted-foreground">No line items on this estimate.</p>
)}
{servicesData.length > 0 && (
<div>
<SectionHeading title="Services" />
{servicesData.map((s) => (
<ItemStatusRow
key={s.id}
name={s.labor_name || s.title || `#${s.id}`}
rate={s.rate}
quantity={s.quantity}
description={s.description}
status={getStatus(`s-${s.id}`)}
onToggle={(a) => handleToggle(`s-${s.id}`, a)}
/>
))}
</div>
)}
{partsData.length > 0 && (
<div>
<SectionHeading title="Parts" />
{partsData.map((p) => (
<ItemStatusRow
key={p.id}
name={p.title || `#${p.id}`}
rate={p.rate}
quantity={p.quantity}
description={p.description}
status={getStatus(`p-${p.id}`)}
onToggle={(a) => handleToggle(`p-${p.id}`, a)}
/>
))}
</div>
)}
{expenseItemsData.length > 0 && (
<div>
<SectionHeading title="Expense Items" />
{expenseItemsData.map((e) => (
<ItemStatusRow
key={e.id}
name={e.item_name || e.title || `#${e.id}`}
rate={e.rate}
quantity={e.quantity}
description={e.description}
status={getStatus(`e-${e.id}`)}
onToggle={(a) => handleToggle(`e-${e.id}`, a)}
/>
))}
</div>
)}
</div>
</ScrollArea>
<div className="grid gap-3 border-t pt-4">
<div className="flex gap-3">
<div className="flex-1">
<p className="text-xs text-muted-foreground mb-1.5">Date</p>
<DatePickerField value={authDate} onChange={(v) => setAuthDate(v ?? "")} />
</div>
<div className="flex-1">
<p className="text-xs text-muted-foreground mb-1.5">Time</p>
<TimePickerField value={authTime} onChange={(v) => setAuthTime(v ?? "")} />
</div>
<div className="flex-1">
<p className="text-xs text-muted-foreground mb-1.5">Method</p>
<Select value={authMethod} onValueChange={setAuthMethod}>
<SelectTrigger className="h-9 w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AUTHORISATION_METHOD_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<p className="text-xs text-muted-foreground mb-1.5">Employee</p>
<EmployeeCombobox
value={employee}
onValueChange={setEmployee}
showClear={!!employee}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAuthOpen(false)}>
Cancel
</Button>
<Button onClick={() => authMutation.mutate()} disabled={authMutation.isPending || isLoading}>
{authMutation.isPending ? "Saving…" : "Save Authorisations"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</> </>
) )
} }

View File

@ -2,9 +2,10 @@
import { createContext, useContext } from "react" import { createContext, useContext } from "react"
type EstimateContextValue = { export type EstimateContextValue = {
id: string id: string
label: string label: string
data?: Record<string, any>
} }
const EstimateContext = createContext<EstimateContextValue | null>(null) const EstimateContext = createContext<EstimateContextValue | null>(null)

View File

@ -0,0 +1,155 @@
"use client"
import React from "react"
import { z } from "zod"
import { AlertTriangle } from "lucide-react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/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 { useAuthApi } from "@/shared/useApi"
import { toast } from "sonner"
import { DEPARTMENT_ROUTES } from "@garage/api"
// ── Schema ──
const schema = z.object({
quantity: z.coerce.number().min(1, "Quantity is required"),
rate: z.string().min(1, "Rate is required"),
description: z.string().optional(),
department: z.object({ value: z.string(), label: z.string() }).nullable().optional(),
})
type FormValues = z.infer<typeof schema>
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
export type EstimateExpenseItemConfigFormProps = {
expenseItem: { id: number; name?: string; purchase_price?: string | number }
estimateId: string
onSuccess?: () => void
onCancel?: () => void
}
export function EstimateExpenseItemConfigForm({
expenseItem,
estimateId,
onSuccess,
onCancel,
}: EstimateExpenseItemConfigFormProps) {
const api = useAuthApi()
const form = useForm<FormValues>({
resolver: zodResolver(schema) as any,
defaultValues: {
quantity: 1,
rate: expenseItem.purchase_price != null ? String(expenseItem.purchase_price) : "",
description: "",
},
})
const [error, setError] = React.useState<string | null>(null)
const [isPending, setIsPending] = React.useState(false)
async function handleSubmit(values: FormValues) {
setError(null)
setIsPending(true)
try {
const promise = api.estimates.addExpenseItem(estimateId, {
expense_item_id: expenseItem.id,
quantity: values.quantity,
rate: values.rate,
description: values.description || undefined,
department_id: values.department ? Number(values.department.value) : undefined,
} as any)
toast.promise(promise, {
loading: "Adding expense item...",
success: "Expense item added successfully",
error: "Failed to add expense item",
})
await promise
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>Failed to add expense item</AlertTitle>
{error}
</Alert>
)}
<FieldGroup>
<div className="rounded-md bg-muted px-3 py-2 text-sm font-medium">
{expenseItem.name ?? `Expense Item #${expenseItem.id}`}
</div>
<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>
<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 ?? item.title ?? String(item.id),
})}
{...STORE_OBJECT}
/>
<RhfTextareaField
name="description"
label="Description"
placeholder="Optional description"
rows={2}
/>
<div className="flex justify-end gap-2">
{onCancel && (
<Button type="button" variant="outline" onClick={onCancel} disabled={isPending}>
Cancel
</Button>
)}
<Button type="submit" disabled={isPending}>
{isPending ? "Adding..." : "Add Expense Item"}
</Button>
</div>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,205 @@
"use client"
import { useState } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { Plus, Trash2 } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { ResourceSelectorDialog } from "@/shared/components/resource-selector/resource-selector-dialog"
import { useAuthApi } from "@/shared/useApi"
import { ESTIMATE_ROUTES, EXPENSE_ITEM_ROUTES } from "@garage/api"
import type { ExpenseItemsClient } from "@garage/api"
import { expenseItemColumns } from "@/modules/expense-items/expense-items-columns"
import { EstimateExpenseItemConfigForm } from "./estimate-expense-item-config-form"
import { toast } from "sonner"
type ExpenseLine = {
id: number
expense_item_id?: number
item_name?: string
title?: string
quantity: number
rate: number | string
description?: string
}
type SelectedExpenseItem = {
id: number
name?: string
purchase_price?: string | number
}
export function EstimateExpenseItemsSection({ estimateId }: { estimateId: string }) {
const api = useAuthApi()
const queryClient = useQueryClient()
const [pickerOpen, setPickerOpen] = useState(false)
const [configExpenseItem, setConfigExpenseItem] = useState<SelectedExpenseItem | null>(null)
const queryKey = [ESTIMATE_ROUTES.EXPENSE_ITEMS, estimateId]
const { data, isLoading } = useQuery({
queryKey,
queryFn: async () => {
const res = await api.estimates.listExpenseItems(estimateId)
return ((res as any)?.data ?? []) as ExpenseLine[]
},
})
const items: ExpenseLine[] = data ?? []
const invalidate = () => queryClient.invalidateQueries({ queryKey })
const updateMutation = useMutation({
mutationFn: ({ lineId, payload }: { lineId: string; payload: { quantity?: number } }) =>
api.estimates.updateExpenseItem(estimateId, lineId, payload),
onSuccess: () => {
toast.success("Expense item updated")
invalidate()
},
onError: () => toast.error("Failed to update expense item"),
})
const removeMutation = useMutation({
mutationFn: (lineId: string) => api.estimates.removeExpenseItem(estimateId, lineId),
onSuccess: invalidate,
onError: () => toast.error("Failed to remove expense item"),
})
const handlePickerConfirm = (rows: any[]) => {
const row = rows[0]
if (!row) return
setConfigExpenseItem({
id: row.id,
name: row.name ?? row.title,
purchase_price: row.purchase_price,
})
}
const getDisplayName = (item: ExpenseLine) =>
item.item_name ?? item.title ?? `Item #${item.expense_item_id ?? item.id}`
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Expense Items</CardTitle>
<Button size="sm" variant="outline" onClick={() => setPickerOpen(true)}>
<Plus className="size-4 mr-1" />
Add Expense Item
</Button>
</CardHeader>
<CardContent>
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
{!isLoading && items.length === 0 && (
<p className="text-sm text-muted-foreground">No expense items added yet.</p>
)}
{items.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead className="w-24">Qty</TableHead>
<TableHead className="w-28">Rate</TableHead>
<TableHead>Description</TableHead>
<TableHead className="w-12" />
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium">{getDisplayName(item)}</TableCell>
<TableCell>
<Input
type="number"
min={1}
defaultValue={item.quantity}
onBlur={(e) =>
updateMutation.mutate({
lineId: String(item.id),
payload: { quantity: Number(e.target.value) || 1 },
})
}
className="h-8 w-20"
/>
</TableCell>
<TableCell className="text-sm text-muted-foreground tabular-nums">
{Number(item.rate).toFixed(2)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{item.description || "—"}
</TableCell>
<TableCell>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeMutation.mutate(String(item.id))}
disabled={removeMutation.isPending}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
{/* Step 1: Pick an expense item (single-select) */}
<ResourceSelectorDialog<ExpenseItemsClient>
title="Select Expense Item"
open={pickerOpen}
onOpenChange={setPickerOpen}
selectionMode="single"
onConfirm={handlePickerConfirm}
crudProps={{
routeKey: EXPENSE_ITEM_ROUTES.INDEX,
getClient: (api) => api.expenseItems,
columns: [
expenseItemColumns.name,
expenseItemColumns.purchasePrice,
expenseItemColumns.chartOfAccount,
],
}}
/>
{/* Step 2: Configure and add the selected expense item */}
<Dialog open={!!configExpenseItem} onOpenChange={(v) => { if (!v) setConfigExpenseItem(null) }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-xl font-bold">Configure Expense Item</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[70vh] px-1">
{configExpenseItem && (
<EstimateExpenseItemConfigForm
expenseItem={configExpenseItem}
estimateId={estimateId}
onSuccess={() => {
setConfigExpenseItem(null)
invalidate()
}}
onCancel={() => setConfigExpenseItem(null)}
/>
)}
</ScrollArea>
</DialogContent>
</Dialog>
</Card>
)
}

View File

@ -0,0 +1,155 @@
"use client"
import React from "react"
import { z } from "zod"
import { AlertTriangle } from "lucide-react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/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 { useAuthApi } from "@/shared/useApi"
import { toast } from "sonner"
import { DEPARTMENT_ROUTES } from "@garage/api"
// ── Schema ──
const schema = z.object({
quantity: z.coerce.number().min(1, "Quantity is required"),
rate: z.string().min(1, "Rate is required"),
department: z.object({ value: z.string(), label: z.string() }).nullable().optional(),
description: z.string().optional(),
})
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
type FormValues = z.infer<typeof schema>
export type EstimatePartConfigFormProps = {
part: { id: number; title?: string; purchase_price?: string | number }
estimateId: string
onSuccess?: () => void
onCancel?: () => void
}
export function EstimatePartConfigForm({
part,
estimateId,
onSuccess,
onCancel,
}: EstimatePartConfigFormProps) {
const api = useAuthApi()
const form = useForm<FormValues>({
resolver: zodResolver(schema) as any,
defaultValues: {
quantity: 1,
rate: part.purchase_price != null ? String(part.purchase_price) : "",
department: null,
description: "",
},
})
const [error, setError] = React.useState<string | null>(null)
const [isPending, setIsPending] = React.useState(false)
async function handleSubmit(values: FormValues) {
setError(null)
setIsPending(true)
try {
const promise = api.estimates.addPart(estimateId, {
part_id: part.id,
quantity: values.quantity,
rate: values.rate,
department_id: values.department ? Number(values.department.value) : undefined,
description: values.description || undefined,
} as any)
toast.promise(promise, {
loading: "Adding part...",
success: "Part added successfully",
error: "Failed to add part",
})
await promise
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>Failed to add part</AlertTitle>
{error}
</Alert>
)}
<FieldGroup>
<div className="rounded-md bg-muted px-3 py-2 text-sm font-medium">
{part.title ?? `Part #${part.id}`}
</div>
<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>
<RhfTextareaField
name="description"
label="Description"
placeholder="Optional description"
rows={2}
/>
<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 ?? item.title ?? String(item.id),
})}
{...STORE_OBJECT}
/>
<div className="flex justify-end gap-2">
{onCancel && (
<Button type="button" variant="outline" onClick={onCancel} disabled={isPending}>
Cancel
</Button>
)}
<Button type="submit" disabled={isPending}>
{isPending ? "Adding..." : "Add Part"}
</Button>
</div>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,206 @@
"use client"
import { useState } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { Plus, Trash2 } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { ResourceSelectorDialog } from "@/shared/components/resource-selector/resource-selector-dialog"
import { useAuthApi } from "@/shared/useApi"
import { PARTS_ROUTES, ESTIMATE_ROUTES } from "@garage/api"
import type { PartsClient } from "@garage/api"
import { partColumns } from "@/modules/parts/parts-columns"
import { EstimatePartConfigForm } from "./estimate-part-config-form"
import { toast } from "sonner"
type PartLine = {
id: number
part_id?: number
title?: string
quantity: number
rate: number | string
description?: string
}
type SelectedPart = {
id: number
title?: string
purchase_price?: string | number
}
export function EstimatePartsSection({ estimateId }: { estimateId: string }) {
const api = useAuthApi()
const queryClient = useQueryClient()
const [pickerOpen, setPickerOpen] = useState(false)
const [configPart, setConfigPart] = useState<SelectedPart | null>(null)
const queryKey = [ESTIMATE_ROUTES.PARTS, estimateId]
const { data, isLoading } = useQuery({
queryKey,
queryFn: async () => {
const res = await api.estimates.listParts(estimateId)
return ((res as any)?.data ?? []) as PartLine[]
},
})
const items: PartLine[] = data ?? []
const invalidate = () => queryClient.invalidateQueries({ queryKey })
const updateMutation = useMutation({
mutationFn: ({ lineId, payload }: { lineId: string; payload: { quantity?: number } }) =>
api.estimates.updatePart(estimateId, lineId, payload),
onSuccess: () => {
toast.success("Part updated")
invalidate()
},
onError: () => toast.error("Failed to update part"),
})
const removeMutation = useMutation({
mutationFn: (lineId: string) => api.estimates.removePart(estimateId, lineId),
onSuccess: invalidate,
onError: () => toast.error("Failed to remove part"),
})
const handlePickerConfirm = (rows: any[]) => {
const row = rows[0]
if (!row) return
setConfigPart({
id: row.id,
title: row.title ?? row.name,
purchase_price: row.purchase_price,
})
}
const getDisplayName = (item: PartLine) =>
item.title ?? `Part #${item.part_id ?? item.id}`
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Parts</CardTitle>
<Button size="sm" variant="outline" onClick={() => setPickerOpen(true)}>
<Plus className="size-4 mr-1" />
Add Part
</Button>
</CardHeader>
<CardContent>
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
{!isLoading && items.length === 0 && (
<p className="text-sm text-muted-foreground">No parts added yet.</p>
)}
{items.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>Part</TableHead>
<TableHead className="w-24">Qty</TableHead>
<TableHead className="w-28">Rate</TableHead>
<TableHead>Description</TableHead>
<TableHead className="w-12" />
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium">{getDisplayName(item)}</TableCell>
<TableCell>
<Input
type="number"
min={1}
defaultValue={item.quantity}
onBlur={(e) =>
updateMutation.mutate({
lineId: String(item.id),
payload: { quantity: Number(e.target.value) || 1 },
})
}
className="h-8 w-20"
/>
</TableCell>
<TableCell className="text-sm text-muted-foreground tabular-nums">
{Number(item.rate).toFixed(2)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{item.description || "—"}
</TableCell>
<TableCell>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeMutation.mutate(String(item.id))}
disabled={removeMutation.isPending}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
{/* Step 1: Pick a part (single-select) */}
<ResourceSelectorDialog<PartsClient>
title="Select Part"
open={pickerOpen}
onOpenChange={setPickerOpen}
selectionMode="single"
onConfirm={handlePickerConfirm}
crudProps={{
routeKey: PARTS_ROUTES.INDEX,
getClient: (api) => api.parts,
columns: [
partColumns.title,
partColumns.partNumber,
partColumns.manufacturer,
partColumns.purchasePrice,
partColumns.stock,
],
}}
/>
{/* Step 2: Configure and add the selected part */}
<Dialog open={!!configPart} onOpenChange={(v) => { if (!v) setConfigPart(null) }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-xl font-bold">Configure Part</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[70vh] px-1">
{configPart && (
<EstimatePartConfigForm
part={configPart}
estimateId={estimateId}
onSuccess={() => {
setConfigPart(null)
invalidate()
}}
onCancel={() => setConfigPart(null)}
/>
)}
</ScrollArea>
</DialogContent>
</Dialog>
</Card>
)
}

View File

@ -0,0 +1,237 @@
"use client"
import React from "react"
import { z } from "zod"
import { AlertTriangle, Plus } from "lucide-react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/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,
RhfSelectField,
RhfAsyncSelectField,
} from "@/shared/components/form"
import { useAuthApi } from "@/shared/useApi"
import { RateType, DEPARTMENT_ROUTES, INVENTORY_ROUTES } from "@garage/api"
import { toast } from "sonner"
// ── Schema ──
const schema = z.object({
rate_type: z.string().optional(),
labor_rate: z.object({ value: z.string(), label: z.string() }).nullable().optional(),
quantity: z.coerce.number().min(1, "Quantity is required"),
rate: z.string().min(1, "Rate is required"),
working_hours: z.string().optional(),
labor_hours: z.string().optional(),
tax: z.string().optional(),
chart_of_account: z.string().optional(),
department: z.object({ value: z.string(), label: z.string() }).nullable().optional(),
description: z.string().optional(),
})
type FormValues = z.infer<typeof schema>
const DEFAULT_VALUES: FormValues = {
rate_type: "flat_rate",
labor_rate: null,
quantity: 1,
rate: "",
working_hours: "",
labor_hours: "",
tax: "",
chart_of_account: "",
department: null,
description: "",
}
const RATE_TYPE_OPTIONS = RateType.map((v) => ({
value: v,
label: v === "flat_rate" ? "Flat Rate" : "Hourly",
}))
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
export type EstimateServiceConfigFormProps = {
/** The selected service row from the picker dialog */
service: { id: number; labor_name?: string; selling_price?: string | number }
estimateId: string
onSuccess?: () => void
onCancel?: () => void
}
export function EstimateServiceConfigForm({
service,
estimateId,
onSuccess,
onCancel,
}: EstimateServiceConfigFormProps) {
const api = useAuthApi()
const form = useForm<FormValues>({
resolver: zodResolver(schema) as any,
defaultValues: {
...DEFAULT_VALUES,
rate: service.selling_price != null ? String(service.selling_price) : "",
},
})
const [error, setError] = React.useState<string | null>(null)
const [isPending, setIsPending] = React.useState(false)
async function handleSubmit(values: FormValues) {
setError(null)
setIsPending(true)
try {
const promise = api.estimates.addService(estimateId, {
service_id: service.id,
rate_type: values.rate_type || undefined,
labor_rate_id: values.labor_rate ? Number(values.labor_rate.value) : undefined,
quantity: values.quantity,
rate: values.rate || undefined,
working_hours: values.working_hours || undefined,
labor_hours: values.labor_hours || undefined,
tax: values.tax || undefined,
chart_of_account: values.chart_of_account || undefined,
department_id: values.department ? Number(values.department.value) : undefined,
description: values.description || undefined,
})
toast.promise(promise, {
loading: "Adding service...",
success: "Service added successfully",
error: "Failed to add service",
})
await promise
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>Failed to add service</AlertTitle>
{error}
</Alert>
)}
<FieldGroup>
<div className="rounded-md bg-muted px-3 py-2 text-sm font-medium">
{service.labor_name ?? `Service #${service.id}`}
</div>
<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>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfSelectField
name="rate_type"
label="Rate Type"
options={RATE_TYPE_OPTIONS}
/>
<RhfAsyncSelectField
name="labor_rate"
label="Labor Rate"
placeholder="Select labor rate"
queryKey={[INVENTORY_ROUTES.LABOR_RATES]}
listFn={() => api.inventory.listLaborRates()}
mapOption={(item: any) => ({
value: String(item.id),
label: item.title ?? String(item.id),
})}
{...STORE_OBJECT}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="working_hours"
label="Working Hours"
type="number"
placeholder="0.00"
/>
<RhfTextField
name="labor_hours"
label="Labor Hours"
type="number"
placeholder="0.00"
/>
</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()}
mapOption={(item: any) => ({
value: String(item.id),
label: item.name ?? String(item.id),
})}
{...STORE_OBJECT}
/>
<RhfTextField
name="tax"
label="Tax (%)"
placeholder="e.g. 5"
/>
</div>
<RhfTextField
name="chart_of_account"
label="Chart of Account"
placeholder="e.g. 4000"
/>
<RhfTextareaField
name="description"
label="Description"
placeholder="Optional description"
rows={2}
/>
<div className="flex justify-end gap-2">
{onCancel && (
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
)}
<Button type="submit" disabled={isPending}>
{isPending ? "Adding..." : (
<>
<Plus className="size-4 mr-1" />
Add Service
</>
)}
</Button>
</div>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,219 @@
"use client"
import { useState } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { Plus, Trash2 } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { ResourceSelectorDialog } from "@/shared/components/resource-selector/resource-selector-dialog"
import { useAuthApi } from "@/shared/useApi"
import { SERVICE_ROUTES, ESTIMATE_ROUTES } from "@garage/api"
import type { ServicesClient } from "@garage/api"
import { serviceColumns } from "@/modules/services/services-columns"
import { EstimateServiceConfigForm } from "./estimate-service-config-form"
import { toast } from "sonner"
type ServiceLine = {
id: number
service_id?: number
labor_name?: string
title?: string
quantity: number
rate: number | string
description?: string
}
type SelectedService = {
id: number
labor_name?: string
selling_price?: string | number
}
export function EstimateServicesSection({ estimateId }: { estimateId: string }) {
const api = useAuthApi()
const queryClient = useQueryClient()
const [pickerOpen, setPickerOpen] = useState(false)
const [configService, setConfigService] = useState<SelectedService | null>(null)
const queryKey = [ESTIMATE_ROUTES.SERVICES, estimateId]
const { data, isLoading } = useQuery({
queryKey,
queryFn: async () => {
const res = await api.estimates.listServices(estimateId)
return ((res as any)?.data ?? []) as ServiceLine[]
},
})
const items: ServiceLine[] = data ?? []
const invalidate = () => queryClient.invalidateQueries({ queryKey })
const updateMutation = useMutation({
mutationFn: ({ lineId, payload }: { lineId: string; payload: { rate?: string; quantity?: number } }) =>
api.estimates.updateService(estimateId, lineId, payload),
onSuccess: () => {
toast.success("Service updated")
invalidate()
},
onError: () => toast.error("Failed to update service"),
})
const removeMutation = useMutation({
mutationFn: (lineId: string) => api.estimates.removeService(estimateId, lineId),
onSuccess: invalidate,
onError: () => toast.error("Failed to remove service"),
})
const handlePickerConfirm = (rows: any[]) => {
const row = rows[0]
if (!row) return
setConfigService({
id: row.id,
labor_name: row.labor_name ?? row.name,
selling_price: row.selling_price,
})
}
const getDisplayName = (item: ServiceLine) =>
item.labor_name ?? item.title ?? `Service #${item.service_id ?? item.id}`
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Services</CardTitle>
<Button size="sm" variant="outline" onClick={() => setPickerOpen(true)}>
<Plus className="size-4 mr-1" />
Add Service
</Button>
</CardHeader>
<CardContent>
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
{!isLoading && items.length === 0 && (
<p className="text-sm text-muted-foreground">No services added yet.</p>
)}
{items.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>Service</TableHead>
<TableHead className="w-24">Qty</TableHead>
<TableHead className="w-28">Rate</TableHead>
<TableHead>Description</TableHead>
<TableHead className="w-12" />
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium">{getDisplayName(item)}</TableCell>
<TableCell>
<Input
type="number"
min={1}
defaultValue={item.quantity}
onBlur={(e) =>
updateMutation.mutate({
lineId: String(item.id),
payload: { quantity: Number(e.target.value) || 1 },
})
}
className="h-8 w-20"
/>
</TableCell>
<TableCell>
<Input
type="number"
min={0}
step={0.01}
defaultValue={Number(item.rate)}
onBlur={(e) =>
updateMutation.mutate({
lineId: String(item.id),
payload: { rate: e.target.value },
})
}
className="h-8 w-24"
/>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{item.description || "—"}
</TableCell>
<TableCell>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeMutation.mutate(String(item.id))}
disabled={removeMutation.isPending}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
{/* Step 1: Pick a service (single-select) */}
<ResourceSelectorDialog<ServicesClient>
title="Select Service"
open={pickerOpen}
onOpenChange={setPickerOpen}
selectionMode="single"
onConfirm={handlePickerConfirm}
crudProps={{
routeKey: SERVICE_ROUTES.INDEX,
getClient: (api) => api.services,
columns: [
serviceColumns.name,
serviceColumns.description,
serviceColumns.sellingPrice,
],
}}
/>
{/* Step 2: Configure and add the selected service */}
<Dialog open={!!configService} onOpenChange={(v) => { if (!v) setConfigService(null) }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-xl font-bold">Configure Service</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[70vh] px-1">
{configService && (
<EstimateServiceConfigForm
service={configService}
estimateId={estimateId}
onSuccess={() => {
setConfigService(null)
invalidate()
}}
onCancel={() => setConfigService(null)}
/>
)}
</ScrollArea>
</DialogContent>
</Dialog>
</Card>
)
}

View File

@ -6,13 +6,21 @@ import { useRouter } from 'next/dist/client/components/navigation';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/shared/components/ui/dropdown-menu' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/shared/components/ui/dropdown-menu'
import { Button } from '@/shared/components/ui/button' import { Button } from '@/shared/components/ui/button'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Ellipsis, Pencil, Trash2 } from 'lucide-react'; import { Ellipsis, Pencil, Printer, Trash2 } from 'lucide-react';
import { useDocumentPrint } from '@/shared/hooks/use-document-print';
export default function JobCardDropdown({ id }: { id: string }) { export default function JobCardDropdown({ id }: { id: string }) {
const router = useRouter(); const router = useRouter();
const { print, isPrinting } = useDocumentPrint()
const handleEdit = () => { const handleEdit = () => {
router.push(`/sales/job-cards/${id}/edit`) router.push(`/sales/job-cards/${id}/edit`)
} }
const handlePrint = () => {
print("job_card", id, "print")
}
const handleDelete = async () => { const handleDelete = async () => {
const confirmed = await confirm({ const confirmed = await confirm({
title: "Delete Job Card", title: "Delete Job Card",
@ -45,6 +53,10 @@ export default function JobCardDropdown({ id }: { id: string }) {
<Pencil className="size-4" /> <Pencil className="size-4" />
Edit Edit
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={handlePrint} disabled={isPrinting}>
<Printer className="size-4" />
{isPrinting ? "Printing..." : "Print"}
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={handleDelete}> <DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" /> <Trash2 className="size-4" />

View File

@ -190,7 +190,7 @@ export function JobCardFilterFields() {
name="customer_id" name="customer_id"
label="Customer" label="Customer"
queryKey={[CUSTOMER_ROUTES.INDEX]} queryKey={[CUSTOMER_ROUTES.INDEX]}
listFn={() => api.customers.list({ per_page: 100 })} listFn={() => api.customers.list( )}
mapOption={customerMapOption} mapOption={customerMapOption}
placeholder="All customers" placeholder="All customers"
/> />
@ -199,7 +199,7 @@ export function JobCardFilterFields() {
name="vehicle_id" name="vehicle_id"
label="Vehicle" label="Vehicle"
queryKey={[VEHICLE_ROUTES.INDEX]} queryKey={[VEHICLE_ROUTES.INDEX]}
listFn={() => api.vehicles.list({ per_page: 100 })} listFn={() => api.vehicles.list( )}
mapOption={vehicleMapOption} mapOption={vehicleMapOption}
placeholder="All vehicles" placeholder="All vehicles"
/> />
@ -208,7 +208,7 @@ export function JobCardFilterFields() {
name="insurer_id" name="insurer_id"
label="Insurance Type" label="Insurance Type"
queryKey={[INSURANCE_TYPE_ROUTES.INDEX]} queryKey={[INSURANCE_TYPE_ROUTES.INDEX]}
listFn={() => api.insuranceTypes.list({ per_page: 100 })} listFn={() => api.insuranceTypes.list( )}
mapOption={(item: any) => ({ value: String(item.id), label: item.title ?? item.name ?? `#${item.id}` })} mapOption={(item: any) => ({ value: String(item.id), label: item.title ?? item.name ?? `#${item.id}` })}
placeholder="All insurers" placeholder="All insurers"
/> />
@ -217,7 +217,7 @@ export function JobCardFilterFields() {
name="department_id" name="department_id"
label="Department" label="Department"
queryKey={[DEPARTMENT_ROUTES.INDEX]} queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.departments.list({ per_page: 100 })} listFn={() => api.departments.list( )}
mapOption={(item: any) => ({ value: String(item.id), label: item.title ?? item.name ?? `#${item.id}` })} mapOption={(item: any) => ({ value: String(item.id), label: item.title ?? item.name ?? `#${item.id}` })}
placeholder="All departments" placeholder="All departments"
/> />
@ -229,7 +229,7 @@ export function JobCardFilterFields() {
name="sales_person_id" name="sales_person_id"
label="Sales Person" label="Sales Person"
queryKey={[EMPLOYEE_ROUTES.INDEX, "sales_person"]} queryKey={[EMPLOYEE_ROUTES.INDEX, "sales_person"]}
listFn={() => api.employees.list({ per_page: 100 })} listFn={() => api.employees.list( )}
mapOption={employeeMapOption} mapOption={employeeMapOption}
placeholder="All sales persons" placeholder="All sales persons"
/> />
@ -238,7 +238,7 @@ export function JobCardFilterFields() {
name="primary_technician_id" name="primary_technician_id"
label="Primary Technician" label="Primary Technician"
queryKey={[EMPLOYEE_ROUTES.INDEX, "technician"]} queryKey={[EMPLOYEE_ROUTES.INDEX, "technician"]}
listFn={() => api.employees.list({ per_page: 100 })} listFn={() => api.employees.list( )}
mapOption={employeeMapOption} mapOption={employeeMapOption}
placeholder="All technicians" placeholder="All technicians"
/> />
@ -247,7 +247,7 @@ export function JobCardFilterFields() {
name="service_writer_id" name="service_writer_id"
label="Service Writer" label="Service Writer"
queryKey={[EMPLOYEE_ROUTES.INDEX, "service_writer"]} queryKey={[EMPLOYEE_ROUTES.INDEX, "service_writer"]}
listFn={() => api.employees.list({ per_page: 100 })} listFn={() => api.employees.list( )}
mapOption={employeeMapOption} mapOption={employeeMapOption}
placeholder="All service writers" placeholder="All service writers"
/> />

View File

@ -33,6 +33,7 @@ import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-fie
import { RhfLabelPickerField } from "@/modules/labels/rhf-label-picker-field" import { RhfLabelPickerField } from "@/modules/labels/rhf-label-picker-field"
import { RhfCustomerRemarksField } from "@/modules/estimates/rhf-customer-remarks-field" import { RhfCustomerRemarksField } from "@/modules/estimates/rhf-customer-remarks-field"
import { InsuranceTypeCrudDialog } from "./insurance-type-crud-dialog" import { InsuranceTypeCrudDialog } from "./insurance-type-crud-dialog"
import { RhfEmployeeSelectField } from "../employees/rhf-employee-select-field"
// ── Props ── // ── Props ──
@ -282,22 +283,18 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField <RhfEmployeeSelectField
showCreate
name="service_writer" name="service_writer"
label="Service Writer" label="Service Writer"
placeholder="Select service writer" placeholder="Select service writer"
queryKey={[EMPLOYEE_ROUTES.INDEX]}
listFn={() => api.employees.list()}
mapOption={mapEmployeeOption}
{...STORE_OBJECT} {...STORE_OBJECT}
/> />
<RhfAsyncSelectField <RhfEmployeeSelectField
showCreate
name="primary_technician" name="primary_technician"
label="Primary Technician" label="Primary Technician"
placeholder="Select technician" placeholder="Select technician"
queryKey={[EMPLOYEE_ROUTES.INDEX]}
listFn={() => api.employees.list()}
mapOption={mapEmployeeOption}
{...STORE_OBJECT} {...STORE_OBJECT}
/> />
</div> </div>

View File

@ -25,6 +25,12 @@ export type ResourceSelectorDialogProps<TClient extends ResourcePageClient> = {
crudProps: Omit<CrudResourceProps<TClient>, "render" | "tableProps"> crudProps: Omit<CrudResourceProps<TClient>, "render" | "tableProps">
/** Optional rowKey for selection identity. Defaults to "id" */ /** Optional rowKey for selection identity. Defaults to "id" */
rowKey?: keyof ResourceItem<TClient> rowKey?: keyof ResourceItem<TClient>
/**
* Selection mode.
* - "multi" (default): checkbox multi-select + Confirm button
* - "single": row click immediately fires onConfirm with the clicked row
*/
selectionMode?: "single" | "multi"
} }
export function ResourceSelectorDialog<TClient extends ResourcePageClient>({ export function ResourceSelectorDialog<TClient extends ResourcePageClient>({
@ -34,6 +40,7 @@ export function ResourceSelectorDialog<TClient extends ResourcePageClient>({
onConfirm, onConfirm,
crudProps, crudProps,
rowKey, rowKey,
selectionMode = "multi",
}: ResourceSelectorDialogProps<TClient>) { }: ResourceSelectorDialogProps<TClient>) {
type TItem = ResourceItem<TClient> type TItem = ResourceItem<TClient>
@ -58,6 +65,11 @@ export function ResourceSelectorDialog<TClient extends ResourcePageClient>({
onOpenChange(false) onOpenChange(false)
} }
const handleRowClick = (row: TItem) => {
onConfirm([row])
onOpenChange(false)
}
return ( return (
<Dialog open={open} onOpenChange={(v) => { if (!v) handleCancel() }}> <Dialog open={open} onOpenChange={(v) => { if (!v) handleCancel() }}>
<DialogContent className="min-w-4xl max-h-[90vh] flex flex-col"> <DialogContent className="min-w-4xl max-h-[90vh] flex flex-col">
@ -67,6 +79,12 @@ export function ResourceSelectorDialog<TClient extends ResourcePageClient>({
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
<Card className="rounded-none border-0 shadow-none"> <Card className="rounded-none border-0 shadow-none">
<CardContent className="p-0"> <CardContent className="p-0">
{selectionMode === "single" ? (
<CrudResource<TClient>
{...crudProps}
onRowClick={handleRowClick}
/>
) : (
<CrudResource<TClient> <CrudResource<TClient>
{...crudProps} {...crudProps}
tableProps={{ tableProps={{
@ -76,9 +94,11 @@ export function ResourceSelectorDialog<TClient extends ResourcePageClient>({
}, },
}} }}
/> />
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{selectionMode === "multi" && (
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={handleCancel}> <Button variant="outline" onClick={handleCancel}>
Cancel Cancel
@ -87,6 +107,7 @@ export function ResourceSelectorDialog<TClient extends ResourcePageClient>({
Confirm{count > 0 ? ` (${count})` : ""} Confirm{count > 0 ? ` (${count})` : ""}
</Button> </Button>
</DialogFooter> </DialogFooter>
)}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) )

View File

@ -0,0 +1,59 @@
"use client"
import { useState } from "react"
import { useAuthApi } from "../useApi"
import { toast } from "sonner"
import type { DocumentPrintMode, DocumentPrintType } from "@garage/api"
interface UseDocumentPrintOptions {
onSuccess?: (blob: Blob) => void
onError?: (error: Error) => void
}
export function useDocumentPrint(options?: UseDocumentPrintOptions) {
const api = useAuthApi()
const [isPrinting, setIsPrinting] = useState(false)
const print = async (
type: DocumentPrintType,
id: string | number,
mode: DocumentPrintMode = "print",
filename?: string,
) => {
setIsPrinting(true)
try {
const blob = await api.documentPrint.print(type, id, mode)
const url = URL.createObjectURL(blob)
if (mode === "print") {
const win = window.open(url, "_blank")
if (win) {
win.addEventListener("load", () => {
win.print()
URL.revokeObjectURL(url)
})
} else {
URL.revokeObjectURL(url)
}
} else {
const a = document.createElement("a")
a.href = url
a.download = filename ?? `${type}-${id}.pdf`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
options?.onSuccess?.(blob)
} catch (error) {
toast.error("Failed to print document")
options?.onError?.(error as Error)
} finally {
setIsPrinting(false)
}
}
return { print, isPrinting }
}

View File

@ -19740,17 +19740,61 @@
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
"services_status": { "estimate_services": {
"type": "string" "type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "integer"
}, },
"parts_status": { "status": {
"type": "string" "type": "string"
}
}
}
}, },
"inspections_status": { "estimate_parts": {
"type": "string" "type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "integer"
}, },
"expense_items_status": { "status": {
"type": "string" "type": "string"
}
}
}
},
"estimate_inspections": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"status": {
"type": "string"
}
}
}
},
"estimate_expense_items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"status": {
"type": "string"
}
}
}
}, },
"authorisation_method": { "authorisation_method": {
"type": "string" "type": "string"
@ -19761,10 +19805,34 @@
} }
}, },
"example": { "example": {
"services_status": "accepted", "estimate_services": [
"parts_status": "accepted", {
"inspections_status": "accepted", "id": 11,
"expense_items_status": "accepted", "status": "accepted"
},
{
"id": 12,
"status": "rejected"
}
],
"estimate_parts": [
{
"id": 21,
"status": "accepted"
}
],
"estimate_inspections": [
{
"id": 31,
"status": "pending"
}
],
"estimate_expense_items": [
{
"id": 41,
"status": "accepted"
}
],
"authorisation_method": "in_person", "authorisation_method": "in_person",
"employee_id": 1 "employee_id": 1
} }
@ -19851,15 +19919,15 @@
"authorisation_date": "2026-04-13", "authorisation_date": "2026-04-13",
"authorisation_time": "14:30:00", "authorisation_time": "14:30:00",
"authorisation_method": "in_person", "authorisation_method": "in_person",
"total_accepted": 8 "total_accepted": 5
}, },
"updated_counts": { "updated_counts": {
"services": 3, "services": 2,
"parts": 2, "parts": 1,
"inspections": 1, "inspections": 1,
"expense_items": 2 "expense_items": 1
}, },
"histories_count": 9 "histories_count": 6
} }
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"info": { "info": {
"_postman_id": "90943e73-b99e-468b-b10b-d3a02f751da9", "_postman_id": "351b2d30-cb82-4904-9a47-0d356181a8ea",
"name": "Reparee Collection", "name": "Reparee Collection",
"description": "Auto-generated from OpenAPI spec. Import storage/app/openapi-default.json for the full schema.", "description": "Auto-generated from OpenAPI spec. Import storage/app/openapi-default.json for the full schema.",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
@ -13384,7 +13384,7 @@
], ],
"body": { "body": {
"mode": "raw", "mode": "raw",
"raw": "{\n \"services_status\": \"accepted\",\n \"parts_status\": \"accepted\",\n \"inspections_status\": \"accepted\",\n \"expense_items_status\": \"accepted\",\n \"authorisation_method\": \"in_person\",\n \"employee_id\": 1\n}", "raw": "{\n \"estimate_services\": [\n {\n \"id\": 11,\n \"status\": \"accepted\"\n },\n {\n \"id\": 12,\n \"status\": \"rejected\"\n }\n ],\n \"estimate_parts\": [\n {\n \"id\": 21,\n \"status\": \"accepted\"\n }\n ],\n \"estimate_inspections\": [\n {\n \"id\": 31,\n \"status\": \"pending\"\n }\n ],\n \"estimate_expense_items\": [\n {\n \"id\": 41,\n \"status\": \"accepted\"\n }\n ],\n \"authorisation_method\": \"in_person\",\n \"employee_id\": 1\n}",
"options": { "options": {
"raw": { "raw": {
"language": "json" "language": "json"
@ -13431,7 +13431,7 @@
], ],
"body": { "body": {
"mode": "raw", "mode": "raw",
"raw": "{\n \"services_status\": \"accepted\",\n \"parts_status\": \"accepted\",\n \"inspections_status\": \"accepted\",\n \"expense_items_status\": \"accepted\",\n \"authorisation_method\": \"in_person\",\n \"employee_id\": 1\n}", "raw": "{\n \"estimate_services\": [\n {\n \"id\": 11,\n \"status\": \"accepted\"\n },\n {\n \"id\": 12,\n \"status\": \"rejected\"\n }\n ],\n \"estimate_parts\": [\n {\n \"id\": 21,\n \"status\": \"accepted\"\n }\n ],\n \"estimate_inspections\": [\n {\n \"id\": 31,\n \"status\": \"pending\"\n }\n ],\n \"estimate_expense_items\": [\n {\n \"id\": 41,\n \"status\": \"accepted\"\n }\n ],\n \"authorisation_method\": \"in_person\",\n \"employee_id\": 1\n}",
"options": { "options": {
"raw": { "raw": {
"language": "json" "language": "json"
@ -13461,7 +13461,7 @@
} }
], ],
"cookie": [], "cookie": [],
"body": "{\n \"message\": \"Estimate authorisation stored successfully\",\n \"data\": {\n \"estimate\": {\n \"id\": 1,\n \"is_authorisation\": true,\n \"authorisation_date\": \"2026-04-13\",\n \"authorisation_time\": \"14:30:00\",\n \"authorisation_method\": \"in_person\",\n \"total_accepted\": 8\n },\n \"updated_counts\": {\n \"services\": 3,\n \"parts\": 2,\n \"inspections\": 1,\n \"expense_items\": 2\n },\n \"histories_count\": 9\n }\n}" "body": "{\n \"message\": \"Estimate authorisation stored successfully\",\n \"data\": {\n \"estimate\": {\n \"id\": 1,\n \"is_authorisation\": true,\n \"authorisation_date\": \"2026-04-13\",\n \"authorisation_time\": \"14:30:00\",\n \"authorisation_method\": \"in_person\",\n \"total_accepted\": 5\n },\n \"updated_counts\": {\n \"services\": 2,\n \"parts\": 1,\n \"inspections\": 1,\n \"expense_items\": 1\n },\n \"histories_count\": 6\n }\n}"
} }
] ]
}, },

View File

@ -56,6 +56,7 @@ import { ConfigurationsClient } from "./clients/configurations"
import { AutoGenerateClient } from "./clients/auto-generate" import { AutoGenerateClient } from "./clients/auto-generate"
import { ExpenseItemsClient } from "./clients/expense-items" import { ExpenseItemsClient } from "./clients/expense-items"
import { InventoryCategoriesClient } from "./clients/inventory-categories" import { InventoryCategoriesClient } from "./clients/inventory-categories"
import { DocumentPrintClient } from "./clients/document-print"
export function createApi(options?: ApiClientOptions) { export function createApi(options?: ApiClientOptions) {
return { return {
@ -116,6 +117,7 @@ export function createApi(options?: ApiClientOptions) {
autoGenerate: new AutoGenerateClient(undefined, options), autoGenerate: new AutoGenerateClient(undefined, options),
expenseItems: new ExpenseItemsClient(undefined, options), expenseItems: new ExpenseItemsClient(undefined, options),
inventoryCategories: new InventoryCategoriesClient(undefined, options), inventoryCategories: new InventoryCategoriesClient(undefined, options),
documentPrint: new DocumentPrintClient(undefined, options),
} }
} }

View File

@ -0,0 +1,23 @@
import { ApiClient, type ApiClientOptions } from "../infra/client"
import type { ApiPath } from "../infra/types"
export const DOCUMENT_PRINT_ROUTES = {
INDEX: "/api/document-print",
} as const satisfies Record<string, ApiPath>
export type DocumentPrintType = "job_card" | "invoice" | "estimate" | "purchase_order" | "bill" | string
export type DocumentPrintMode = "print" | "download"
export class DocumentPrintClient extends ApiClient {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions)
}
async print(type: DocumentPrintType, id: number | string, mode: DocumentPrintMode = "print"): Promise<Blob> {
return this.fetchBlob(DOCUMENT_PRINT_ROUTES.INDEX, {
method: "POST",
body: { type, id: Number(id), mode },
})
}
}

View File

@ -5,6 +5,13 @@ import type { ApiPath } from "../infra/types"
export const ESTIMATE_ROUTES = { export const ESTIMATE_ROUTES = {
INDEX: "/api/estimates", INDEX: "/api/estimates",
BY_ID: "/api/estimates/{id}", BY_ID: "/api/estimates/{id}",
SERVICES: "/api/estimate/{id}/services",
SERVICE_BY_ID: "/api/estimate/{id}/services/{service_id}",
PARTS: "/api/estimate/{id}/parts",
PART_BY_ID: "/api/estimate/{id}/parts/{part_id}",
EXPENSE_ITEMS: "/api/estimate/{id}/expense-items",
EXPENSE_ITEM_BY_ID: "/api/estimate/{id}/expense-items/{expense_item_id}",
STORE_AUTHORISATION: "/api/estimates/{id}/store-authorisation",
} as const satisfies Record<string, ApiPath> } as const satisfies Record<string, ApiPath>
export class EstimatesClient extends CrudClient< export class EstimatesClient extends CrudClient<
@ -21,4 +28,67 @@ export class EstimatesClient extends CrudClient<
const data = await this.get(ESTIMATE_ROUTES.INDEX, { query: { id } } as never) const data = await this.get(ESTIMATE_ROUTES.INDEX, { query: { id } } as never)
return {...data, data: (data as any)?.data?.[0] ?? null } return {...data, data: (data as any)?.data?.[0] ?? null }
} }
// ── Estimate Services ──
async listServices(estimateId: string) {
return this.get(ESTIMATE_ROUTES.SERVICES, { params: { id: estimateId } } as never)
}
async addService(estimateId: string, payload: { service_id?: number; rate_type?: string; quantity?: number; rate?: string; description?: string; labor_rate_id?: number; working_hours?: string | number; labor_hours?: string | number; tax?: string; chart_of_account?: string; department_id?: number }) {
return this.post(ESTIMATE_ROUTES.SERVICES, payload as never, { params: { id: estimateId } } as never)
}
async updateService(estimateId: string, serviceId: string, payload: { rate?: string; quantity?: number }) {
return this.put(ESTIMATE_ROUTES.SERVICE_BY_ID, payload as never, { params: { id: estimateId, service_id: serviceId } } as never)
}
async removeService(estimateId: string, serviceId: string) {
return this.delete(ESTIMATE_ROUTES.SERVICE_BY_ID, { params: { id: estimateId, service_id: serviceId } } as never)
}
// ── Estimate Parts ──
async listParts(estimateId: string) {
return this.get(ESTIMATE_ROUTES.PARTS, { params: { id: estimateId } } as never)
}
async addPart(estimateId: string, payload: { part_id?: number; quantity?: number; rate?: string; description?: string }) {
return this.post(ESTIMATE_ROUTES.PARTS, payload as never, { params: { id: estimateId } } as never)
}
async updatePart(estimateId: string, partId: string, payload: { quantity?: number }) {
return this.put(ESTIMATE_ROUTES.PART_BY_ID, payload as never, { params: { id: estimateId, part_id: partId } } as never)
}
async removePart(estimateId: string, partId: string) {
return this.delete(ESTIMATE_ROUTES.PART_BY_ID, { params: { id: estimateId, part_id: partId } } as never)
}
// ── Estimate Expense Items ──
async listExpenseItems(estimateId: string) {
return this.get(ESTIMATE_ROUTES.EXPENSE_ITEMS, { params: { id: estimateId } } as never)
}
async addExpenseItem(estimateId: string, payload: { expense_item_id?: number; quantity?: number; rate?: string; description?: string }) {
return this.post(ESTIMATE_ROUTES.EXPENSE_ITEMS, payload as never, { params: { id: estimateId } } as never)
}
async updateExpenseItem(estimateId: string, expenseItemId: string, payload: { quantity?: number }) {
return this.put(ESTIMATE_ROUTES.EXPENSE_ITEM_BY_ID, payload as never, { params: { id: estimateId, expense_item_id: expenseItemId } } as never)
}
async removeExpenseItem(estimateId: string, expenseItemId: string) {
return this.delete(ESTIMATE_ROUTES.EXPENSE_ITEM_BY_ID, { params: { id: estimateId, expense_item_id: expenseItemId } } as never)
}
// ── Authorisation ──
async storeAuthorisation(estimateId: string, payload: {
estimate_services?: { id: number; status: string }[]
estimate_parts?: { id: number; status: string }[]
estimate_inspections?: { id: number; status: string }[]
estimate_expense_items?: { id: number; status: string }[]
authorisation_method?: string
employee_id?: number
}) {
return this.post(ESTIMATE_ROUTES.STORE_AUTHORISATION, payload as never, { params: { id: estimateId } } as never)
}
} }

View File

@ -38,6 +38,7 @@ export { InvoicesClient, INVOICE_ROUTES } from "./invoices"
export { HomeClient, HOME_ROUTES, type HomeDashboardResponse } from "./home" export { HomeClient, HOME_ROUTES, type HomeDashboardResponse } from "./home"
export { BillsClient, BILL_ROUTES } from "./bills" export { BillsClient, BILL_ROUTES } from "./bills"
export { ReasonsClient, REASON_ROUTES } from "./reasons" export { ReasonsClient, REASON_ROUTES } from "./reasons"
export { DocumentPrintClient, DOCUMENT_PRINT_ROUTES, type DocumentPrintType, type DocumentPrintMode } from "./document-print"
export { HolidaysClient, HOLIDAY_ROUTES } from "./holidays" export { HolidaysClient, HOLIDAY_ROUTES } from "./holidays"
export { MakeAndModelsClient, MAKE_AND_MODEL_ROUTES } from "./make-and-models" export { MakeAndModelsClient, MAKE_AND_MODEL_ROUTES } from "./make-and-models"
export { TimeSheetsClient, TIME_SHEET_ROUTES } from "./time-sheets" export { TimeSheetsClient, TIME_SHEET_ROUTES } from "./time-sheets"

View File

@ -109,7 +109,7 @@ export const SellRatesTaxInclusive = ['Tax Inclusive', 'Tax Exclusive'] as const
export type SellRatesTaxInclusive = (typeof SellRatesTaxInclusive)[number]; export type SellRatesTaxInclusive = (typeof SellRatesTaxInclusive)[number];
// Tables // Tables
export const Tables= ['bills', 'expenses', 'invoices', 'job_cards', 'credit_notes', 'vendor_credits'] as const; export const Tables= ['bills', 'expenses', 'invoices', 'job_cards', 'credit_notes', 'vendor_credits', 'estimates'] as const;
export type Tables = (typeof Tables)[number]; export type Tables = (typeof Tables)[number];
export const GiveDiscounts = ['no', 'line_item_level', 'transaction_level'] as const; export const GiveDiscounts = ['no', 'line_item_level', 'transaction_level'] as const;

View File

@ -12947,19 +12947,55 @@ export interface paths {
content: { content: {
/** /**
* @example { * @example {
* "services_status": "accepted", * "estimate_services": [
* "parts_status": "accepted", * {
* "inspections_status": "accepted", * "id": 11,
* "expense_items_status": "accepted", * "status": "accepted"
* },
* {
* "id": 12,
* "status": "rejected"
* }
* ],
* "estimate_parts": [
* {
* "id": 21,
* "status": "accepted"
* }
* ],
* "estimate_inspections": [
* {
* "id": 31,
* "status": "pending"
* }
* ],
* "estimate_expense_items": [
* {
* "id": 41,
* "status": "accepted"
* }
* ],
* "authorisation_method": "in_person", * "authorisation_method": "in_person",
* "employee_id": 1 * "employee_id": 1
* } * }
*/ */
"application/json": { "application/json": {
services_status?: string; estimate_services?: {
parts_status?: string; id?: number;
inspections_status?: string; status?: string;
expense_items_status?: string; }[];
estimate_parts?: {
id?: number;
status?: string;
}[];
estimate_inspections?: {
id?: number;
status?: string;
}[];
estimate_expense_items?: {
id?: number;
status?: string;
}[];
authorisation_method?: string; authorisation_method?: string;
employee_id?: number; employee_id?: number;
}; };
@ -12982,15 +13018,15 @@ export interface paths {
* "authorisation_date": "2026-04-13", * "authorisation_date": "2026-04-13",
* "authorisation_time": "14:30:00", * "authorisation_time": "14:30:00",
* "authorisation_method": "in_person", * "authorisation_method": "in_person",
* "total_accepted": 8 * "total_accepted": 5
* }, * },
* "updated_counts": { * "updated_counts": {
* "services": 3, * "services": 2,
* "parts": 2, * "parts": 1,
* "inspections": 1, * "inspections": 1,
* "expense_items": 2 * "expense_items": 1
* }, * },
* "histories_count": 9 * "histories_count": 6
* } * }
* } * }
*/ */