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` |
|
| Form component | `<PascalFeature>Form` | `JobCardForm` |
|
||||||
| Page component | `<PascalFeature>Page` (default export) | `JobCardsPage` |
|
| Page component | `<PascalFeature>Page` (default export) | `JobCardsPage` |
|
||||||
|
|
||||||
### Relation Fields (Foreign Keys)
|
### Relation Fields — Choosing the Right Component
|
||||||
|
|
||||||
|
**Two patterns exist. Pick the right one before building any relational field.**
|
||||||
|
|
||||||
|
#### Simple FK → `RhfAsyncSelectField`
|
||||||
|
|
||||||
|
Use for single-record foreign keys within the same domain or to a simple lookup/reference entity.
|
||||||
|
|
||||||
|
Examples: invoice → customer, bill → vendor, part → category, service → unit type, job card → vehicle, PO → department, any `*_type` or `*_terms` relation.
|
||||||
|
|
||||||
- Stored in form as `{ value: string, label: string } | null`
|
- Stored in form as `{ value: string, label: string } | null`
|
||||||
- Use `toRelation(id, name)` to convert API data → form value
|
- Use `toRelation(id, name)` to convert API data → form value
|
||||||
- Use `toId(relation)` to convert form value → API payload
|
- Use `toId(relation)` to convert form value → API payload
|
||||||
- Schema uses `relationFieldSchema = z.object({ value: z.string(), label: z.string() }).nullable()`
|
- Schema uses `relationFieldSchema = z.object({ value: z.string(), label: z.string() }).nullable()`
|
||||||
- Rendered with `<RhfAsyncSelectField>` (fetches options via React Query)
|
|
||||||
|
#### Cross-Domain Line Items → `RhfResourceField` (resource-selector skill)
|
||||||
|
|
||||||
|
Use when the relationship involves a **join table with extra fields** (quantity, rate, description) or the related items are from a **fully separate domain with its own CRUD page** and multiple records can be linked.
|
||||||
|
|
||||||
|
Examples: adding parts to a bill, linking services to an invoice, attaching expense items to a PO.
|
||||||
|
|
||||||
|
**→ Read the `resource-selector` SKILL before implementing these fields.**
|
||||||
|
|
||||||
|
Ready-to-use selector fields (import directly — do not re-implement):
|
||||||
|
|
||||||
|
| Component | Import path | For |
|
||||||
|
|---|---|---|
|
||||||
|
| `PartsSelectorField` | `@/modules/parts/parts-selector-field` | Parts line items |
|
||||||
|
| `ServicesSelectorField` | `@/modules/services/services-selector-field` | Service line items |
|
||||||
|
| `ExpenseItemsSelectorField` | `@/modules/expense-items/expense-items-selector-field` | Expense line items |
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Schema: use an array sub-schema with part_id / service_id / expense_id
|
||||||
|
part_items: z.array(z.object({ part_id: z.number(), title: z.string(), quantity: z.number(), rate: z.number(), description: z.string().optional() })).optional()
|
||||||
|
|
||||||
|
// Form: render inside <FieldGroup>
|
||||||
|
<PartsSelectorField<MyFormValues, "part_items"> name="part_items" />
|
||||||
|
```
|
||||||
|
|
||||||
### Async Select Pattern
|
### Async Select Pattern
|
||||||
|
|
||||||
@ -144,8 +175,11 @@ const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) =
|
|||||||
| `RhfTextareaField` | Multi-line text |
|
| `RhfTextareaField` | Multi-line text |
|
||||||
| `RhfCheckboxField` | Boolean toggles |
|
| `RhfCheckboxField` | Boolean toggles |
|
||||||
| `RhfSelectField` | Static option dropdowns |
|
| `RhfSelectField` | Static option dropdowns |
|
||||||
| `RhfAsyncSelectField` | Server-fetched single-select combobox |
|
| `RhfAsyncSelectField` | Server-fetched single FK combobox (same-domain relation) |
|
||||||
| `RhfAsyncMultiSelectField` | Server-fetched multi-select combobox |
|
| `RhfAsyncMultiSelectField` | Server-fetched multi-select combobox |
|
||||||
|
| `RhfResourceField` | Cross-domain multi-select with join table extra fields (parts, services, expenses) — see resource-selector skill |
|
||||||
|
| `RhfDateField` | Date picker — see date-time-pickers skill |
|
||||||
|
| `RhfTimeField` | Time picker — see date-time-pickers skill |
|
||||||
|
|
||||||
### Imports Cheat Sheet
|
### Imports Cheat Sheet
|
||||||
|
|
||||||
|
|||||||
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"
|
name="some_relation_id"
|
||||||
label="Some Relation"
|
label="Some Relation"
|
||||||
queryKey={[SOME_ROUTES.INDEX]}
|
queryKey={[SOME_ROUTES.INDEX]}
|
||||||
listFn={() => api.someResource.list({ per_page: 100 })}
|
listFn={() => api.someResource.list( )}
|
||||||
mapOption={(item: any) => ({ value: String(item.id), label: item.name })}
|
mapOption={(item: any) => ({ value: String(item.id), label: item.name })}
|
||||||
placeholder="All"
|
placeholder="All"
|
||||||
/>
|
/>
|
||||||
|
|||||||
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 { getServerApi } from '@garage/api/server'
|
||||||
import { EstimateActions } from '@/modules/estimates/estimate-actions'
|
import { EstimateActions } from '@/modules/estimates/estimate-actions'
|
||||||
import { EstimateProvider } from '@/modules/estimates/estimate-context'
|
import { EstimateProvider } from '@/modules/estimates/estimate-context'
|
||||||
|
import { CreateInvoiceFromEstimateButton } from '@/modules/estimates/create-invoice-from-estimate-button'
|
||||||
import { FileTextIcon } from 'lucide-react'
|
import { FileTextIcon } from 'lucide-react'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
@ -20,7 +21,7 @@ export default async function layout(props: {
|
|||||||
: title
|
: title
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EstimateProvider estimate={{ id, label: estimateLabel }}>
|
<EstimateProvider estimate={{ id, label: estimateLabel, data: estimateData }}>
|
||||||
<DashboardDetailsPage
|
<DashboardDetailsPage
|
||||||
className="p-0 lg:p-0"
|
className="p-0 lg:p-0"
|
||||||
icon={<FileTextIcon className="size-5" />}
|
icon={<FileTextIcon className="size-5" />}
|
||||||
@ -29,7 +30,12 @@ export default async function layout(props: {
|
|||||||
estimateData?.date ? `Date: ${estimateData.date}` : undefined
|
estimateData?.date ? `Date: ${estimateData.date}` : undefined
|
||||||
}
|
}
|
||||||
backHref="/sales/estimates"
|
backHref="/sales/estimates"
|
||||||
actions={<EstimateActions estimateId={id} />}
|
actions={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CreateInvoiceFromEstimateButton />
|
||||||
|
<EstimateActions estimateId={id} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
tabs={[
|
tabs={[
|
||||||
{ href: `/sales/estimates/${id}`, label: 'Details' },
|
{ href: `/sales/estimates/${id}`, label: 'Details' },
|
||||||
]}
|
]}
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import { getServerApi } from '@garage/api/server'
|
import { getServerApi } from '@garage/api/server'
|
||||||
import { EstimateGeneralInfo } from '@/modules/estimates/estimate-general-info'
|
import { EstimateGeneralInfo } from '@/modules/estimates/estimate-general-info'
|
||||||
|
import { EstimateServicesSection } from '@/modules/estimates/estimate-services-section'
|
||||||
|
import { EstimatePartsSection } from '@/modules/estimates/estimate-parts-section'
|
||||||
|
import { EstimateExpenseItemsSection } from '@/modules/estimates/estimate-expense-items-section'
|
||||||
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
|
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
|
||||||
|
|
||||||
export default async function page(props: { params: Promise<{ id: string }> }) {
|
export default async function page(props: { params: Promise<{ id: string }> }) {
|
||||||
@ -15,7 +18,13 @@ export default async function page(props: { params: Promise<{ id: string }> }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardPage header={null}>
|
<DashboardPage header={null}>
|
||||||
<EstimateGeneralInfo estimate={estimateData} />
|
<div className="grid gap-6">
|
||||||
|
<EstimateGeneralInfo estimate={estimateData} />
|
||||||
|
<EstimateServicesSection estimateId={id} />
|
||||||
|
<EstimatePartsSection estimateId={id} />
|
||||||
|
<EstimateExpenseItemsSection estimateId={id} />
|
||||||
|
</div>
|
||||||
</DashboardPage>
|
</DashboardPage>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,22 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
import { useFormContext, useController, type FieldValues, type FieldPath } from "react-hook-form"
|
import { useFormContext, useController, type FieldValues, type FieldPath } from "react-hook-form"
|
||||||
|
import { useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { PlusIcon } from "lucide-react"
|
||||||
|
import { EMPLOYEE_ROUTES } from "@garage/api"
|
||||||
|
import { Field, FieldLabel, FieldError, FieldDescription } from "@/shared/components/ui/field"
|
||||||
import { FieldShell } from "@/shared/components/form/field-shell"
|
import { FieldShell } from "@/shared/components/form/field-shell"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/components/ui/dialog"
|
||||||
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||||
import { EmployeeCombobox } from "./employee-combobox"
|
import { EmployeeCombobox } from "./employee-combobox"
|
||||||
|
import { EmployeeForm } from "./employee-form"
|
||||||
|
|
||||||
// ── Props ──
|
// ── Props ──
|
||||||
|
|
||||||
@ -16,6 +30,8 @@ export type RhfEmployeeSelectFieldProps<
|
|||||||
required?: boolean
|
required?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
|
/** Show a "+" button to create a new employee inline */
|
||||||
|
showCreate?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Component ──
|
// ── Component ──
|
||||||
@ -30,6 +46,7 @@ export function RhfEmployeeSelectField<
|
|||||||
required,
|
required,
|
||||||
disabled,
|
disabled,
|
||||||
placeholder = "Search by name or email...",
|
placeholder = "Search by name or email...",
|
||||||
|
showCreate,
|
||||||
}: RhfEmployeeSelectFieldProps<TValues, TName>) {
|
}: RhfEmployeeSelectFieldProps<TValues, TName>) {
|
||||||
const { control } = useFormContext<TValues>()
|
const { control } = useFormContext<TValues>()
|
||||||
const {
|
const {
|
||||||
@ -37,6 +54,69 @@ export function RhfEmployeeSelectField<
|
|||||||
fieldState: { error },
|
fieldState: { error },
|
||||||
} = useController({ name, control, disabled })
|
} = useController({ name, control, disabled })
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
field.onChange(emp ? { value: emp.value, label: emp.label } : null)
|
||||||
|
}}
|
||||||
|
disabled={field.disabled}
|
||||||
|
placeholder={placeholder}
|
||||||
|
showClear={!!field.value}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
aria-invalid={!!error || undefined}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<FieldShell
|
<FieldShell
|
||||||
label={label}
|
label={label}
|
||||||
@ -44,17 +124,7 @@ export function RhfEmployeeSelectField<
|
|||||||
description={description}
|
description={description}
|
||||||
required={required}
|
required={required}
|
||||||
>
|
>
|
||||||
<EmployeeCombobox
|
{combobox}
|
||||||
value={field.value}
|
|
||||||
onValueChange={(emp) => {
|
|
||||||
field.onChange(emp ? { value: emp.value, label: emp.label } : null)
|
|
||||||
}}
|
|
||||||
disabled={field.disabled}
|
|
||||||
placeholder={placeholder}
|
|
||||||
showClear={!!field.value}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
aria-invalid={!!error || undefined}
|
|
||||||
/>
|
|
||||||
</FieldShell>
|
</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,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
} from "@/shared/components/ui/dialog"
|
} from "@/shared/components/ui/dialog"
|
||||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||||
import { Ellipsis, Pencil, Trash2 } from "lucide-react"
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select"
|
||||||
|
import { Ellipsis, Pencil, Trash2, ShieldCheck, Check, X } from "lucide-react"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query"
|
||||||
|
import { toast } from "sonner"
|
||||||
import { EstimateForm } from "./estimate-form"
|
import { EstimateForm } from "./estimate-form"
|
||||||
|
import { ESTIMATE_ROUTES } from "@garage/api"
|
||||||
|
import { DatePickerField, TimePickerField } from "@/shared/components/form"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
import { EmployeeCombobox, type EmployeeOption } from "../employees/employee-combobox"
|
||||||
|
|
||||||
type EstimateActionsProps = {
|
type EstimateActionsProps = {
|
||||||
estimateId: string
|
estimateId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AUTHORISATION_METHOD_OPTIONS = [
|
||||||
|
{ value: "in_person", label: "In Person" },
|
||||||
|
{ value: "phone", label: "Phone" },
|
||||||
|
{ value: "email", label: "Email" },
|
||||||
|
{ value: "online", label: "Online" },
|
||||||
|
]
|
||||||
|
|
||||||
|
type ServiceLine = { id: number; labor_name?: string; title?: string; quantity: number; rate: number | string; description?: string }
|
||||||
|
type PartLine = { id: number; title?: string; quantity: number; rate: number | string; description?: string }
|
||||||
|
type ExpenseLine = { id: number; item_name?: string; title?: string; quantity: number; rate: number | string; description?: string }
|
||||||
|
|
||||||
|
function toggleStatus(current: string, action: "accepted" | "rejected"): string {
|
||||||
|
return current === action ? "pending" : action
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemStatusRow({
|
||||||
|
name,
|
||||||
|
rate,
|
||||||
|
quantity,
|
||||||
|
description,
|
||||||
|
status,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
rate: number | string
|
||||||
|
quantity: number
|
||||||
|
description?: string
|
||||||
|
status: string
|
||||||
|
onToggle: (action: "accepted" | "rejected") => void
|
||||||
|
}) {
|
||||||
|
const rateNum = Number(rate)
|
||||||
|
const amount = rateNum * quantity
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4 py-2.5 border-b last:border-0">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{name}</p>
|
||||||
|
<div className="flex items-center gap-3 mt-1">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">{quantity}</span> qty
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground/40">·</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Rate <span className="font-medium text-foreground">{rateNum.toFixed(2)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground/40">·</span>
|
||||||
|
<span className="text-xs font-semibold text-foreground">{amount.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
{description && (
|
||||||
|
<p className="text-xs text-muted-foreground/70 truncate mt-0.5">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"h-8 gap-1.5 px-3",
|
||||||
|
status === "rejected" && "bg-destructive text-destructive-foreground border-destructive hover:bg-destructive/90",
|
||||||
|
)}
|
||||||
|
onClick={() => onToggle("rejected")}
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"h-8 gap-1.5 px-3",
|
||||||
|
status === "accepted" && "bg-primary text-primary-foreground border-primary hover:bg-primary/90",
|
||||||
|
)}
|
||||||
|
onClick={() => onToggle("accepted")}
|
||||||
|
>
|
||||||
|
<Check className="size-3.5" />
|
||||||
|
Accept
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionHeading({ title }: { title: string }) {
|
||||||
|
return (
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-1">{title}</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function EstimateActions({ estimateId }: EstimateActionsProps) {
|
export function EstimateActions({ estimateId }: EstimateActionsProps) {
|
||||||
const api = useAuthApi()
|
const api = useAuthApi()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [editOpen, setEditOpen] = useState(false)
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
|
const [authOpen, setAuthOpen] = useState(false)
|
||||||
|
const [itemStatuses, setItemStatuses] = useState<Record<string, string>>({})
|
||||||
|
const [authMethod, setAuthMethod] = useState("in_person")
|
||||||
|
const [employee, setEmployee] = useState<EmployeeOption | null>(null)
|
||||||
|
const [authDate, setAuthDate] = useState("")
|
||||||
|
const [authTime, setAuthTime] = useState("")
|
||||||
|
|
||||||
|
const { data: servicesData = [], isLoading: loadingServices } = useQuery({
|
||||||
|
queryKey: [ESTIMATE_ROUTES.SERVICES, estimateId, "auth"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.estimates.listServices(estimateId)
|
||||||
|
return ((res as any)?.data ?? []) as ServiceLine[]
|
||||||
|
},
|
||||||
|
enabled: authOpen,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: partsData = [], isLoading: loadingParts } = useQuery({
|
||||||
|
queryKey: [ESTIMATE_ROUTES.PARTS, estimateId, "auth"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.estimates.listParts(estimateId)
|
||||||
|
return ((res as any)?.data ?? []) as PartLine[]
|
||||||
|
},
|
||||||
|
enabled: authOpen,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: expenseItemsData = [], isLoading: loadingExpenseItems } = useQuery({
|
||||||
|
queryKey: [ESTIMATE_ROUTES.EXPENSE_ITEMS, estimateId, "auth"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.estimates.listExpenseItems(estimateId)
|
||||||
|
return ((res as any)?.data ?? []) as ExpenseLine[]
|
||||||
|
},
|
||||||
|
enabled: authOpen,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isLoading = loadingServices || loadingParts || loadingExpenseItems
|
||||||
|
const hasItems = servicesData.length > 0 || partsData.length > 0 || expenseItemsData.length > 0
|
||||||
|
|
||||||
|
const getStatus = (key: string) => itemStatuses[key] ?? "pending"
|
||||||
|
const handleToggle = (key: string, action: "accepted" | "rejected") =>
|
||||||
|
setItemStatuses((prev) => ({ ...prev, [key]: toggleStatus(prev[key] ?? "pending", action) }))
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
await api.estimates.destroy(estimateId)
|
await api.estimates.destroy(estimateId)
|
||||||
router.push("/sales/estimates")
|
router.push("/sales/estimates")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const authMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
api.estimates.storeAuthorisation(estimateId, {
|
||||||
|
estimate_services: servicesData.map((s) => ({ id: s.id, status: getStatus(`s-${s.id}`) })),
|
||||||
|
estimate_parts: partsData.map((p) => ({ id: p.id, status: getStatus(`p-${p.id}`) })),
|
||||||
|
estimate_expense_items: expenseItemsData.map((e) => ({ id: e.id, status: getStatus(`e-${e.id}`) })),
|
||||||
|
authorisation_method: authMethod,
|
||||||
|
employee_id: employee ? Number(employee.value) : undefined,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Authorisation stored successfully")
|
||||||
|
setAuthOpen(false)
|
||||||
|
router.refresh()
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to store authorisation"),
|
||||||
|
})
|
||||||
|
|
||||||
|
const openAuthDialog = () => {
|
||||||
|
setItemStatuses({})
|
||||||
|
setAuthMethod("in_person")
|
||||||
|
setEmployee(null)
|
||||||
|
const n = new Date()
|
||||||
|
setAuthDate(n.toISOString().split("T")[0])
|
||||||
|
setAuthTime(`${String(n.getHours()).padStart(2, "0")}:${String(n.getMinutes()).padStart(2, "0")}:00`)
|
||||||
|
setAuthOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@ -47,6 +218,10 @@ export function EstimateActions({ estimateId }: EstimateActionsProps) {
|
|||||||
<Pencil className="size-4" />
|
<Pencil className="size-4" />
|
||||||
Edit
|
Edit
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={openAuthDialog}>
|
||||||
|
<ShieldCheck className="size-4" />
|
||||||
|
Store Authorisation
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
Delete
|
Delete
|
||||||
@ -54,6 +229,7 @@ export function EstimateActions({ estimateId }: EstimateActionsProps) {
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Edit Dialog */}
|
||||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||||
<DialogContent className="min-w-xl">
|
<DialogContent className="min-w-xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@ -70,6 +246,122 @@ export function EstimateActions({ estimateId }: EstimateActionsProps) {
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Authorisation Dialog */}
|
||||||
|
<Dialog open={authOpen} onOpenChange={setAuthOpen}>
|
||||||
|
<DialogContent className="sm:max-w-7xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl font-bold">Store Authorisation</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<ScrollArea className="max-h-[55vh]">
|
||||||
|
<div className="grid gap-5 px-1 py-1">
|
||||||
|
{isLoading && (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading line items…</p>
|
||||||
|
)}
|
||||||
|
{!isLoading && !hasItems && (
|
||||||
|
<p className="text-sm text-muted-foreground">No line items on this estimate.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{servicesData.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<SectionHeading title="Services" />
|
||||||
|
{servicesData.map((s) => (
|
||||||
|
<ItemStatusRow
|
||||||
|
key={s.id}
|
||||||
|
name={s.labor_name || s.title || `#${s.id}`}
|
||||||
|
rate={s.rate}
|
||||||
|
quantity={s.quantity}
|
||||||
|
description={s.description}
|
||||||
|
status={getStatus(`s-${s.id}`)}
|
||||||
|
onToggle={(a) => handleToggle(`s-${s.id}`, a)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{partsData.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<SectionHeading title="Parts" />
|
||||||
|
{partsData.map((p) => (
|
||||||
|
<ItemStatusRow
|
||||||
|
key={p.id}
|
||||||
|
name={p.title || `#${p.id}`}
|
||||||
|
rate={p.rate}
|
||||||
|
quantity={p.quantity}
|
||||||
|
description={p.description}
|
||||||
|
status={getStatus(`p-${p.id}`)}
|
||||||
|
onToggle={(a) => handleToggle(`p-${p.id}`, a)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expenseItemsData.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<SectionHeading title="Expense Items" />
|
||||||
|
{expenseItemsData.map((e) => (
|
||||||
|
<ItemStatusRow
|
||||||
|
key={e.id}
|
||||||
|
name={e.item_name || e.title || `#${e.id}`}
|
||||||
|
rate={e.rate}
|
||||||
|
quantity={e.quantity}
|
||||||
|
description={e.description}
|
||||||
|
status={getStatus(`e-${e.id}`)}
|
||||||
|
onToggle={(a) => handleToggle(`e-${e.id}`, a)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<div className="grid gap-3 border-t pt-4">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1.5">Date</p>
|
||||||
|
<DatePickerField value={authDate} onChange={(v) => setAuthDate(v ?? "")} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1.5">Time</p>
|
||||||
|
<TimePickerField value={authTime} onChange={(v) => setAuthTime(v ?? "")} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1.5">Method</p>
|
||||||
|
<Select value={authMethod} onValueChange={setAuthMethod}>
|
||||||
|
<SelectTrigger className="h-9 w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{AUTHORISATION_METHOD_OPTIONS.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-1.5">Employee</p>
|
||||||
|
<EmployeeCombobox
|
||||||
|
value={employee}
|
||||||
|
onValueChange={setEmployee}
|
||||||
|
showClear={!!employee}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setAuthOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => authMutation.mutate()} disabled={authMutation.isPending || isLoading}>
|
||||||
|
{authMutation.isPending ? "Saving…" : "Save Authorisations"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import { createContext, useContext } from "react"
|
import { createContext, useContext } from "react"
|
||||||
|
|
||||||
type EstimateContextValue = {
|
export type EstimateContextValue = {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
|
data?: Record<string, any>
|
||||||
}
|
}
|
||||||
|
|
||||||
const EstimateContext = createContext<EstimateContextValue | null>(null)
|
const EstimateContext = createContext<EstimateContextValue | null>(null)
|
||||||
|
|||||||
@ -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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/shared/components/ui/dropdown-menu'
|
||||||
import { Button } from '@/shared/components/ui/button'
|
import { Button } from '@/shared/components/ui/button'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Ellipsis, Pencil, Trash2 } from 'lucide-react';
|
import { Ellipsis, Pencil, Printer, Trash2 } from 'lucide-react';
|
||||||
|
import { useDocumentPrint } from '@/shared/hooks/use-document-print';
|
||||||
|
|
||||||
export default function JobCardDropdown({ id }: { id: string }) {
|
export default function JobCardDropdown({ id }: { id: string }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { print, isPrinting } = useDocumentPrint()
|
||||||
|
|
||||||
const handleEdit = () => {
|
const handleEdit = () => {
|
||||||
router.push(`/sales/job-cards/${id}/edit`)
|
router.push(`/sales/job-cards/${id}/edit`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlePrint = () => {
|
||||||
|
print("job_card", id, "print")
|
||||||
|
}
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
const confirmed = await confirm({
|
const confirmed = await confirm({
|
||||||
title: "Delete Job Card",
|
title: "Delete Job Card",
|
||||||
@ -45,6 +53,10 @@ export default function JobCardDropdown({ id }: { id: string }) {
|
|||||||
<Pencil className="size-4" />
|
<Pencil className="size-4" />
|
||||||
Edit
|
Edit
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={handlePrint} disabled={isPrinting}>
|
||||||
|
<Printer className="size-4" />
|
||||||
|
{isPrinting ? "Printing..." : "Print"}
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
|
|||||||
@ -190,7 +190,7 @@ export function JobCardFilterFields() {
|
|||||||
name="customer_id"
|
name="customer_id"
|
||||||
label="Customer"
|
label="Customer"
|
||||||
queryKey={[CUSTOMER_ROUTES.INDEX]}
|
queryKey={[CUSTOMER_ROUTES.INDEX]}
|
||||||
listFn={() => api.customers.list({ per_page: 100 })}
|
listFn={() => api.customers.list( )}
|
||||||
mapOption={customerMapOption}
|
mapOption={customerMapOption}
|
||||||
placeholder="All customers"
|
placeholder="All customers"
|
||||||
/>
|
/>
|
||||||
@ -199,7 +199,7 @@ export function JobCardFilterFields() {
|
|||||||
name="vehicle_id"
|
name="vehicle_id"
|
||||||
label="Vehicle"
|
label="Vehicle"
|
||||||
queryKey={[VEHICLE_ROUTES.INDEX]}
|
queryKey={[VEHICLE_ROUTES.INDEX]}
|
||||||
listFn={() => api.vehicles.list({ per_page: 100 })}
|
listFn={() => api.vehicles.list( )}
|
||||||
mapOption={vehicleMapOption}
|
mapOption={vehicleMapOption}
|
||||||
placeholder="All vehicles"
|
placeholder="All vehicles"
|
||||||
/>
|
/>
|
||||||
@ -208,7 +208,7 @@ export function JobCardFilterFields() {
|
|||||||
name="insurer_id"
|
name="insurer_id"
|
||||||
label="Insurance Type"
|
label="Insurance Type"
|
||||||
queryKey={[INSURANCE_TYPE_ROUTES.INDEX]}
|
queryKey={[INSURANCE_TYPE_ROUTES.INDEX]}
|
||||||
listFn={() => api.insuranceTypes.list({ per_page: 100 })}
|
listFn={() => api.insuranceTypes.list( )}
|
||||||
mapOption={(item: any) => ({ value: String(item.id), label: item.title ?? item.name ?? `#${item.id}` })}
|
mapOption={(item: any) => ({ value: String(item.id), label: item.title ?? item.name ?? `#${item.id}` })}
|
||||||
placeholder="All insurers"
|
placeholder="All insurers"
|
||||||
/>
|
/>
|
||||||
@ -217,7 +217,7 @@ export function JobCardFilterFields() {
|
|||||||
name="department_id"
|
name="department_id"
|
||||||
label="Department"
|
label="Department"
|
||||||
queryKey={[DEPARTMENT_ROUTES.INDEX]}
|
queryKey={[DEPARTMENT_ROUTES.INDEX]}
|
||||||
listFn={() => api.departments.list({ per_page: 100 })}
|
listFn={() => api.departments.list( )}
|
||||||
mapOption={(item: any) => ({ value: String(item.id), label: item.title ?? item.name ?? `#${item.id}` })}
|
mapOption={(item: any) => ({ value: String(item.id), label: item.title ?? item.name ?? `#${item.id}` })}
|
||||||
placeholder="All departments"
|
placeholder="All departments"
|
||||||
/>
|
/>
|
||||||
@ -229,7 +229,7 @@ export function JobCardFilterFields() {
|
|||||||
name="sales_person_id"
|
name="sales_person_id"
|
||||||
label="Sales Person"
|
label="Sales Person"
|
||||||
queryKey={[EMPLOYEE_ROUTES.INDEX, "sales_person"]}
|
queryKey={[EMPLOYEE_ROUTES.INDEX, "sales_person"]}
|
||||||
listFn={() => api.employees.list({ per_page: 100 })}
|
listFn={() => api.employees.list( )}
|
||||||
mapOption={employeeMapOption}
|
mapOption={employeeMapOption}
|
||||||
placeholder="All sales persons"
|
placeholder="All sales persons"
|
||||||
/>
|
/>
|
||||||
@ -238,7 +238,7 @@ export function JobCardFilterFields() {
|
|||||||
name="primary_technician_id"
|
name="primary_technician_id"
|
||||||
label="Primary Technician"
|
label="Primary Technician"
|
||||||
queryKey={[EMPLOYEE_ROUTES.INDEX, "technician"]}
|
queryKey={[EMPLOYEE_ROUTES.INDEX, "technician"]}
|
||||||
listFn={() => api.employees.list({ per_page: 100 })}
|
listFn={() => api.employees.list( )}
|
||||||
mapOption={employeeMapOption}
|
mapOption={employeeMapOption}
|
||||||
placeholder="All technicians"
|
placeholder="All technicians"
|
||||||
/>
|
/>
|
||||||
@ -247,7 +247,7 @@ export function JobCardFilterFields() {
|
|||||||
name="service_writer_id"
|
name="service_writer_id"
|
||||||
label="Service Writer"
|
label="Service Writer"
|
||||||
queryKey={[EMPLOYEE_ROUTES.INDEX, "service_writer"]}
|
queryKey={[EMPLOYEE_ROUTES.INDEX, "service_writer"]}
|
||||||
listFn={() => api.employees.list({ per_page: 100 })}
|
listFn={() => api.employees.list( )}
|
||||||
mapOption={employeeMapOption}
|
mapOption={employeeMapOption}
|
||||||
placeholder="All service writers"
|
placeholder="All service writers"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -33,6 +33,7 @@ import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-fie
|
|||||||
import { RhfLabelPickerField } from "@/modules/labels/rhf-label-picker-field"
|
import { RhfLabelPickerField } from "@/modules/labels/rhf-label-picker-field"
|
||||||
import { RhfCustomerRemarksField } from "@/modules/estimates/rhf-customer-remarks-field"
|
import { RhfCustomerRemarksField } from "@/modules/estimates/rhf-customer-remarks-field"
|
||||||
import { InsuranceTypeCrudDialog } from "./insurance-type-crud-dialog"
|
import { InsuranceTypeCrudDialog } from "./insurance-type-crud-dialog"
|
||||||
|
import { RhfEmployeeSelectField } from "../employees/rhf-employee-select-field"
|
||||||
|
|
||||||
// ── Props ──
|
// ── Props ──
|
||||||
|
|
||||||
@ -282,22 +283,18 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<RhfAsyncSelectField
|
<RhfEmployeeSelectField
|
||||||
|
showCreate
|
||||||
name="service_writer"
|
name="service_writer"
|
||||||
label="Service Writer"
|
label="Service Writer"
|
||||||
placeholder="Select service writer"
|
placeholder="Select service writer"
|
||||||
queryKey={[EMPLOYEE_ROUTES.INDEX]}
|
|
||||||
listFn={() => api.employees.list()}
|
|
||||||
mapOption={mapEmployeeOption}
|
|
||||||
{...STORE_OBJECT}
|
{...STORE_OBJECT}
|
||||||
/>
|
/>
|
||||||
<RhfAsyncSelectField
|
<RhfEmployeeSelectField
|
||||||
|
showCreate
|
||||||
name="primary_technician"
|
name="primary_technician"
|
||||||
label="Primary Technician"
|
label="Primary Technician"
|
||||||
placeholder="Select technician"
|
placeholder="Select technician"
|
||||||
queryKey={[EMPLOYEE_ROUTES.INDEX]}
|
|
||||||
listFn={() => api.employees.list()}
|
|
||||||
mapOption={mapEmployeeOption}
|
|
||||||
{...STORE_OBJECT}
|
{...STORE_OBJECT}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -25,6 +25,12 @@ export type ResourceSelectorDialogProps<TClient extends ResourcePageClient> = {
|
|||||||
crudProps: Omit<CrudResourceProps<TClient>, "render" | "tableProps">
|
crudProps: Omit<CrudResourceProps<TClient>, "render" | "tableProps">
|
||||||
/** Optional rowKey for selection identity. Defaults to "id" */
|
/** Optional rowKey for selection identity. Defaults to "id" */
|
||||||
rowKey?: keyof ResourceItem<TClient>
|
rowKey?: keyof ResourceItem<TClient>
|
||||||
|
/**
|
||||||
|
* Selection mode.
|
||||||
|
* - "multi" (default): checkbox multi-select + Confirm button
|
||||||
|
* - "single": row click immediately fires onConfirm with the clicked row
|
||||||
|
*/
|
||||||
|
selectionMode?: "single" | "multi"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ResourceSelectorDialog<TClient extends ResourcePageClient>({
|
export function ResourceSelectorDialog<TClient extends ResourcePageClient>({
|
||||||
@ -34,6 +40,7 @@ export function ResourceSelectorDialog<TClient extends ResourcePageClient>({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
crudProps,
|
crudProps,
|
||||||
rowKey,
|
rowKey,
|
||||||
|
selectionMode = "multi",
|
||||||
}: ResourceSelectorDialogProps<TClient>) {
|
}: ResourceSelectorDialogProps<TClient>) {
|
||||||
type TItem = ResourceItem<TClient>
|
type TItem = ResourceItem<TClient>
|
||||||
|
|
||||||
@ -58,6 +65,11 @@ export function ResourceSelectorDialog<TClient extends ResourcePageClient>({
|
|||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleRowClick = (row: TItem) => {
|
||||||
|
onConfirm([row])
|
||||||
|
onOpenChange(false)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(v) => { if (!v) handleCancel() }}>
|
<Dialog open={open} onOpenChange={(v) => { if (!v) handleCancel() }}>
|
||||||
<DialogContent className="min-w-4xl max-h-[90vh] flex flex-col">
|
<DialogContent className="min-w-4xl max-h-[90vh] flex flex-col">
|
||||||
@ -67,26 +79,35 @@ export function ResourceSelectorDialog<TClient extends ResourcePageClient>({
|
|||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<Card className="rounded-none border-0 shadow-none">
|
<Card className="rounded-none border-0 shadow-none">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<CrudResource<TClient>
|
{selectionMode === "single" ? (
|
||||||
{...crudProps}
|
<CrudResource<TClient>
|
||||||
tableProps={{
|
{...crudProps}
|
||||||
selection: {
|
onRowClick={handleRowClick}
|
||||||
onSelectionChange: handleSelectionChange,
|
/>
|
||||||
...(rowKey ? { rowKey } : {}),
|
) : (
|
||||||
},
|
<CrudResource<TClient>
|
||||||
}}
|
{...crudProps}
|
||||||
/>
|
tableProps={{
|
||||||
|
selection: {
|
||||||
|
onSelectionChange: handleSelectionChange,
|
||||||
|
...(rowKey ? { rowKey } : {}),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
{selectionMode === "multi" && (
|
||||||
<Button variant="outline" onClick={handleCancel}>
|
<DialogFooter>
|
||||||
Cancel
|
<Button variant="outline" onClick={handleCancel}>
|
||||||
</Button>
|
Cancel
|
||||||
<Button onClick={handleConfirm} disabled={count === 0}>
|
</Button>
|
||||||
Confirm{count > 0 ? ` (${count})` : ""}
|
<Button onClick={handleConfirm} disabled={count === 0}>
|
||||||
</Button>
|
Confirm{count > 0 ? ` (${count})` : ""}
|
||||||
</DialogFooter>
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"services_status": {
|
"estimate_services": {
|
||||||
"type": "string"
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"parts_status": {
|
"estimate_parts": {
|
||||||
"type": "string"
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"inspections_status": {
|
"estimate_inspections": {
|
||||||
"type": "string"
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"expense_items_status": {
|
"estimate_expense_items": {
|
||||||
"type": "string"
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"authorisation_method": {
|
"authorisation_method": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@ -19761,10 +19805,34 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"example": {
|
"example": {
|
||||||
"services_status": "accepted",
|
"estimate_services": [
|
||||||
"parts_status": "accepted",
|
{
|
||||||
"inspections_status": "accepted",
|
"id": 11,
|
||||||
"expense_items_status": "accepted",
|
"status": "accepted"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"status": "rejected"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"estimate_parts": [
|
||||||
|
{
|
||||||
|
"id": 21,
|
||||||
|
"status": "accepted"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"estimate_inspections": [
|
||||||
|
{
|
||||||
|
"id": 31,
|
||||||
|
"status": "pending"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"estimate_expense_items": [
|
||||||
|
{
|
||||||
|
"id": 41,
|
||||||
|
"status": "accepted"
|
||||||
|
}
|
||||||
|
],
|
||||||
"authorisation_method": "in_person",
|
"authorisation_method": "in_person",
|
||||||
"employee_id": 1
|
"employee_id": 1
|
||||||
}
|
}
|
||||||
@ -19851,15 +19919,15 @@
|
|||||||
"authorisation_date": "2026-04-13",
|
"authorisation_date": "2026-04-13",
|
||||||
"authorisation_time": "14:30:00",
|
"authorisation_time": "14:30:00",
|
||||||
"authorisation_method": "in_person",
|
"authorisation_method": "in_person",
|
||||||
"total_accepted": 8
|
"total_accepted": 5
|
||||||
},
|
},
|
||||||
"updated_counts": {
|
"updated_counts": {
|
||||||
"services": 3,
|
"services": 2,
|
||||||
"parts": 2,
|
"parts": 1,
|
||||||
"inspections": 1,
|
"inspections": 1,
|
||||||
"expense_items": 2
|
"expense_items": 1
|
||||||
},
|
},
|
||||||
"histories_count": 9
|
"histories_count": 6
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"info": {
|
"info": {
|
||||||
"_postman_id": "90943e73-b99e-468b-b10b-d3a02f751da9",
|
"_postman_id": "351b2d30-cb82-4904-9a47-0d356181a8ea",
|
||||||
"name": "Reparee Collection",
|
"name": "Reparee Collection",
|
||||||
"description": "Auto-generated from OpenAPI spec. Import storage/app/openapi-default.json for the full schema.",
|
"description": "Auto-generated from OpenAPI spec. Import storage/app/openapi-default.json for the full schema.",
|
||||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
||||||
@ -13384,7 +13384,7 @@
|
|||||||
],
|
],
|
||||||
"body": {
|
"body": {
|
||||||
"mode": "raw",
|
"mode": "raw",
|
||||||
"raw": "{\n \"services_status\": \"accepted\",\n \"parts_status\": \"accepted\",\n \"inspections_status\": \"accepted\",\n \"expense_items_status\": \"accepted\",\n \"authorisation_method\": \"in_person\",\n \"employee_id\": 1\n}",
|
"raw": "{\n \"estimate_services\": [\n {\n \"id\": 11,\n \"status\": \"accepted\"\n },\n {\n \"id\": 12,\n \"status\": \"rejected\"\n }\n ],\n \"estimate_parts\": [\n {\n \"id\": 21,\n \"status\": \"accepted\"\n }\n ],\n \"estimate_inspections\": [\n {\n \"id\": 31,\n \"status\": \"pending\"\n }\n ],\n \"estimate_expense_items\": [\n {\n \"id\": 41,\n \"status\": \"accepted\"\n }\n ],\n \"authorisation_method\": \"in_person\",\n \"employee_id\": 1\n}",
|
||||||
"options": {
|
"options": {
|
||||||
"raw": {
|
"raw": {
|
||||||
"language": "json"
|
"language": "json"
|
||||||
@ -13431,7 +13431,7 @@
|
|||||||
],
|
],
|
||||||
"body": {
|
"body": {
|
||||||
"mode": "raw",
|
"mode": "raw",
|
||||||
"raw": "{\n \"services_status\": \"accepted\",\n \"parts_status\": \"accepted\",\n \"inspections_status\": \"accepted\",\n \"expense_items_status\": \"accepted\",\n \"authorisation_method\": \"in_person\",\n \"employee_id\": 1\n}",
|
"raw": "{\n \"estimate_services\": [\n {\n \"id\": 11,\n \"status\": \"accepted\"\n },\n {\n \"id\": 12,\n \"status\": \"rejected\"\n }\n ],\n \"estimate_parts\": [\n {\n \"id\": 21,\n \"status\": \"accepted\"\n }\n ],\n \"estimate_inspections\": [\n {\n \"id\": 31,\n \"status\": \"pending\"\n }\n ],\n \"estimate_expense_items\": [\n {\n \"id\": 41,\n \"status\": \"accepted\"\n }\n ],\n \"authorisation_method\": \"in_person\",\n \"employee_id\": 1\n}",
|
||||||
"options": {
|
"options": {
|
||||||
"raw": {
|
"raw": {
|
||||||
"language": "json"
|
"language": "json"
|
||||||
@ -13461,7 +13461,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"cookie": [],
|
"cookie": [],
|
||||||
"body": "{\n \"message\": \"Estimate authorisation stored successfully\",\n \"data\": {\n \"estimate\": {\n \"id\": 1,\n \"is_authorisation\": true,\n \"authorisation_date\": \"2026-04-13\",\n \"authorisation_time\": \"14:30:00\",\n \"authorisation_method\": \"in_person\",\n \"total_accepted\": 8\n },\n \"updated_counts\": {\n \"services\": 3,\n \"parts\": 2,\n \"inspections\": 1,\n \"expense_items\": 2\n },\n \"histories_count\": 9\n }\n}"
|
"body": "{\n \"message\": \"Estimate authorisation stored successfully\",\n \"data\": {\n \"estimate\": {\n \"id\": 1,\n \"is_authorisation\": true,\n \"authorisation_date\": \"2026-04-13\",\n \"authorisation_time\": \"14:30:00\",\n \"authorisation_method\": \"in_person\",\n \"total_accepted\": 5\n },\n \"updated_counts\": {\n \"services\": 2,\n \"parts\": 1,\n \"inspections\": 1,\n \"expense_items\": 1\n },\n \"histories_count\": 6\n }\n}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@ -56,6 +56,7 @@ import { ConfigurationsClient } from "./clients/configurations"
|
|||||||
import { AutoGenerateClient } from "./clients/auto-generate"
|
import { AutoGenerateClient } from "./clients/auto-generate"
|
||||||
import { ExpenseItemsClient } from "./clients/expense-items"
|
import { ExpenseItemsClient } from "./clients/expense-items"
|
||||||
import { InventoryCategoriesClient } from "./clients/inventory-categories"
|
import { InventoryCategoriesClient } from "./clients/inventory-categories"
|
||||||
|
import { DocumentPrintClient } from "./clients/document-print"
|
||||||
|
|
||||||
export function createApi(options?: ApiClientOptions) {
|
export function createApi(options?: ApiClientOptions) {
|
||||||
return {
|
return {
|
||||||
@ -116,6 +117,7 @@ export function createApi(options?: ApiClientOptions) {
|
|||||||
autoGenerate: new AutoGenerateClient(undefined, options),
|
autoGenerate: new AutoGenerateClient(undefined, options),
|
||||||
expenseItems: new ExpenseItemsClient(undefined, options),
|
expenseItems: new ExpenseItemsClient(undefined, options),
|
||||||
inventoryCategories: new InventoryCategoriesClient(undefined, options),
|
inventoryCategories: new InventoryCategoriesClient(undefined, options),
|
||||||
|
documentPrint: new DocumentPrintClient(undefined, options),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 = {
|
export const ESTIMATE_ROUTES = {
|
||||||
INDEX: "/api/estimates",
|
INDEX: "/api/estimates",
|
||||||
BY_ID: "/api/estimates/{id}",
|
BY_ID: "/api/estimates/{id}",
|
||||||
|
SERVICES: "/api/estimate/{id}/services",
|
||||||
|
SERVICE_BY_ID: "/api/estimate/{id}/services/{service_id}",
|
||||||
|
PARTS: "/api/estimate/{id}/parts",
|
||||||
|
PART_BY_ID: "/api/estimate/{id}/parts/{part_id}",
|
||||||
|
EXPENSE_ITEMS: "/api/estimate/{id}/expense-items",
|
||||||
|
EXPENSE_ITEM_BY_ID: "/api/estimate/{id}/expense-items/{expense_item_id}",
|
||||||
|
STORE_AUTHORISATION: "/api/estimates/{id}/store-authorisation",
|
||||||
} as const satisfies Record<string, ApiPath>
|
} as const satisfies Record<string, ApiPath>
|
||||||
|
|
||||||
export class EstimatesClient extends CrudClient<
|
export class EstimatesClient extends CrudClient<
|
||||||
@ -21,4 +28,67 @@ export class EstimatesClient extends CrudClient<
|
|||||||
const data = await this.get(ESTIMATE_ROUTES.INDEX, { query: { id } } as never)
|
const data = await this.get(ESTIMATE_ROUTES.INDEX, { query: { id } } as never)
|
||||||
return {...data, data: (data as any)?.data?.[0] ?? null }
|
return {...data, data: (data as any)?.data?.[0] ?? null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Estimate Services ──
|
||||||
|
async listServices(estimateId: string) {
|
||||||
|
return this.get(ESTIMATE_ROUTES.SERVICES, { params: { id: estimateId } } as never)
|
||||||
|
}
|
||||||
|
|
||||||
|
async addService(estimateId: string, payload: { service_id?: number; rate_type?: string; quantity?: number; rate?: string; description?: string; labor_rate_id?: number; working_hours?: string | number; labor_hours?: string | number; tax?: string; chart_of_account?: string; department_id?: number }) {
|
||||||
|
return this.post(ESTIMATE_ROUTES.SERVICES, payload as never, { params: { id: estimateId } } as never)
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateService(estimateId: string, serviceId: string, payload: { rate?: string; quantity?: number }) {
|
||||||
|
return this.put(ESTIMATE_ROUTES.SERVICE_BY_ID, payload as never, { params: { id: estimateId, service_id: serviceId } } as never)
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeService(estimateId: string, serviceId: string) {
|
||||||
|
return this.delete(ESTIMATE_ROUTES.SERVICE_BY_ID, { params: { id: estimateId, service_id: serviceId } } as never)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Estimate Parts ──
|
||||||
|
async listParts(estimateId: string) {
|
||||||
|
return this.get(ESTIMATE_ROUTES.PARTS, { params: { id: estimateId } } as never)
|
||||||
|
}
|
||||||
|
|
||||||
|
async addPart(estimateId: string, payload: { part_id?: number; quantity?: number; rate?: string; description?: string }) {
|
||||||
|
return this.post(ESTIMATE_ROUTES.PARTS, payload as never, { params: { id: estimateId } } as never)
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePart(estimateId: string, partId: string, payload: { quantity?: number }) {
|
||||||
|
return this.put(ESTIMATE_ROUTES.PART_BY_ID, payload as never, { params: { id: estimateId, part_id: partId } } as never)
|
||||||
|
}
|
||||||
|
|
||||||
|
async removePart(estimateId: string, partId: string) {
|
||||||
|
return this.delete(ESTIMATE_ROUTES.PART_BY_ID, { params: { id: estimateId, part_id: partId } } as never)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Estimate Expense Items ──
|
||||||
|
async listExpenseItems(estimateId: string) {
|
||||||
|
return this.get(ESTIMATE_ROUTES.EXPENSE_ITEMS, { params: { id: estimateId } } as never)
|
||||||
|
}
|
||||||
|
|
||||||
|
async addExpenseItem(estimateId: string, payload: { expense_item_id?: number; quantity?: number; rate?: string; description?: string }) {
|
||||||
|
return this.post(ESTIMATE_ROUTES.EXPENSE_ITEMS, payload as never, { params: { id: estimateId } } as never)
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateExpenseItem(estimateId: string, expenseItemId: string, payload: { quantity?: number }) {
|
||||||
|
return this.put(ESTIMATE_ROUTES.EXPENSE_ITEM_BY_ID, payload as never, { params: { id: estimateId, expense_item_id: expenseItemId } } as never)
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeExpenseItem(estimateId: string, expenseItemId: string) {
|
||||||
|
return this.delete(ESTIMATE_ROUTES.EXPENSE_ITEM_BY_ID, { params: { id: estimateId, expense_item_id: expenseItemId } } as never)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Authorisation ──
|
||||||
|
async storeAuthorisation(estimateId: string, payload: {
|
||||||
|
estimate_services?: { id: number; status: string }[]
|
||||||
|
estimate_parts?: { id: number; status: string }[]
|
||||||
|
estimate_inspections?: { id: number; status: string }[]
|
||||||
|
estimate_expense_items?: { id: number; status: string }[]
|
||||||
|
authorisation_method?: string
|
||||||
|
employee_id?: number
|
||||||
|
}) {
|
||||||
|
return this.post(ESTIMATE_ROUTES.STORE_AUTHORISATION, payload as never, { params: { id: estimateId } } as never)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,6 +38,7 @@ export { InvoicesClient, INVOICE_ROUTES } from "./invoices"
|
|||||||
export { HomeClient, HOME_ROUTES, type HomeDashboardResponse } from "./home"
|
export { HomeClient, HOME_ROUTES, type HomeDashboardResponse } from "./home"
|
||||||
export { BillsClient, BILL_ROUTES } from "./bills"
|
export { BillsClient, BILL_ROUTES } from "./bills"
|
||||||
export { ReasonsClient, REASON_ROUTES } from "./reasons"
|
export { ReasonsClient, REASON_ROUTES } from "./reasons"
|
||||||
|
export { DocumentPrintClient, DOCUMENT_PRINT_ROUTES, type DocumentPrintType, type DocumentPrintMode } from "./document-print"
|
||||||
export { HolidaysClient, HOLIDAY_ROUTES } from "./holidays"
|
export { HolidaysClient, HOLIDAY_ROUTES } from "./holidays"
|
||||||
export { MakeAndModelsClient, MAKE_AND_MODEL_ROUTES } from "./make-and-models"
|
export { MakeAndModelsClient, MAKE_AND_MODEL_ROUTES } from "./make-and-models"
|
||||||
export { TimeSheetsClient, TIME_SHEET_ROUTES } from "./time-sheets"
|
export { TimeSheetsClient, TIME_SHEET_ROUTES } from "./time-sheets"
|
||||||
|
|||||||
@ -109,7 +109,7 @@ export const SellRatesTaxInclusive = ['Tax Inclusive', 'Tax Exclusive'] as const
|
|||||||
export type SellRatesTaxInclusive = (typeof SellRatesTaxInclusive)[number];
|
export type SellRatesTaxInclusive = (typeof SellRatesTaxInclusive)[number];
|
||||||
// Tables
|
// Tables
|
||||||
|
|
||||||
export const Tables= ['bills', 'expenses', 'invoices', 'job_cards', 'credit_notes', 'vendor_credits'] as const;
|
export const Tables= ['bills', 'expenses', 'invoices', 'job_cards', 'credit_notes', 'vendor_credits', 'estimates'] as const;
|
||||||
export type Tables = (typeof Tables)[number];
|
export type Tables = (typeof Tables)[number];
|
||||||
|
|
||||||
export const GiveDiscounts = ['no', 'line_item_level', 'transaction_level'] as const;
|
export const GiveDiscounts = ['no', 'line_item_level', 'transaction_level'] as const;
|
||||||
|
|||||||
@ -12947,19 +12947,55 @@ export interface paths {
|
|||||||
content: {
|
content: {
|
||||||
/**
|
/**
|
||||||
* @example {
|
* @example {
|
||||||
* "services_status": "accepted",
|
* "estimate_services": [
|
||||||
* "parts_status": "accepted",
|
* {
|
||||||
* "inspections_status": "accepted",
|
* "id": 11,
|
||||||
* "expense_items_status": "accepted",
|
* "status": "accepted"
|
||||||
|
* },
|
||||||
|
* {
|
||||||
|
* "id": 12,
|
||||||
|
* "status": "rejected"
|
||||||
|
* }
|
||||||
|
* ],
|
||||||
|
* "estimate_parts": [
|
||||||
|
* {
|
||||||
|
* "id": 21,
|
||||||
|
* "status": "accepted"
|
||||||
|
* }
|
||||||
|
* ],
|
||||||
|
* "estimate_inspections": [
|
||||||
|
* {
|
||||||
|
* "id": 31,
|
||||||
|
* "status": "pending"
|
||||||
|
* }
|
||||||
|
* ],
|
||||||
|
* "estimate_expense_items": [
|
||||||
|
* {
|
||||||
|
* "id": 41,
|
||||||
|
* "status": "accepted"
|
||||||
|
* }
|
||||||
|
* ],
|
||||||
* "authorisation_method": "in_person",
|
* "authorisation_method": "in_person",
|
||||||
* "employee_id": 1
|
* "employee_id": 1
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
"application/json": {
|
"application/json": {
|
||||||
services_status?: string;
|
estimate_services?: {
|
||||||
parts_status?: string;
|
id?: number;
|
||||||
inspections_status?: string;
|
status?: string;
|
||||||
expense_items_status?: string;
|
}[];
|
||||||
|
estimate_parts?: {
|
||||||
|
id?: number;
|
||||||
|
status?: string;
|
||||||
|
}[];
|
||||||
|
estimate_inspections?: {
|
||||||
|
id?: number;
|
||||||
|
status?: string;
|
||||||
|
}[];
|
||||||
|
estimate_expense_items?: {
|
||||||
|
id?: number;
|
||||||
|
status?: string;
|
||||||
|
}[];
|
||||||
authorisation_method?: string;
|
authorisation_method?: string;
|
||||||
employee_id?: number;
|
employee_id?: number;
|
||||||
};
|
};
|
||||||
@ -12982,15 +13018,15 @@ export interface paths {
|
|||||||
* "authorisation_date": "2026-04-13",
|
* "authorisation_date": "2026-04-13",
|
||||||
* "authorisation_time": "14:30:00",
|
* "authorisation_time": "14:30:00",
|
||||||
* "authorisation_method": "in_person",
|
* "authorisation_method": "in_person",
|
||||||
* "total_accepted": 8
|
* "total_accepted": 5
|
||||||
* },
|
* },
|
||||||
* "updated_counts": {
|
* "updated_counts": {
|
||||||
* "services": 3,
|
* "services": 2,
|
||||||
* "parts": 2,
|
* "parts": 1,
|
||||||
* "inspections": 1,
|
* "inspections": 1,
|
||||||
* "expense_items": 2
|
* "expense_items": 1
|
||||||
* },
|
* },
|
||||||
* "histories_count": 9
|
* "histories_count": 6
|
||||||
* }
|
* }
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user