fix build
This commit is contained in:
parent
020ffccfd6
commit
973149e974
40
.github/skills/crud-page/SKILL.md
vendored
40
.github/skills/crud-page/SKILL.md
vendored
@ -111,13 +111,44 @@ Create `apps/dashboard/app/(authenticated)/<section>/<feature>/page.tsx`:
|
||||
| Form component | `<PascalFeature>Form` | `JobCardForm` |
|
||||
| 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`
|
||||
- Use `toRelation(id, name)` to convert API data → form value
|
||||
- Use `toId(relation)` to convert form value → API payload
|
||||
- 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
|
||||
|
||||
@ -144,8 +175,11 @@ const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) =
|
||||
| `RhfTextareaField` | Multi-line text |
|
||||
| `RhfCheckboxField` | Boolean toggles |
|
||||
| `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 |
|
||||
| `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
|
||||
|
||||
|
||||
2
.github/skills/resource-filters/SKILL.md
vendored
2
.github/skills/resource-filters/SKILL.md
vendored
@ -147,7 +147,7 @@ export function FeatureFilterFields() {
|
||||
name="some_relation_id"
|
||||
label="Some Relation"
|
||||
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 })}
|
||||
placeholder="All"
|
||||
/>
|
||||
|
||||
315
.github/skills/resource-selector/SKILL.md
vendored
Normal file
315
.github/skills/resource-selector/SKILL.md
vendored
Normal 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" />`.
|
||||
@ -2,6 +2,7 @@ import { DashboardDetailsPage } from '@/base/components/layout/dashboard'
|
||||
import { getServerApi } from '@garage/api/server'
|
||||
import { EstimateActions } from '@/modules/estimates/estimate-actions'
|
||||
import { EstimateProvider } from '@/modules/estimates/estimate-context'
|
||||
import { CreateInvoiceFromEstimateButton } from '@/modules/estimates/create-invoice-from-estimate-button'
|
||||
import { FileTextIcon } from 'lucide-react'
|
||||
import React from 'react'
|
||||
|
||||
@ -20,7 +21,7 @@ export default async function layout(props: {
|
||||
: title
|
||||
|
||||
return (
|
||||
<EstimateProvider estimate={{ id, label: estimateLabel }}>
|
||||
<EstimateProvider estimate={{ id, label: estimateLabel, data: estimateData }}>
|
||||
<DashboardDetailsPage
|
||||
className="p-0 lg:p-0"
|
||||
icon={<FileTextIcon className="size-5" />}
|
||||
@ -29,7 +30,12 @@ export default async function layout(props: {
|
||||
estimateData?.date ? `Date: ${estimateData.date}` : undefined
|
||||
}
|
||||
backHref="/sales/estimates"
|
||||
actions={<EstimateActions estimateId={id} />}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<CreateInvoiceFromEstimateButton />
|
||||
<EstimateActions estimateId={id} />
|
||||
</div>
|
||||
}
|
||||
tabs={[
|
||||
{ href: `/sales/estimates/${id}`, label: 'Details' },
|
||||
]}
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import { getServerApi } from '@garage/api/server'
|
||||
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'
|
||||
|
||||
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 (
|
||||
<DashboardPage header={null}>
|
||||
<div className="grid gap-6">
|
||||
<EstimateGeneralInfo estimate={estimateData} />
|
||||
<EstimateServicesSection estimateId={id} />
|
||||
<EstimatePartsSection estimateId={id} />
|
||||
<EstimateExpenseItemsSection estimateId={id} />
|
||||
</div>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,8 +1,22 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
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 { 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 { EmployeeForm } from "./employee-form"
|
||||
|
||||
// ── Props ──
|
||||
|
||||
@ -16,6 +30,8 @@ export type RhfEmployeeSelectFieldProps<
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
/** Show a "+" button to create a new employee inline */
|
||||
showCreate?: boolean
|
||||
}
|
||||
|
||||
// ── Component ──
|
||||
@ -30,6 +46,7 @@ export function RhfEmployeeSelectField<
|
||||
required,
|
||||
disabled,
|
||||
placeholder = "Search by name or email...",
|
||||
showCreate,
|
||||
}: RhfEmployeeSelectFieldProps<TValues, TName>) {
|
||||
const { control } = useFormContext<TValues>()
|
||||
const {
|
||||
@ -37,13 +54,15 @@ export function RhfEmployeeSelectField<
|
||||
fieldState: { error },
|
||||
} = useController({ name, control, disabled })
|
||||
|
||||
return (
|
||||
<FieldShell
|
||||
label={label}
|
||||
error={error?.message}
|
||||
description={description}
|
||||
required={required}
|
||||
>
|
||||
const queryClient = useQueryClient()
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
||||
|
||||
const handleCreateSuccess = () => {
|
||||
queryClient.invalidateQueries({ queryKey: [EMPLOYEE_ROUTES.INDEX] })
|
||||
setIsCreateOpen(false)
|
||||
}
|
||||
|
||||
const combobox = (
|
||||
<EmployeeCombobox
|
||||
value={field.value}
|
||||
onValueChange={(emp) => {
|
||||
@ -55,6 +74,57 @@ export function RhfEmployeeSelectField<
|
||||
onBlur={field.onBlur}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -14,26 +14,197 @@ import {
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
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 { useMutation, useQuery } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
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 = {
|
||||
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) {
|
||||
const api = useAuthApi()
|
||||
const router = useRouter()
|
||||
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 () => {
|
||||
await api.estimates.destroy(estimateId)
|
||||
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 (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
@ -47,6 +218,10 @@ export function EstimateActions({ estimateId }: EstimateActionsProps) {
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={openAuthDialog}>
|
||||
<ShieldCheck className="size-4" />
|
||||
Store Authorisation
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
@ -54,6 +229,7 @@ export function EstimateActions({ estimateId }: EstimateActionsProps) {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||
<DialogContent className="min-w-xl">
|
||||
<DialogHeader>
|
||||
@ -70,6 +246,122 @@ export function EstimateActions({ estimateId }: EstimateActionsProps) {
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -2,9 +2,10 @@
|
||||
|
||||
import { createContext, useContext } from "react"
|
||||
|
||||
type EstimateContextValue = {
|
||||
export type EstimateContextValue = {
|
||||
id: string
|
||||
label: string
|
||||
data?: Record<string, any>
|
||||
}
|
||||
|
||||
const EstimateContext = createContext<EstimateContextValue | null>(null)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
155
apps/dashboard/modules/estimates/estimate-part-config-form.tsx
Normal file
155
apps/dashboard/modules/estimates/estimate-part-config-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
206
apps/dashboard/modules/estimates/estimate-parts-section.tsx
Normal file
206
apps/dashboard/modules/estimates/estimate-parts-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
219
apps/dashboard/modules/estimates/estimate-services-section.tsx
Normal file
219
apps/dashboard/modules/estimates/estimate-services-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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 { Button } from '@/shared/components/ui/button'
|
||||
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 }) {
|
||||
const router = useRouter();
|
||||
const { print, isPrinting } = useDocumentPrint()
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/sales/job-cards/${id}/edit`)
|
||||
}
|
||||
|
||||
const handlePrint = () => {
|
||||
print("job_card", id, "print")
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
const confirmed = await confirm({
|
||||
title: "Delete Job Card",
|
||||
@ -45,6 +53,10 @@ export default function JobCardDropdown({ id }: { id: string }) {
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handlePrint} disabled={isPrinting}>
|
||||
<Printer className="size-4" />
|
||||
{isPrinting ? "Printing..." : "Print"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="size-4" />
|
||||
|
||||
@ -190,7 +190,7 @@ export function JobCardFilterFields() {
|
||||
name="customer_id"
|
||||
label="Customer"
|
||||
queryKey={[CUSTOMER_ROUTES.INDEX]}
|
||||
listFn={() => api.customers.list({ per_page: 100 })}
|
||||
listFn={() => api.customers.list( )}
|
||||
mapOption={customerMapOption}
|
||||
placeholder="All customers"
|
||||
/>
|
||||
@ -199,7 +199,7 @@ export function JobCardFilterFields() {
|
||||
name="vehicle_id"
|
||||
label="Vehicle"
|
||||
queryKey={[VEHICLE_ROUTES.INDEX]}
|
||||
listFn={() => api.vehicles.list({ per_page: 100 })}
|
||||
listFn={() => api.vehicles.list( )}
|
||||
mapOption={vehicleMapOption}
|
||||
placeholder="All vehicles"
|
||||
/>
|
||||
@ -208,7 +208,7 @@ export function JobCardFilterFields() {
|
||||
name="insurer_id"
|
||||
label="Insurance Type"
|
||||
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}` })}
|
||||
placeholder="All insurers"
|
||||
/>
|
||||
@ -217,7 +217,7 @@ export function JobCardFilterFields() {
|
||||
name="department_id"
|
||||
label="Department"
|
||||
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}` })}
|
||||
placeholder="All departments"
|
||||
/>
|
||||
@ -229,7 +229,7 @@ export function JobCardFilterFields() {
|
||||
name="sales_person_id"
|
||||
label="Sales Person"
|
||||
queryKey={[EMPLOYEE_ROUTES.INDEX, "sales_person"]}
|
||||
listFn={() => api.employees.list({ per_page: 100 })}
|
||||
listFn={() => api.employees.list( )}
|
||||
mapOption={employeeMapOption}
|
||||
placeholder="All sales persons"
|
||||
/>
|
||||
@ -238,7 +238,7 @@ export function JobCardFilterFields() {
|
||||
name="primary_technician_id"
|
||||
label="Primary Technician"
|
||||
queryKey={[EMPLOYEE_ROUTES.INDEX, "technician"]}
|
||||
listFn={() => api.employees.list({ per_page: 100 })}
|
||||
listFn={() => api.employees.list( )}
|
||||
mapOption={employeeMapOption}
|
||||
placeholder="All technicians"
|
||||
/>
|
||||
@ -247,7 +247,7 @@ export function JobCardFilterFields() {
|
||||
name="service_writer_id"
|
||||
label="Service Writer"
|
||||
queryKey={[EMPLOYEE_ROUTES.INDEX, "service_writer"]}
|
||||
listFn={() => api.employees.list({ per_page: 100 })}
|
||||
listFn={() => api.employees.list( )}
|
||||
mapOption={employeeMapOption}
|
||||
placeholder="All service writers"
|
||||
/>
|
||||
|
||||
@ -33,6 +33,7 @@ import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-fie
|
||||
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"
|
||||
import { RhfEmployeeSelectField } from "../employees/rhf-employee-select-field"
|
||||
|
||||
// ── Props ──
|
||||
|
||||
@ -282,22 +283,18 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
<RhfEmployeeSelectField
|
||||
showCreate
|
||||
name="service_writer"
|
||||
label="Service Writer"
|
||||
placeholder="Select service writer"
|
||||
queryKey={[EMPLOYEE_ROUTES.INDEX]}
|
||||
listFn={() => api.employees.list()}
|
||||
mapOption={mapEmployeeOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
<RhfEmployeeSelectField
|
||||
showCreate
|
||||
name="primary_technician"
|
||||
label="Primary Technician"
|
||||
placeholder="Select technician"
|
||||
queryKey={[EMPLOYEE_ROUTES.INDEX]}
|
||||
listFn={() => api.employees.list()}
|
||||
mapOption={mapEmployeeOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -25,6 +25,12 @@ export type ResourceSelectorDialogProps<TClient extends ResourcePageClient> = {
|
||||
crudProps: Omit<CrudResourceProps<TClient>, "render" | "tableProps">
|
||||
/** Optional rowKey for selection identity. Defaults to "id" */
|
||||
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>({
|
||||
@ -34,6 +40,7 @@ export function ResourceSelectorDialog<TClient extends ResourcePageClient>({
|
||||
onConfirm,
|
||||
crudProps,
|
||||
rowKey,
|
||||
selectionMode = "multi",
|
||||
}: ResourceSelectorDialogProps<TClient>) {
|
||||
type TItem = ResourceItem<TClient>
|
||||
|
||||
@ -58,6 +65,11 @@ export function ResourceSelectorDialog<TClient extends ResourcePageClient>({
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const handleRowClick = (row: TItem) => {
|
||||
onConfirm([row])
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) handleCancel() }}>
|
||||
<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">
|
||||
<Card className="rounded-none border-0 shadow-none">
|
||||
<CardContent className="p-0">
|
||||
{selectionMode === "single" ? (
|
||||
<CrudResource<TClient>
|
||||
{...crudProps}
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
) : (
|
||||
<CrudResource<TClient>
|
||||
{...crudProps}
|
||||
tableProps={{
|
||||
@ -76,9 +94,11 @@ export function ResourceSelectorDialog<TClient extends ResourcePageClient>({
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{selectionMode === "multi" && (
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
@ -87,6 +107,7 @@ export function ResourceSelectorDialog<TClient extends ResourcePageClient>({
|
||||
Confirm{count > 0 ? ` (${count})` : ""}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
59
apps/dashboard/shared/hooks/use-document-print.ts
Normal file
59
apps/dashboard/shared/hooks/use-document-print.ts
Normal 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 }
|
||||
}
|
||||
@ -19740,17 +19740,61 @@
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"services_status": {
|
||||
"type": "string"
|
||||
"estimate_services": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"parts_status": {
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"inspections_status": {
|
||||
"type": "string"
|
||||
"estimate_parts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"expense_items_status": {
|
||||
"status": {
|
||||
"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": {
|
||||
"type": "string"
|
||||
@ -19761,10 +19805,34 @@
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"services_status": "accepted",
|
||||
"parts_status": "accepted",
|
||||
"inspections_status": "accepted",
|
||||
"expense_items_status": "accepted",
|
||||
"estimate_services": [
|
||||
{
|
||||
"id": 11,
|
||||
"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",
|
||||
"employee_id": 1
|
||||
}
|
||||
@ -19851,15 +19919,15 @@
|
||||
"authorisation_date": "2026-04-13",
|
||||
"authorisation_time": "14:30:00",
|
||||
"authorisation_method": "in_person",
|
||||
"total_accepted": 8
|
||||
"total_accepted": 5
|
||||
},
|
||||
"updated_counts": {
|
||||
"services": 3,
|
||||
"parts": 2,
|
||||
"services": 2,
|
||||
"parts": 1,
|
||||
"inspections": 1,
|
||||
"expense_items": 2
|
||||
"expense_items": 1
|
||||
},
|
||||
"histories_count": 9
|
||||
"histories_count": 6
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "90943e73-b99e-468b-b10b-d3a02f751da9",
|
||||
"_postman_id": "351b2d30-cb82-4904-9a47-0d356181a8ea",
|
||||
"name": "Reparee Collection",
|
||||
"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",
|
||||
@ -13384,7 +13384,7 @@
|
||||
],
|
||||
"body": {
|
||||
"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": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
@ -13431,7 +13431,7 @@
|
||||
],
|
||||
"body": {
|
||||
"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": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
@ -13461,7 +13461,7 @@
|
||||
}
|
||||
],
|
||||
"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}"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@ -56,6 +56,7 @@ import { ConfigurationsClient } from "./clients/configurations"
|
||||
import { AutoGenerateClient } from "./clients/auto-generate"
|
||||
import { ExpenseItemsClient } from "./clients/expense-items"
|
||||
import { InventoryCategoriesClient } from "./clients/inventory-categories"
|
||||
import { DocumentPrintClient } from "./clients/document-print"
|
||||
|
||||
export function createApi(options?: ApiClientOptions) {
|
||||
return {
|
||||
@ -116,6 +117,7 @@ export function createApi(options?: ApiClientOptions) {
|
||||
autoGenerate: new AutoGenerateClient(undefined, options),
|
||||
expenseItems: new ExpenseItemsClient(undefined, options),
|
||||
inventoryCategories: new InventoryCategoriesClient(undefined, options),
|
||||
documentPrint: new DocumentPrintClient(undefined, options),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
23
packages/api/src/clients/document-print.ts
Normal file
23
packages/api/src/clients/document-print.ts
Normal 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 },
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,13 @@ import type { ApiPath } from "../infra/types"
|
||||
export const ESTIMATE_ROUTES = {
|
||||
INDEX: "/api/estimates",
|
||||
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>
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,6 +38,7 @@ export { InvoicesClient, INVOICE_ROUTES } from "./invoices"
|
||||
export { HomeClient, HOME_ROUTES, type HomeDashboardResponse } from "./home"
|
||||
export { BillsClient, BILL_ROUTES } from "./bills"
|
||||
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 { MakeAndModelsClient, MAKE_AND_MODEL_ROUTES } from "./make-and-models"
|
||||
export { TimeSheetsClient, TIME_SHEET_ROUTES } from "./time-sheets"
|
||||
|
||||
@ -109,7 +109,7 @@ export const SellRatesTaxInclusive = ['Tax Inclusive', 'Tax Exclusive'] as const
|
||||
export type SellRatesTaxInclusive = (typeof SellRatesTaxInclusive)[number];
|
||||
// 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 const GiveDiscounts = ['no', 'line_item_level', 'transaction_level'] as const;
|
||||
|
||||
@ -12947,19 +12947,55 @@ export interface paths {
|
||||
content: {
|
||||
/**
|
||||
* @example {
|
||||
* "services_status": "accepted",
|
||||
* "parts_status": "accepted",
|
||||
* "inspections_status": "accepted",
|
||||
* "expense_items_status": "accepted",
|
||||
* "estimate_services": [
|
||||
* {
|
||||
* "id": 11,
|
||||
* "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",
|
||||
* "employee_id": 1
|
||||
* }
|
||||
*/
|
||||
"application/json": {
|
||||
services_status?: string;
|
||||
parts_status?: string;
|
||||
inspections_status?: string;
|
||||
expense_items_status?: string;
|
||||
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;
|
||||
};
|
||||
@ -12982,15 +13018,15 @@ export interface paths {
|
||||
* "authorisation_date": "2026-04-13",
|
||||
* "authorisation_time": "14:30:00",
|
||||
* "authorisation_method": "in_person",
|
||||
* "total_accepted": 8
|
||||
* "total_accepted": 5
|
||||
* },
|
||||
* "updated_counts": {
|
||||
* "services": 3,
|
||||
* "parts": 2,
|
||||
* "services": 2,
|
||||
* "parts": 1,
|
||||
* "inspections": 1,
|
||||
* "expense_items": 2
|
||||
* "expense_items": 1
|
||||
* },
|
||||
* "histories_count": 9
|
||||
* "histories_count": 6
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user