diff --git a/.github/skills/crud-page/SKILL.md b/.github/skills/crud-page/SKILL.md index db65136..8f7c11b 100644 --- a/.github/skills/crud-page/SKILL.md +++ b/.github/skills/crud-page/SKILL.md @@ -111,13 +111,44 @@ Create `apps/dashboard/app/(authenticated)/
//page.tsx`: | Form component | `Form` | `JobCardForm` | | Page component | `Page` (default export) | `JobCardsPage` | -### Relation Fields (Foreign Keys) +### Relation Fields — Choosing the Right Component + +**Two patterns exist. Pick the right one before building any relational field.** + +#### Simple FK → `RhfAsyncSelectField` + +Use for single-record foreign keys within the same domain or to a simple lookup/reference entity. + +Examples: invoice → customer, bill → vendor, part → category, service → unit type, job card → vehicle, PO → department, any `*_type` or `*_terms` relation. - Stored in form as `{ value: string, label: string } | null` - Use `toRelation(id, name)` to convert API data → form value - Use `toId(relation)` to convert form value → API payload - Schema uses `relationFieldSchema = z.object({ value: z.string(), label: z.string() }).nullable()` -- Rendered with `` (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 + name="part_items" /> +``` ### Async Select Pattern @@ -144,8 +175,11 @@ const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) = | `RhfTextareaField` | Multi-line text | | `RhfCheckboxField` | Boolean toggles | | `RhfSelectField` | Static option dropdowns | -| `RhfAsyncSelectField` | Server-fetched single-select combobox | +| `RhfAsyncSelectField` | Server-fetched single FK combobox (same-domain relation) | | `RhfAsyncMultiSelectField` | Server-fetched multi-select combobox | +| `RhfResourceField` | Cross-domain multi-select with join table extra fields (parts, services, expenses) — see resource-selector skill | +| `RhfDateField` | Date picker — see date-time-pickers skill | +| `RhfTimeField` | Time picker — see date-time-pickers skill | ### Imports Cheat Sheet diff --git a/.github/skills/resource-filters/SKILL.md b/.github/skills/resource-filters/SKILL.md index 61f5f43..9574bcc 100644 --- a/.github/skills/resource-filters/SKILL.md +++ b/.github/skills/resource-filters/SKILL.md @@ -147,7 +147,7 @@ export function FeatureFilterFields() { name="some_relation_id" label="Some Relation" queryKey={[SOME_ROUTES.INDEX]} - listFn={() => api.someResource.list({ per_page: 100 })} + listFn={() => api.someResource.list( )} mapOption={(item: any) => ({ value: String(item.id), label: item.name })} placeholder="All" /> diff --git a/.github/skills/resource-selector/SKILL.md b/.github/skills/resource-selector/SKILL.md new file mode 100644 index 0000000..36baeaf --- /dev/null +++ b/.github/skills/resource-selector/SKILL.md @@ -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//-columns.tsx` | Shared column definitions reused by both the domain page and selector dialogs | +| `modules//-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//-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 }) => , + cell: ({ row }) => {(row.original as any).title || "—"}, + }, + purchasePrice: { + accessorKey: "purchase_price", + header: ({ column }) => , + cell: ({ row }) => { + const val = (row.original as any).purchase_price + return val != null ? `$${Number(val).toFixed(2)}` : "—" + }, + }, + // ... more columns +} satisfies Record> +// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +// CRITICAL: use `satisfies Record>` +// NOT `as ColumnDef` — 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 .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//-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, +> = { + name: TName & (TValues[TName] extends Constraint ? TName : never) + label?: string + triggerLabel?: string +} + +export function PartsSelectorField< + TValues extends FieldValues, + TName extends FieldPath, +>({ name, label = "Parts", triggerLabel = "Add Parts" }: PartsSelectorFieldProps) { + return ( + + 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 }) => ( + + + + Part + Qty + Rate + Description + + + + + {((items as PartItem[] | undefined) ?? []).map((item, index) => ( + + {item.title} + + update(index, { ...item, quantity: Number(e.target.value) || 1 } as any)} + className="h-8 w-20" /> + + + update(index, { ...item, rate: Number(e.target.value) || 0 } as any)} + className="h-8 w-24" /> + + + update(index, { ...item, description: e.target.value } as any)} + placeholder="Optional description" className="h-8" /> + + + + + + ))} + +
+ )} + /> + ) +} +``` + +### Step 4: Wire into the Form + +```tsx +// In -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 + name="part_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 : + name="part_items" /> + name="service_items" /> + name="expense_items" /> +``` + +## API Props Reference + +### `RhfResourceField` + +| Prop | Type | Required | Description | +|---|---|---|---| +| `name` | `FieldPath` | ✅ | 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>` on the columns object. Using `as ColumnDef` 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: ` name="part_items" />`. diff --git a/apps/dashboard/app/(authenticated)/sales/estimates/[id]/layout.tsx b/apps/dashboard/app/(authenticated)/sales/estimates/[id]/layout.tsx index 25e2452..a46d74a 100644 --- a/apps/dashboard/app/(authenticated)/sales/estimates/[id]/layout.tsx +++ b/apps/dashboard/app/(authenticated)/sales/estimates/[id]/layout.tsx @@ -2,6 +2,7 @@ import { DashboardDetailsPage } from '@/base/components/layout/dashboard' import { getServerApi } from '@garage/api/server' import { EstimateActions } from '@/modules/estimates/estimate-actions' import { EstimateProvider } from '@/modules/estimates/estimate-context' +import { CreateInvoiceFromEstimateButton } from '@/modules/estimates/create-invoice-from-estimate-button' import { FileTextIcon } from 'lucide-react' import React from 'react' @@ -20,7 +21,7 @@ export default async function layout(props: { : title return ( - + } @@ -29,7 +30,12 @@ export default async function layout(props: { estimateData?.date ? `Date: ${estimateData.date}` : undefined } backHref="/sales/estimates" - actions={} + actions={ +
+ + +
+ } tabs={[ { href: `/sales/estimates/${id}`, label: 'Details' }, ]} diff --git a/apps/dashboard/app/(authenticated)/sales/estimates/[id]/page.tsx b/apps/dashboard/app/(authenticated)/sales/estimates/[id]/page.tsx index 8e412cd..ff5a616 100644 --- a/apps/dashboard/app/(authenticated)/sales/estimates/[id]/page.tsx +++ b/apps/dashboard/app/(authenticated)/sales/estimates/[id]/page.tsx @@ -1,5 +1,8 @@ import { getServerApi } from '@garage/api/server' import { EstimateGeneralInfo } from '@/modules/estimates/estimate-general-info' +import { EstimateServicesSection } from '@/modules/estimates/estimate-services-section' +import { EstimatePartsSection } from '@/modules/estimates/estimate-parts-section' +import { EstimateExpenseItemsSection } from '@/modules/estimates/estimate-expense-items-section' import DashboardPage from '@/base/components/layout/dashboard/dashboard-page' export default async function page(props: { params: Promise<{ id: string }> }) { @@ -15,7 +18,13 @@ export default async function page(props: { params: Promise<{ id: string }> }) { return ( - +
+ + + + +
) } + diff --git a/apps/dashboard/modules/employees/rhf-employee-select-field.tsx b/apps/dashboard/modules/employees/rhf-employee-select-field.tsx index 532853d..c654bb4 100644 --- a/apps/dashboard/modules/employees/rhf-employee-select-field.tsx +++ b/apps/dashboard/modules/employees/rhf-employee-select-field.tsx @@ -1,8 +1,22 @@ "use client" +import { useState } from "react" import { useFormContext, useController, type FieldValues, type FieldPath } from "react-hook-form" +import { useQueryClient } from "@tanstack/react-query" +import { PlusIcon } from "lucide-react" +import { EMPLOYEE_ROUTES } from "@garage/api" +import { Field, FieldLabel, FieldError, FieldDescription } from "@/shared/components/ui/field" import { FieldShell } from "@/shared/components/form/field-shell" +import { Button } from "@/shared/components/ui/button" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/shared/components/ui/dialog" +import { ScrollArea } from "@/shared/components/ui/scroll-area" import { EmployeeCombobox } from "./employee-combobox" +import { EmployeeForm } from "./employee-form" // ── Props ── @@ -16,6 +30,8 @@ export type RhfEmployeeSelectFieldProps< required?: boolean disabled?: boolean placeholder?: string + /** Show a "+" button to create a new employee inline */ + showCreate?: boolean } // ── Component ── @@ -30,6 +46,7 @@ export function RhfEmployeeSelectField< required, disabled, placeholder = "Search by name or email...", + showCreate, }: RhfEmployeeSelectFieldProps) { const { control } = useFormContext() const { @@ -37,6 +54,69 @@ export function RhfEmployeeSelectField< fieldState: { error }, } = useController({ name, control, disabled }) + const queryClient = useQueryClient() + const [isCreateOpen, setIsCreateOpen] = useState(false) + + const handleCreateSuccess = () => { + queryClient.invalidateQueries({ queryKey: [EMPLOYEE_ROUTES.INDEX] }) + setIsCreateOpen(false) + } + + const combobox = ( + { + 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 ( + <> + + {label && ( +
+ + {label} + {required && *} + + +
+ )} + {combobox} + {description && {description}} + {error && {error.message}} +
+ + { if (!v) setIsCreateOpen(false) }}> + + + Add Employee + + + + + + + + ) + } + return ( - { - 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} - /> + {combobox} ) } diff --git a/apps/dashboard/modules/estimates/create-invoice-from-estimate-button.tsx b/apps/dashboard/modules/estimates/create-invoice-from-estimate-button.tsx new file mode 100644 index 0000000..f02048d --- /dev/null +++ b/apps/dashboard/modules/estimates/create-invoice-from-estimate-button.tsx @@ -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) { + 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 ( + <> + + + + + + Generate Invoice from Estimate + + + setOpen(false)} + /> + + + + + ) +} diff --git a/apps/dashboard/modules/estimates/estimate-actions.tsx b/apps/dashboard/modules/estimates/estimate-actions.tsx index 6a878df..0d9ad13 100644 --- a/apps/dashboard/modules/estimates/estimate-actions.tsx +++ b/apps/dashboard/modules/estimates/estimate-actions.tsx @@ -14,26 +14,197 @@ import { DialogContent, DialogHeader, DialogTitle, + DialogFooter, } from "@/shared/components/ui/dialog" import { ScrollArea } from "@/shared/components/ui/scroll-area" -import { Ellipsis, Pencil, Trash2 } from "lucide-react" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/components/ui/select" +import { Ellipsis, Pencil, Trash2, ShieldCheck, Check, X } from "lucide-react" import { useState } from "react" +import { useMutation, useQuery } from "@tanstack/react-query" +import { toast } from "sonner" import { EstimateForm } from "./estimate-form" +import { ESTIMATE_ROUTES } from "@garage/api" +import { DatePickerField, TimePickerField } from "@/shared/components/form" +import { cn } from "@/shared/lib/utils" +import { EmployeeCombobox, type EmployeeOption } from "../employees/employee-combobox" type EstimateActionsProps = { estimateId: string } +const AUTHORISATION_METHOD_OPTIONS = [ + { value: "in_person", label: "In Person" }, + { value: "phone", label: "Phone" }, + { value: "email", label: "Email" }, + { value: "online", label: "Online" }, +] + +type ServiceLine = { id: number; labor_name?: string; title?: string; quantity: number; rate: number | string; description?: string } +type PartLine = { id: number; title?: string; quantity: number; rate: number | string; description?: string } +type ExpenseLine = { id: number; item_name?: string; title?: string; quantity: number; rate: number | string; description?: string } + +function toggleStatus(current: string, action: "accepted" | "rejected"): string { + return current === action ? "pending" : action +} + +function ItemStatusRow({ + name, + rate, + quantity, + description, + status, + onToggle, +}: { + name: string + rate: number | string + quantity: number + description?: string + status: string + onToggle: (action: "accepted" | "rejected") => void +}) { + const rateNum = Number(rate) + const amount = rateNum * quantity + + return ( +
+
+

{name}

+
+ + {quantity} qty + + · + + Rate {rateNum.toFixed(2)} + + · + {amount.toFixed(2)} +
+ {description && ( +

{description}

+ )} +
+
+ + +
+
+ ) +} + +function SectionHeading({ title }: { title: string }) { + return ( +

{title}

+ ) +} + export function EstimateActions({ estimateId }: EstimateActionsProps) { const api = useAuthApi() const router = useRouter() const [editOpen, setEditOpen] = useState(false) + const [authOpen, setAuthOpen] = useState(false) + const [itemStatuses, setItemStatuses] = useState>({}) + const [authMethod, setAuthMethod] = useState("in_person") + const [employee, setEmployee] = useState(null) + const [authDate, setAuthDate] = useState("") + const [authTime, setAuthTime] = useState("") + + const { data: servicesData = [], isLoading: loadingServices } = useQuery({ + queryKey: [ESTIMATE_ROUTES.SERVICES, estimateId, "auth"], + queryFn: async () => { + const res = await api.estimates.listServices(estimateId) + return ((res as any)?.data ?? []) as ServiceLine[] + }, + enabled: authOpen, + }) + + const { data: partsData = [], isLoading: loadingParts } = useQuery({ + queryKey: [ESTIMATE_ROUTES.PARTS, estimateId, "auth"], + queryFn: async () => { + const res = await api.estimates.listParts(estimateId) + return ((res as any)?.data ?? []) as PartLine[] + }, + enabled: authOpen, + }) + + const { data: expenseItemsData = [], isLoading: loadingExpenseItems } = useQuery({ + queryKey: [ESTIMATE_ROUTES.EXPENSE_ITEMS, estimateId, "auth"], + queryFn: async () => { + const res = await api.estimates.listExpenseItems(estimateId) + return ((res as any)?.data ?? []) as ExpenseLine[] + }, + enabled: authOpen, + }) + + const isLoading = loadingServices || loadingParts || loadingExpenseItems + const hasItems = servicesData.length > 0 || partsData.length > 0 || expenseItemsData.length > 0 + + const getStatus = (key: string) => itemStatuses[key] ?? "pending" + const handleToggle = (key: string, action: "accepted" | "rejected") => + setItemStatuses((prev) => ({ ...prev, [key]: toggleStatus(prev[key] ?? "pending", action) })) const handleDelete = async () => { await api.estimates.destroy(estimateId) router.push("/sales/estimates") } + const authMutation = useMutation({ + mutationFn: () => + api.estimates.storeAuthorisation(estimateId, { + estimate_services: servicesData.map((s) => ({ id: s.id, status: getStatus(`s-${s.id}`) })), + estimate_parts: partsData.map((p) => ({ id: p.id, status: getStatus(`p-${p.id}`) })), + estimate_expense_items: expenseItemsData.map((e) => ({ id: e.id, status: getStatus(`e-${e.id}`) })), + authorisation_method: authMethod, + employee_id: employee ? Number(employee.value) : undefined, + }), + onSuccess: () => { + toast.success("Authorisation stored successfully") + setAuthOpen(false) + router.refresh() + }, + onError: () => toast.error("Failed to store authorisation"), + }) + + const openAuthDialog = () => { + setItemStatuses({}) + setAuthMethod("in_person") + setEmployee(null) + const n = new Date() + setAuthDate(n.toISOString().split("T")[0]) + setAuthTime(`${String(n.getHours()).padStart(2, "0")}:${String(n.getMinutes()).padStart(2, "0")}:00`) + setAuthOpen(true) + } + return ( <> @@ -47,6 +218,10 @@ export function EstimateActions({ estimateId }: EstimateActionsProps) { Edit + + + Store Authorisation + Delete @@ -54,6 +229,7 @@ export function EstimateActions({ estimateId }: EstimateActionsProps) { + {/* Edit Dialog */} @@ -70,6 +246,122 @@ export function EstimateActions({ estimateId }: EstimateActionsProps) { + + {/* Authorisation Dialog */} + + + + Store Authorisation + + + +
+ {isLoading && ( +

Loading line items…

+ )} + {!isLoading && !hasItems && ( +

No line items on this estimate.

+ )} + + {servicesData.length > 0 && ( +
+ + {servicesData.map((s) => ( + handleToggle(`s-${s.id}`, a)} + /> + ))} +
+ )} + + {partsData.length > 0 && ( +
+ + {partsData.map((p) => ( + handleToggle(`p-${p.id}`, a)} + /> + ))} +
+ )} + + {expenseItemsData.length > 0 && ( +
+ + {expenseItemsData.map((e) => ( + handleToggle(`e-${e.id}`, a)} + /> + ))} +
+ )} +
+
+ +
+
+
+

Date

+ setAuthDate(v ?? "")} /> +
+
+

Time

+ setAuthTime(v ?? "")} /> +
+
+

Method

+ +
+
+ +
+

Employee

+ +
+
+ + + + + +
+
) } + diff --git a/apps/dashboard/modules/estimates/estimate-context.tsx b/apps/dashboard/modules/estimates/estimate-context.tsx index cdd5cad..0851ce5 100644 --- a/apps/dashboard/modules/estimates/estimate-context.tsx +++ b/apps/dashboard/modules/estimates/estimate-context.tsx @@ -2,9 +2,10 @@ import { createContext, useContext } from "react" -type EstimateContextValue = { +export type EstimateContextValue = { id: string label: string + data?: Record } const EstimateContext = createContext(null) diff --git a/apps/dashboard/modules/estimates/estimate-expense-item-config-form.tsx b/apps/dashboard/modules/estimates/estimate-expense-item-config-form.tsx new file mode 100644 index 0000000..e46991a --- /dev/null +++ b/apps/dashboard/modules/estimates/estimate-expense-item-config-form.tsx @@ -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 + +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({ + resolver: zodResolver(schema) as any, + defaultValues: { + quantity: 1, + rate: expenseItem.purchase_price != null ? String(expenseItem.purchase_price) : "", + description: "", + }, + }) + + const [error, setError] = React.useState(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 ( + + {error && ( + + + Failed to add expense item + {error} + + )} + + +
+ {expenseItem.name ?? `Expense Item #${expenseItem.id}`} +
+ +
+ + +
+ + api.departments.list()} + mapOption={(item: any) => ({ + value: String(item.id), + label: item.name ?? item.title ?? String(item.id), + })} + {...STORE_OBJECT} + /> + + + +
+ {onCancel && ( + + )} + +
+
+
+ ) +} diff --git a/apps/dashboard/modules/estimates/estimate-expense-items-section.tsx b/apps/dashboard/modules/estimates/estimate-expense-items-section.tsx new file mode 100644 index 0000000..16a9228 --- /dev/null +++ b/apps/dashboard/modules/estimates/estimate-expense-items-section.tsx @@ -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(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 ( + + + Expense Items + + + + {isLoading &&

Loading...

} + {!isLoading && items.length === 0 && ( +

No expense items added yet.

+ )} + {items.length > 0 && ( + + + + Item + Qty + Rate + Description + + + + + {items.map((item) => ( + + {getDisplayName(item)} + + + updateMutation.mutate({ + lineId: String(item.id), + payload: { quantity: Number(e.target.value) || 1 }, + }) + } + className="h-8 w-20" + /> + + + {Number(item.rate).toFixed(2)} + + + {item.description || "—"} + + + + + + ))} + +
+ )} +
+ + {/* Step 1: Pick an expense item (single-select) */} + + 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 */} + { if (!v) setConfigExpenseItem(null) }}> + + + Configure Expense Item + + + {configExpenseItem && ( + { + setConfigExpenseItem(null) + invalidate() + }} + onCancel={() => setConfigExpenseItem(null)} + /> + )} + + + +
+ ) +} diff --git a/apps/dashboard/modules/estimates/estimate-part-config-form.tsx b/apps/dashboard/modules/estimates/estimate-part-config-form.tsx new file mode 100644 index 0000000..29158cf --- /dev/null +++ b/apps/dashboard/modules/estimates/estimate-part-config-form.tsx @@ -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 + +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({ + 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(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 ( + + {error && ( + + + Failed to add part + {error} + + )} + + +
+ {part.title ?? `Part #${part.id}`} +
+ +
+ + +
+ + + + api.departments.list()} + mapOption={(item: any) => ({ + value: String(item.id), + label: item.name ?? item.title ?? String(item.id), + })} + {...STORE_OBJECT} + /> + +
+ {onCancel && ( + + )} + +
+
+
+ ) +} diff --git a/apps/dashboard/modules/estimates/estimate-parts-section.tsx b/apps/dashboard/modules/estimates/estimate-parts-section.tsx new file mode 100644 index 0000000..7b3745a --- /dev/null +++ b/apps/dashboard/modules/estimates/estimate-parts-section.tsx @@ -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(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 ( + + + Parts + + + + {isLoading &&

Loading...

} + {!isLoading && items.length === 0 && ( +

No parts added yet.

+ )} + {items.length > 0 && ( + + + + Part + Qty + Rate + Description + + + + + {items.map((item) => ( + + {getDisplayName(item)} + + + updateMutation.mutate({ + lineId: String(item.id), + payload: { quantity: Number(e.target.value) || 1 }, + }) + } + className="h-8 w-20" + /> + + + {Number(item.rate).toFixed(2)} + + + {item.description || "—"} + + + + + + ))} + +
+ )} +
+ + {/* Step 1: Pick a part (single-select) */} + + 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 */} + { if (!v) setConfigPart(null) }}> + + + Configure Part + + + {configPart && ( + { + setConfigPart(null) + invalidate() + }} + onCancel={() => setConfigPart(null)} + /> + )} + + + +
+ ) +} diff --git a/apps/dashboard/modules/estimates/estimate-service-config-form.tsx b/apps/dashboard/modules/estimates/estimate-service-config-form.tsx new file mode 100644 index 0000000..14da372 --- /dev/null +++ b/apps/dashboard/modules/estimates/estimate-service-config-form.tsx @@ -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 + +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({ + resolver: zodResolver(schema) as any, + defaultValues: { + ...DEFAULT_VALUES, + rate: service.selling_price != null ? String(service.selling_price) : "", + }, + }) + + const [error, setError] = React.useState(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 ( + + {error && ( + + + Failed to add service + {error} + + )} + + +
+ {service.labor_name ?? `Service #${service.id}`} +
+ +
+ + +
+ +
+ + api.inventory.listLaborRates()} + mapOption={(item: any) => ({ + value: String(item.id), + label: item.title ?? String(item.id), + })} + {...STORE_OBJECT} + /> +
+ +
+ + +
+ +
+ api.departments.list()} + mapOption={(item: any) => ({ + value: String(item.id), + label: item.name ?? String(item.id), + })} + {...STORE_OBJECT} + /> + +
+ + + + + +
+ {onCancel && ( + + )} + +
+
+
+ ) +} diff --git a/apps/dashboard/modules/estimates/estimate-services-section.tsx b/apps/dashboard/modules/estimates/estimate-services-section.tsx new file mode 100644 index 0000000..0f08570 --- /dev/null +++ b/apps/dashboard/modules/estimates/estimate-services-section.tsx @@ -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(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 ( + + + Services + + + + {isLoading &&

Loading...

} + {!isLoading && items.length === 0 && ( +

No services added yet.

+ )} + {items.length > 0 && ( + + + + Service + Qty + Rate + Description + + + + + {items.map((item) => ( + + {getDisplayName(item)} + + + updateMutation.mutate({ + lineId: String(item.id), + payload: { quantity: Number(e.target.value) || 1 }, + }) + } + className="h-8 w-20" + /> + + + + updateMutation.mutate({ + lineId: String(item.id), + payload: { rate: e.target.value }, + }) + } + className="h-8 w-24" + /> + + + {item.description || "—"} + + + + + + ))} + +
+ )} +
+ + {/* Step 1: Pick a service (single-select) */} + + 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 */} + { if (!v) setConfigService(null) }}> + + + Configure Service + + + {configService && ( + { + setConfigService(null) + invalidate() + }} + onCancel={() => setConfigService(null)} + /> + )} + + + +
+ ) +} + + \ No newline at end of file diff --git a/apps/dashboard/modules/job-cards/job-card-dropdown.tsx b/apps/dashboard/modules/job-cards/job-card-dropdown.tsx index ccf7e31..f81861e 100644 --- a/apps/dashboard/modules/job-cards/job-card-dropdown.tsx +++ b/apps/dashboard/modules/job-cards/job-card-dropdown.tsx @@ -6,13 +6,21 @@ import { useRouter } from 'next/dist/client/components/navigation'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/shared/components/ui/dropdown-menu' import { Button } from '@/shared/components/ui/button' import { toast } from 'sonner' -import { Ellipsis, Pencil, Trash2 } from 'lucide-react'; +import { Ellipsis, Pencil, Printer, Trash2 } from 'lucide-react'; +import { useDocumentPrint } from '@/shared/hooks/use-document-print'; + export default function JobCardDropdown({ id }: { id: string }) { const router = useRouter(); + const { print, isPrinting } = useDocumentPrint() + const handleEdit = () => { router.push(`/sales/job-cards/${id}/edit`) } + const handlePrint = () => { + print("job_card", id, "print") + } + const handleDelete = async () => { const confirmed = await confirm({ title: "Delete Job Card", @@ -45,6 +53,10 @@ export default function JobCardDropdown({ id }: { id: string }) { Edit + + + {isPrinting ? "Printing..." : "Print"} + diff --git a/apps/dashboard/modules/job-cards/job-card-filters.tsx b/apps/dashboard/modules/job-cards/job-card-filters.tsx index e9e2806..7ce53b1 100644 --- a/apps/dashboard/modules/job-cards/job-card-filters.tsx +++ b/apps/dashboard/modules/job-cards/job-card-filters.tsx @@ -190,7 +190,7 @@ export function JobCardFilterFields() { name="customer_id" label="Customer" queryKey={[CUSTOMER_ROUTES.INDEX]} - listFn={() => api.customers.list({ per_page: 100 })} + listFn={() => api.customers.list( )} mapOption={customerMapOption} placeholder="All customers" /> @@ -199,7 +199,7 @@ export function JobCardFilterFields() { name="vehicle_id" label="Vehicle" queryKey={[VEHICLE_ROUTES.INDEX]} - listFn={() => api.vehicles.list({ per_page: 100 })} + listFn={() => api.vehicles.list( )} mapOption={vehicleMapOption} placeholder="All vehicles" /> @@ -208,7 +208,7 @@ export function JobCardFilterFields() { name="insurer_id" label="Insurance Type" queryKey={[INSURANCE_TYPE_ROUTES.INDEX]} - listFn={() => api.insuranceTypes.list({ per_page: 100 })} + listFn={() => api.insuranceTypes.list( )} mapOption={(item: any) => ({ value: String(item.id), label: item.title ?? item.name ?? `#${item.id}` })} placeholder="All insurers" /> @@ -217,7 +217,7 @@ export function JobCardFilterFields() { name="department_id" label="Department" queryKey={[DEPARTMENT_ROUTES.INDEX]} - listFn={() => api.departments.list({ per_page: 100 })} + listFn={() => api.departments.list( )} mapOption={(item: any) => ({ value: String(item.id), label: item.title ?? item.name ?? `#${item.id}` })} placeholder="All departments" /> @@ -229,7 +229,7 @@ export function JobCardFilterFields() { name="sales_person_id" label="Sales Person" queryKey={[EMPLOYEE_ROUTES.INDEX, "sales_person"]} - listFn={() => api.employees.list({ per_page: 100 })} + listFn={() => api.employees.list( )} mapOption={employeeMapOption} placeholder="All sales persons" /> @@ -238,7 +238,7 @@ export function JobCardFilterFields() { name="primary_technician_id" label="Primary Technician" queryKey={[EMPLOYEE_ROUTES.INDEX, "technician"]} - listFn={() => api.employees.list({ per_page: 100 })} + listFn={() => api.employees.list( )} mapOption={employeeMapOption} placeholder="All technicians" /> @@ -247,7 +247,7 @@ export function JobCardFilterFields() { name="service_writer_id" label="Service Writer" queryKey={[EMPLOYEE_ROUTES.INDEX, "service_writer"]} - listFn={() => api.employees.list({ per_page: 100 })} + listFn={() => api.employees.list( )} mapOption={employeeMapOption} placeholder="All service writers" /> diff --git a/apps/dashboard/modules/job-cards/job-card-form.tsx b/apps/dashboard/modules/job-cards/job-card-form.tsx index 1fc35fe..4031a7d 100644 --- a/apps/dashboard/modules/job-cards/job-card-form.tsx +++ b/apps/dashboard/modules/job-cards/job-card-form.tsx @@ -33,6 +33,7 @@ import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-fie import { RhfLabelPickerField } from "@/modules/labels/rhf-label-picker-field" import { RhfCustomerRemarksField } from "@/modules/estimates/rhf-customer-remarks-field" import { InsuranceTypeCrudDialog } from "./insurance-type-crud-dialog" +import { RhfEmployeeSelectField } from "../employees/rhf-employee-select-field" // ── Props ── @@ -282,22 +283,18 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP
- api.employees.list()} - mapOption={mapEmployeeOption} {...STORE_OBJECT} - /> - + api.employees.list()} - mapOption={mapEmployeeOption} {...STORE_OBJECT} />
diff --git a/apps/dashboard/shared/components/resource-selector/resource-selector-dialog.tsx b/apps/dashboard/shared/components/resource-selector/resource-selector-dialog.tsx index f481ebc..6946f60 100644 --- a/apps/dashboard/shared/components/resource-selector/resource-selector-dialog.tsx +++ b/apps/dashboard/shared/components/resource-selector/resource-selector-dialog.tsx @@ -25,6 +25,12 @@ export type ResourceSelectorDialogProps = { crudProps: Omit, "render" | "tableProps"> /** Optional rowKey for selection identity. Defaults to "id" */ rowKey?: keyof ResourceItem + /** + * 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({ @@ -34,6 +40,7 @@ export function ResourceSelectorDialog({ onConfirm, crudProps, rowKey, + selectionMode = "multi", }: ResourceSelectorDialogProps) { type TItem = ResourceItem @@ -58,6 +65,11 @@ export function ResourceSelectorDialog({ onOpenChange(false) } + const handleRowClick = (row: TItem) => { + onConfirm([row]) + onOpenChange(false) + } + return ( { if (!v) handleCancel() }}> @@ -67,26 +79,35 @@ export function ResourceSelectorDialog({
- - {...crudProps} - tableProps={{ - selection: { - onSelectionChange: handleSelectionChange, - ...(rowKey ? { rowKey } : {}), - }, - }} - /> + {selectionMode === "single" ? ( + + {...crudProps} + onRowClick={handleRowClick} + /> + ) : ( + + {...crudProps} + tableProps={{ + selection: { + onSelectionChange: handleSelectionChange, + ...(rowKey ? { rowKey } : {}), + }, + }} + /> + )}
- - - - + {selectionMode === "multi" && ( + + + + + )}
) diff --git a/apps/dashboard/shared/hooks/use-document-print.ts b/apps/dashboard/shared/hooks/use-document-print.ts new file mode 100644 index 0000000..bea14c5 --- /dev/null +++ b/apps/dashboard/shared/hooks/use-document-print.ts @@ -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 } +} diff --git a/packages/api/open-api/schema.json b/packages/api/open-api/schema.json index ccd71b1..bab8e16 100644 --- a/packages/api/open-api/schema.json +++ b/packages/api/open-api/schema.json @@ -19740,17 +19740,61 @@ "schema": { "type": "object", "properties": { - "services_status": { - "type": "string" + "estimate_services": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "status": { + "type": "string" + } + } + } }, - "parts_status": { - "type": "string" + "estimate_parts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "status": { + "type": "string" + } + } + } }, - "inspections_status": { - "type": "string" + "estimate_inspections": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "status": { + "type": "string" + } + } + } }, - "expense_items_status": { - "type": "string" + "estimate_expense_items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "status": { + "type": "string" + } + } + } }, "authorisation_method": { "type": "string" @@ -19761,10 +19805,34 @@ } }, "example": { - "services_status": "accepted", - "parts_status": "accepted", - "inspections_status": "accepted", - "expense_items_status": "accepted", + "estimate_services": [ + { + "id": 11, + "status": "accepted" + }, + { + "id": 12, + "status": "rejected" + } + ], + "estimate_parts": [ + { + "id": 21, + "status": "accepted" + } + ], + "estimate_inspections": [ + { + "id": 31, + "status": "pending" + } + ], + "estimate_expense_items": [ + { + "id": 41, + "status": "accepted" + } + ], "authorisation_method": "in_person", "employee_id": 1 } @@ -19851,15 +19919,15 @@ "authorisation_date": "2026-04-13", "authorisation_time": "14:30:00", "authorisation_method": "in_person", - "total_accepted": 8 + "total_accepted": 5 }, "updated_counts": { - "services": 3, - "parts": 2, + "services": 2, + "parts": 1, "inspections": 1, - "expense_items": 2 + "expense_items": 1 }, - "histories_count": 9 + "histories_count": 6 } } } diff --git a/packages/api/postman/collection.json b/packages/api/postman/collection.json index e90f261..aec8e44 100644 --- a/packages/api/postman/collection.json +++ b/packages/api/postman/collection.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "90943e73-b99e-468b-b10b-d3a02f751da9", + "_postman_id": "351b2d30-cb82-4904-9a47-0d356181a8ea", "name": "Reparee Collection", "description": "Auto-generated from OpenAPI spec. Import storage/app/openapi-default.json for the full schema.", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", @@ -13384,7 +13384,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"services_status\": \"accepted\",\n \"parts_status\": \"accepted\",\n \"inspections_status\": \"accepted\",\n \"expense_items_status\": \"accepted\",\n \"authorisation_method\": \"in_person\",\n \"employee_id\": 1\n}", + "raw": "{\n \"estimate_services\": [\n {\n \"id\": 11,\n \"status\": \"accepted\"\n },\n {\n \"id\": 12,\n \"status\": \"rejected\"\n }\n ],\n \"estimate_parts\": [\n {\n \"id\": 21,\n \"status\": \"accepted\"\n }\n ],\n \"estimate_inspections\": [\n {\n \"id\": 31,\n \"status\": \"pending\"\n }\n ],\n \"estimate_expense_items\": [\n {\n \"id\": 41,\n \"status\": \"accepted\"\n }\n ],\n \"authorisation_method\": \"in_person\",\n \"employee_id\": 1\n}", "options": { "raw": { "language": "json" @@ -13431,7 +13431,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"services_status\": \"accepted\",\n \"parts_status\": \"accepted\",\n \"inspections_status\": \"accepted\",\n \"expense_items_status\": \"accepted\",\n \"authorisation_method\": \"in_person\",\n \"employee_id\": 1\n}", + "raw": "{\n \"estimate_services\": [\n {\n \"id\": 11,\n \"status\": \"accepted\"\n },\n {\n \"id\": 12,\n \"status\": \"rejected\"\n }\n ],\n \"estimate_parts\": [\n {\n \"id\": 21,\n \"status\": \"accepted\"\n }\n ],\n \"estimate_inspections\": [\n {\n \"id\": 31,\n \"status\": \"pending\"\n }\n ],\n \"estimate_expense_items\": [\n {\n \"id\": 41,\n \"status\": \"accepted\"\n }\n ],\n \"authorisation_method\": \"in_person\",\n \"employee_id\": 1\n}", "options": { "raw": { "language": "json" @@ -13461,7 +13461,7 @@ } ], "cookie": [], - "body": "{\n \"message\": \"Estimate authorisation stored successfully\",\n \"data\": {\n \"estimate\": {\n \"id\": 1,\n \"is_authorisation\": true,\n \"authorisation_date\": \"2026-04-13\",\n \"authorisation_time\": \"14:30:00\",\n \"authorisation_method\": \"in_person\",\n \"total_accepted\": 8\n },\n \"updated_counts\": {\n \"services\": 3,\n \"parts\": 2,\n \"inspections\": 1,\n \"expense_items\": 2\n },\n \"histories_count\": 9\n }\n}" + "body": "{\n \"message\": \"Estimate authorisation stored successfully\",\n \"data\": {\n \"estimate\": {\n \"id\": 1,\n \"is_authorisation\": true,\n \"authorisation_date\": \"2026-04-13\",\n \"authorisation_time\": \"14:30:00\",\n \"authorisation_method\": \"in_person\",\n \"total_accepted\": 5\n },\n \"updated_counts\": {\n \"services\": 2,\n \"parts\": 1,\n \"inspections\": 1,\n \"expense_items\": 1\n },\n \"histories_count\": 6\n }\n}" } ] }, diff --git a/packages/api/src/api.ts b/packages/api/src/api.ts index 1544d11..b547622 100644 --- a/packages/api/src/api.ts +++ b/packages/api/src/api.ts @@ -56,6 +56,7 @@ import { ConfigurationsClient } from "./clients/configurations" import { AutoGenerateClient } from "./clients/auto-generate" import { ExpenseItemsClient } from "./clients/expense-items" import { InventoryCategoriesClient } from "./clients/inventory-categories" +import { DocumentPrintClient } from "./clients/document-print" export function createApi(options?: ApiClientOptions) { return { @@ -116,6 +117,7 @@ export function createApi(options?: ApiClientOptions) { autoGenerate: new AutoGenerateClient(undefined, options), expenseItems: new ExpenseItemsClient(undefined, options), inventoryCategories: new InventoryCategoriesClient(undefined, options), + documentPrint: new DocumentPrintClient(undefined, options), } } diff --git a/packages/api/src/clients/document-print.ts b/packages/api/src/clients/document-print.ts new file mode 100644 index 0000000..09f7d25 --- /dev/null +++ b/packages/api/src/clients/document-print.ts @@ -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 + +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 { + return this.fetchBlob(DOCUMENT_PRINT_ROUTES.INDEX, { + method: "POST", + body: { type, id: Number(id), mode }, + }) + } +} diff --git a/packages/api/src/clients/estimates.ts b/packages/api/src/clients/estimates.ts index b023c32..d8f7b09 100644 --- a/packages/api/src/clients/estimates.ts +++ b/packages/api/src/clients/estimates.ts @@ -5,6 +5,13 @@ import type { ApiPath } from "../infra/types" export const ESTIMATE_ROUTES = { INDEX: "/api/estimates", BY_ID: "/api/estimates/{id}", + SERVICES: "/api/estimate/{id}/services", + SERVICE_BY_ID: "/api/estimate/{id}/services/{service_id}", + PARTS: "/api/estimate/{id}/parts", + PART_BY_ID: "/api/estimate/{id}/parts/{part_id}", + EXPENSE_ITEMS: "/api/estimate/{id}/expense-items", + EXPENSE_ITEM_BY_ID: "/api/estimate/{id}/expense-items/{expense_item_id}", + STORE_AUTHORISATION: "/api/estimates/{id}/store-authorisation", } as const satisfies Record export class EstimatesClient extends CrudClient< @@ -21,4 +28,67 @@ export class EstimatesClient extends CrudClient< const data = await this.get(ESTIMATE_ROUTES.INDEX, { query: { id } } as never) return {...data, data: (data as any)?.data?.[0] ?? null } } + + // ── Estimate Services ── + async listServices(estimateId: string) { + return this.get(ESTIMATE_ROUTES.SERVICES, { params: { id: estimateId } } as never) + } + + async addService(estimateId: string, payload: { service_id?: number; rate_type?: string; quantity?: number; rate?: string; description?: string; labor_rate_id?: number; working_hours?: string | number; labor_hours?: string | number; tax?: string; chart_of_account?: string; department_id?: number }) { + return this.post(ESTIMATE_ROUTES.SERVICES, payload as never, { params: { id: estimateId } } as never) + } + + async updateService(estimateId: string, serviceId: string, payload: { rate?: string; quantity?: number }) { + return this.put(ESTIMATE_ROUTES.SERVICE_BY_ID, payload as never, { params: { id: estimateId, service_id: serviceId } } as never) + } + + async removeService(estimateId: string, serviceId: string) { + return this.delete(ESTIMATE_ROUTES.SERVICE_BY_ID, { params: { id: estimateId, service_id: serviceId } } as never) + } + + // ── Estimate Parts ── + async listParts(estimateId: string) { + return this.get(ESTIMATE_ROUTES.PARTS, { params: { id: estimateId } } as never) + } + + async addPart(estimateId: string, payload: { part_id?: number; quantity?: number; rate?: string; description?: string }) { + return this.post(ESTIMATE_ROUTES.PARTS, payload as never, { params: { id: estimateId } } as never) + } + + async updatePart(estimateId: string, partId: string, payload: { quantity?: number }) { + return this.put(ESTIMATE_ROUTES.PART_BY_ID, payload as never, { params: { id: estimateId, part_id: partId } } as never) + } + + async removePart(estimateId: string, partId: string) { + return this.delete(ESTIMATE_ROUTES.PART_BY_ID, { params: { id: estimateId, part_id: partId } } as never) + } + + // ── Estimate Expense Items ── + async listExpenseItems(estimateId: string) { + return this.get(ESTIMATE_ROUTES.EXPENSE_ITEMS, { params: { id: estimateId } } as never) + } + + async addExpenseItem(estimateId: string, payload: { expense_item_id?: number; quantity?: number; rate?: string; description?: string }) { + return this.post(ESTIMATE_ROUTES.EXPENSE_ITEMS, payload as never, { params: { id: estimateId } } as never) + } + + async updateExpenseItem(estimateId: string, expenseItemId: string, payload: { quantity?: number }) { + return this.put(ESTIMATE_ROUTES.EXPENSE_ITEM_BY_ID, payload as never, { params: { id: estimateId, expense_item_id: expenseItemId } } as never) + } + + async removeExpenseItem(estimateId: string, expenseItemId: string) { + return this.delete(ESTIMATE_ROUTES.EXPENSE_ITEM_BY_ID, { params: { id: estimateId, expense_item_id: expenseItemId } } as never) + } + + // ── Authorisation ── + async storeAuthorisation(estimateId: string, payload: { + estimate_services?: { id: number; status: string }[] + estimate_parts?: { id: number; status: string }[] + estimate_inspections?: { id: number; status: string }[] + estimate_expense_items?: { id: number; status: string }[] + authorisation_method?: string + employee_id?: number + }) { + return this.post(ESTIMATE_ROUTES.STORE_AUTHORISATION, payload as never, { params: { id: estimateId } } as never) + } } diff --git a/packages/api/src/clients/index.ts b/packages/api/src/clients/index.ts index 23d0183..4262e1b 100644 --- a/packages/api/src/clients/index.ts +++ b/packages/api/src/clients/index.ts @@ -38,6 +38,7 @@ export { InvoicesClient, INVOICE_ROUTES } from "./invoices" export { HomeClient, HOME_ROUTES, type HomeDashboardResponse } from "./home" export { BillsClient, BILL_ROUTES } from "./bills" export { ReasonsClient, REASON_ROUTES } from "./reasons" +export { DocumentPrintClient, DOCUMENT_PRINT_ROUTES, type DocumentPrintType, type DocumentPrintMode } from "./document-print" export { HolidaysClient, HOLIDAY_ROUTES } from "./holidays" export { MakeAndModelsClient, MAKE_AND_MODEL_ROUTES } from "./make-and-models" export { TimeSheetsClient, TIME_SHEET_ROUTES } from "./time-sheets" diff --git a/packages/api/src/contracts/enums.ts b/packages/api/src/contracts/enums.ts index 9efad80..713354d 100644 --- a/packages/api/src/contracts/enums.ts +++ b/packages/api/src/contracts/enums.ts @@ -109,7 +109,7 @@ export const SellRatesTaxInclusive = ['Tax Inclusive', 'Tax Exclusive'] as const export type SellRatesTaxInclusive = (typeof SellRatesTaxInclusive)[number]; // Tables -export const Tables= ['bills', 'expenses', 'invoices', 'job_cards', 'credit_notes', 'vendor_credits'] as const; +export const Tables= ['bills', 'expenses', 'invoices', 'job_cards', 'credit_notes', 'vendor_credits', 'estimates'] as const; export type Tables = (typeof Tables)[number]; export const GiveDiscounts = ['no', 'line_item_level', 'transaction_level'] as const; diff --git a/packages/api/types/index.ts b/packages/api/types/index.ts index ae36c4e..059fc63 100644 --- a/packages/api/types/index.ts +++ b/packages/api/types/index.ts @@ -12947,19 +12947,55 @@ export interface paths { content: { /** * @example { - * "services_status": "accepted", - * "parts_status": "accepted", - * "inspections_status": "accepted", - * "expense_items_status": "accepted", + * "estimate_services": [ + * { + * "id": 11, + * "status": "accepted" + * }, + * { + * "id": 12, + * "status": "rejected" + * } + * ], + * "estimate_parts": [ + * { + * "id": 21, + * "status": "accepted" + * } + * ], + * "estimate_inspections": [ + * { + * "id": 31, + * "status": "pending" + * } + * ], + * "estimate_expense_items": [ + * { + * "id": 41, + * "status": "accepted" + * } + * ], * "authorisation_method": "in_person", * "employee_id": 1 * } */ "application/json": { - services_status?: string; - parts_status?: string; - inspections_status?: string; - expense_items_status?: string; + estimate_services?: { + id?: number; + status?: string; + }[]; + estimate_parts?: { + id?: number; + status?: string; + }[]; + estimate_inspections?: { + id?: number; + status?: string; + }[]; + estimate_expense_items?: { + id?: number; + status?: string; + }[]; authorisation_method?: string; employee_id?: number; }; @@ -12982,15 +13018,15 @@ export interface paths { * "authorisation_date": "2026-04-13", * "authorisation_time": "14:30:00", * "authorisation_method": "in_person", - * "total_accepted": 8 + * "total_accepted": 5 * }, * "updated_counts": { - * "services": 3, - * "parts": 2, + * "services": 2, + * "parts": 1, * "inspections": 1, - * "expense_items": 2 + * "expense_items": 1 * }, - * "histories_count": 9 + * "histories_count": 6 * } * } */