update
This commit is contained in:
parent
38ef10da4d
commit
90c84a0bda
@ -63,6 +63,10 @@ export default async function JobCardDetailLayout(props: { params: Promise<{ id:
|
|||||||
href: `/sales/job-cards/${id}/parts`,
|
href: `/sales/job-cards/${id}/parts`,
|
||||||
label: `Parts (${jobCard?.parts_count || 0})`
|
label: `Parts (${jobCard?.parts_count || 0})`
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: `/sales/job-cards/${id}/services`,
|
||||||
|
label: `Services`
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
|
|||||||
@ -0,0 +1,228 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { use, useState } from "react"
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
|
import { ColumnHeader, DataTable } from "@/shared/data-view/table-view"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/shared/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/shared/components/ui/dropdown-menu"
|
||||||
|
import { confirm } from "@/shared/components/confirm-dialog"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { Ellipsis, Plus } from "lucide-react"
|
||||||
|
import type { ColumnDef } from "@tanstack/react-table"
|
||||||
|
import { JobCardServiceForm } from "@/modules/job-cards/job-card-service-form"
|
||||||
|
import { formatDate } from "@/shared/utils/formatters"
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
|
||||||
|
export default function JobCardServicesPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}) {
|
||||||
|
const { id: jobCardId } = use(params)
|
||||||
|
const api = useAuthApi()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const queryKey = ["job-card-services", jobCardId]
|
||||||
|
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [editItem, setEditItem] = useState<any | null>(null)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey,
|
||||||
|
queryFn: () => api.jobCards.getServices(jobCardId),
|
||||||
|
})
|
||||||
|
|
||||||
|
const rows = (data as any)?.data ?? []
|
||||||
|
|
||||||
|
const invalidate = () => queryClient.invalidateQueries({ queryKey })
|
||||||
|
|
||||||
|
async function handleDelete(row: any) {
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: "Delete this service?",
|
||||||
|
description: `Remove service "${row.service?.labor_name ?? "this service"}" from the job card?`,
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
const promise = api.jobCards.deleteService(jobCardId, row.id)
|
||||||
|
toast.promise(promise, {
|
||||||
|
loading: "Deleting...",
|
||||||
|
success: "Service deleted",
|
||||||
|
error: "Failed to delete service",
|
||||||
|
})
|
||||||
|
await promise
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnDef<any>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "service.labor_name",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Service" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const service = row.original.service
|
||||||
|
return service ? (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">{service.labor_name}</span>
|
||||||
|
{service.service_code && (
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">{service.service_code}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : "—"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "rate_type",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Rate Type" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const val = row.original.rate_type
|
||||||
|
if (!val) return "—"
|
||||||
|
return (
|
||||||
|
<Badge variant="outline">
|
||||||
|
{val === "flat_rate" ? "Flat Rate" : val === "hourly" ? "Hourly" : val}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "quantity",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Qty" />,
|
||||||
|
cell: ({ row }) => row.original.quantity ?? "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "rate",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Rate" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const val = row.original.rate
|
||||||
|
return val != null ? `$${Number(val).toFixed(2)}` : "—"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "labor_rate.title",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Labor Rate" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const lr = row.original.labor_rate
|
||||||
|
return lr ? `${lr.title} ($${Number(lr.rate).toFixed(2)})` : "—"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "working_hours",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Working Hrs" />,
|
||||||
|
cell: ({ row }) => row.original.working_hours ?? "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "labor_hours",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Labor Hrs" />,
|
||||||
|
cell: ({ row }) => row.original.labor_hours ?? "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "tax",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Tax" />,
|
||||||
|
cell: ({ row }) => row.original.tax || "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "department.name",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Department" />,
|
||||||
|
cell: ({ row }) => row.original.department?.name || "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "description",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Description" />,
|
||||||
|
cell: ({ row }) => row.original.description || "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "created_at",
|
||||||
|
header: ({ column }) => <ColumnHeader column={column} title="Added" />,
|
||||||
|
cell: ({ row }) => formatDate(row.original.created_at),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Ellipsis className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setEditItem(row.original)
|
||||||
|
setDialogOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => handleDelete(row.original)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 p-4">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Dialog
|
||||||
|
open={dialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setDialogOpen(open)
|
||||||
|
if (!open) setEditItem(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button onClick={() => setEditItem(null)}>
|
||||||
|
<Plus className="me-2 h-4 w-4" />
|
||||||
|
Add Service
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editItem ? "Edit Service" : "Add Service"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<JobCardServiceForm
|
||||||
|
jobCardId={jobCardId}
|
||||||
|
jobCardServiceId={editItem?.id ?? null}
|
||||||
|
initialData={editItem}
|
||||||
|
onSuccess={() => {
|
||||||
|
setDialogOpen(false)
|
||||||
|
setEditItem(null)
|
||||||
|
invalidate()
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setDialogOpen(false)
|
||||||
|
setEditItem(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={rows}
|
||||||
|
pagination={{
|
||||||
|
page: 1,
|
||||||
|
pageSize: rows.length || 15,
|
||||||
|
pageCount: 1,
|
||||||
|
total: rows.length,
|
||||||
|
}}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
310
apps/dashboard/modules/job-cards/job-card-service-form.tsx
Normal file
310
apps/dashboard/modules/job-cards/job-card-service-form.tsx
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import React from "react"
|
||||||
|
import { AlertTriangle } from "lucide-react"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
|
||||||
|
import { FieldGroup } from "@/shared/components/ui/field"
|
||||||
|
import {
|
||||||
|
Rhform,
|
||||||
|
RhfTextField,
|
||||||
|
RhfTextareaField,
|
||||||
|
RhfSelectField,
|
||||||
|
RhfAsyncSelectField,
|
||||||
|
} from "@/shared/components/form"
|
||||||
|
import { DepartmentInlineForm } from "@/modules/services/inline-forms/department-inline-form"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { RateType } from "@garage/api"
|
||||||
|
|
||||||
|
// ── Schema ──
|
||||||
|
|
||||||
|
const jobCardServiceFormSchema = z.object({
|
||||||
|
service: z
|
||||||
|
.object({ value: z.string(), label: z.string() })
|
||||||
|
.nullable(),
|
||||||
|
department: z
|
||||||
|
.object({ value: z.string(), label: z.string() })
|
||||||
|
.nullable()
|
||||||
|
.optional(),
|
||||||
|
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.coerce.number().min(0, "Rate is required"),
|
||||||
|
working_hours: z.coerce.number().min(0).optional(),
|
||||||
|
labor_hours: z.coerce.number().min(0).optional(),
|
||||||
|
tax: z.string().optional(),
|
||||||
|
chart_of_account: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
type JobCardServiceFormValues = z.infer<typeof jobCardServiceFormSchema>
|
||||||
|
|
||||||
|
// ── Props ──
|
||||||
|
|
||||||
|
export type JobCardServiceFormProps = {
|
||||||
|
jobCardId: string
|
||||||
|
jobCardServiceId?: number | null
|
||||||
|
initialData?: unknown
|
||||||
|
onSuccess?: () => void
|
||||||
|
onCancel?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_VALUES: JobCardServiceFormValues = {
|
||||||
|
service: null,
|
||||||
|
department: null,
|
||||||
|
rate_type: "flat_rate",
|
||||||
|
labor_rate: null,
|
||||||
|
quantity: 1,
|
||||||
|
rate: 0,
|
||||||
|
working_hours: 0,
|
||||||
|
labor_hours: 0,
|
||||||
|
tax: "",
|
||||||
|
chart_of_account: "",
|
||||||
|
description: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||||
|
|
||||||
|
function mapToFormValues(data: unknown): JobCardServiceFormValues {
|
||||||
|
const d = (data as any) ?? {}
|
||||||
|
return {
|
||||||
|
service: d.service
|
||||||
|
? { value: String(d.service.id), label: d.service.labor_name ?? String(d.service.id) }
|
||||||
|
: null,
|
||||||
|
department: d.department
|
||||||
|
? { value: String(d.department.id), label: d.department.name ?? String(d.department.id) }
|
||||||
|
: null,
|
||||||
|
rate_type: d.rate_type ?? "flat_rate",
|
||||||
|
labor_rate: d.labor_rate
|
||||||
|
? { value: String(d.labor_rate.id), label: d.labor_rate.title ?? String(d.labor_rate.id) }
|
||||||
|
: null,
|
||||||
|
quantity: d.quantity ?? 1,
|
||||||
|
rate: d.rate != null ? Number(d.rate) : 0,
|
||||||
|
working_hours: d.working_hours != null ? Number(d.working_hours) : 0,
|
||||||
|
labor_hours: d.labor_hours != null ? Number(d.labor_hours) : 0,
|
||||||
|
tax: d.tax ?? "",
|
||||||
|
chart_of_account: d.chart_of_account ?? "",
|
||||||
|
description: d.description ?? "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const RATE_TYPE_OPTIONS = RateType.map((v) => ({
|
||||||
|
value: v,
|
||||||
|
label: v === "flat_rate" ? "Flat Rate" : "Hourly",
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ── Component ──
|
||||||
|
|
||||||
|
export function JobCardServiceForm({
|
||||||
|
jobCardId,
|
||||||
|
jobCardServiceId,
|
||||||
|
initialData,
|
||||||
|
onSuccess,
|
||||||
|
onCancel,
|
||||||
|
}: JobCardServiceFormProps) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
const isEditing = !!jobCardServiceId
|
||||||
|
|
||||||
|
const form = useForm<JobCardServiceFormValues>({
|
||||||
|
resolver: zodResolver(jobCardServiceFormSchema) as any,
|
||||||
|
defaultValues: initialData
|
||||||
|
? mapToFormValues(initialData)
|
||||||
|
: DEFAULT_VALUES,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [error, setError] = React.useState<string | null>(null)
|
||||||
|
const [isPending, setIsPending] = React.useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(values: JobCardServiceFormValues) {
|
||||||
|
setError(null)
|
||||||
|
setIsPending(true)
|
||||||
|
try {
|
||||||
|
if (isEditing && jobCardServiceId) {
|
||||||
|
await toast.promise(
|
||||||
|
api.jobCards.updateService(jobCardId, {
|
||||||
|
job_card_service_id: jobCardServiceId,
|
||||||
|
quantity: values.quantity,
|
||||||
|
rate: values.rate,
|
||||||
|
description: values.description || undefined,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
loading: "Updating service...",
|
||||||
|
success: "Service updated successfully",
|
||||||
|
error: "Failed to update service",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
await toast.promise(
|
||||||
|
api.jobCards.addService(jobCardId, {
|
||||||
|
service_id: values.service ? Number(values.service.value) : undefined,
|
||||||
|
department_id: values.department ? Number(values.department.value) : undefined,
|
||||||
|
rate_type: values.rate_type || undefined,
|
||||||
|
labor_rate_id: values.labor_rate ? Number(values.labor_rate.value) : undefined,
|
||||||
|
quantity: values.quantity,
|
||||||
|
rate: values.rate,
|
||||||
|
working_hours: values.working_hours || undefined,
|
||||||
|
labor_hours: values.labor_hours || undefined,
|
||||||
|
tax: values.tax || undefined,
|
||||||
|
chart_of_account: values.chart_of_account || undefined,
|
||||||
|
description: values.description || undefined,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
loading: "Adding service...",
|
||||||
|
success: "Service added successfully",
|
||||||
|
error: "Failed to add service",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
form.reset()
|
||||||
|
onSuccess?.()
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message ?? "An unexpected error occurred")
|
||||||
|
} finally {
|
||||||
|
setIsPending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Rhform form={form} onSubmit={handleSubmit}>
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive" className="mb-4">
|
||||||
|
<AlertTriangle className="me-2 h-4 w-4" />
|
||||||
|
<AlertTitle>
|
||||||
|
{isEditing ? "Failed to update service" : "Failed to add service"}
|
||||||
|
</AlertTitle>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FieldGroup>
|
||||||
|
{!isEditing && (
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="service"
|
||||||
|
label="Service"
|
||||||
|
placeholder="Select service"
|
||||||
|
required
|
||||||
|
queryKey={["services"]}
|
||||||
|
listFn={() => api.services.list()}
|
||||||
|
mapOption={(item: any) => ({
|
||||||
|
value: String(item.id),
|
||||||
|
label: item.labor_name ?? String(item.id),
|
||||||
|
})}
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfTextField
|
||||||
|
name="quantity"
|
||||||
|
label="Quantity"
|
||||||
|
type="number"
|
||||||
|
placeholder="1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<RhfTextField
|
||||||
|
name="rate"
|
||||||
|
label="Rate"
|
||||||
|
type="number"
|
||||||
|
placeholder="0.00"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isEditing && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfSelectField
|
||||||
|
name="rate_type"
|
||||||
|
label="Rate Type"
|
||||||
|
placeholder="Select rate type"
|
||||||
|
options={RATE_TYPE_OPTIONS}
|
||||||
|
/>
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="labor_rate"
|
||||||
|
label="Labor Rate"
|
||||||
|
placeholder="Select labor rate"
|
||||||
|
queryKey={["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"
|
||||||
|
/>
|
||||||
|
<RhfTextField
|
||||||
|
name="labor_hours"
|
||||||
|
label="Labor Hours"
|
||||||
|
type="number"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfAsyncSelectField
|
||||||
|
name="department"
|
||||||
|
label="Department"
|
||||||
|
placeholder="Select department"
|
||||||
|
queryKey={["departments"]}
|
||||||
|
listFn={() => api.departments.list()}
|
||||||
|
mapOption={(item: any) => ({
|
||||||
|
value: String(item.id),
|
||||||
|
label: item.name ?? String(item.id),
|
||||||
|
})}
|
||||||
|
createForm={(props) => <DepartmentInlineForm {...props} />}
|
||||||
|
createLabel="Department"
|
||||||
|
{...STORE_OBJECT}
|
||||||
|
/>
|
||||||
|
<RhfTextField
|
||||||
|
name="tax"
|
||||||
|
label="Tax"
|
||||||
|
placeholder="e.g. 5%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RhfTextField
|
||||||
|
name="chart_of_account"
|
||||||
|
label="Chart of Account"
|
||||||
|
placeholder="e.g. COA-200"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<RhfTextareaField
|
||||||
|
name="description"
|
||||||
|
label="Description"
|
||||||
|
placeholder="Optional notes"
|
||||||
|
/>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
{onCancel && (
|
||||||
|
<Button type="button" variant="outline" onClick={onCancel} disabled={isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button type="submit" disabled={isPending}>
|
||||||
|
{isPending
|
||||||
|
? isEditing ? "Updating..." : "Adding..."
|
||||||
|
: isEditing ? "Update Service" : "Add Service"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Rhform>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { CrudClient } from "../infra/crud-client"
|
import { CrudClient } from "../infra/crud-client"
|
||||||
import type { ApiClientOptions } from "../infra/client"
|
import type { ApiClientOptions } from "../infra/client"
|
||||||
import type { ApiOperationResponse, ApiPath, ApiRequestBody, ApiResponse } from "../infra/types"
|
import type { ApiOperationResponse, ApiPath, ApiRequestBody, ApiResponse } from "../infra/types"
|
||||||
import { ApiBaseResponse } from "src/contracts/types"
|
import { ApiBaseResponse } from "../contracts/types"
|
||||||
|
|
||||||
export const JOB_CARD_ROUTES = {
|
export const JOB_CARD_ROUTES = {
|
||||||
INDEX: "/api/job-cards",
|
INDEX: "/api/job-cards",
|
||||||
@ -24,6 +24,12 @@ export const JOB_CARD_ROUTES = {
|
|||||||
ADD_PART: "/api/job-cards/{id}/add-part",
|
ADD_PART: "/api/job-cards/{id}/add-part",
|
||||||
UPDATE_PART: "/api/job-cards/{id}/update-part",
|
UPDATE_PART: "/api/job-cards/{id}/update-part",
|
||||||
DELETE_PART: "/api/job-cards/{id}/delete-part",
|
DELETE_PART: "/api/job-cards/{id}/delete-part",
|
||||||
|
GET_SERVICES: "/api/job-cards/{id}/get-services",
|
||||||
|
ADD_SERVICE: "/api/job-cards/{id}/add-service",
|
||||||
|
UPDATE_SERVICE: "/api/job-cards/{id}/update-service",
|
||||||
|
DELETE_SERVICE: "/api/job-cards/{id}/delete-service",
|
||||||
|
ADD_SERVICE_ATTACHMENT: "/api/job-cards/{id}/add-service-attachment",
|
||||||
|
DELETE_SERVICE_ATTACHMENT: "/api/job-cards/{id}/delete-service-attachment",
|
||||||
} as const satisfies Record<string, ApiPath>
|
} as const satisfies Record<string, ApiPath>
|
||||||
|
|
||||||
|
|
||||||
@ -122,4 +128,33 @@ export class JobCardsClient extends CrudClient<
|
|||||||
async deletePart(id: string, jobCardPartId: number) {
|
async deletePart(id: string, jobCardPartId: number) {
|
||||||
return this.delete(JOB_CARD_ROUTES.DELETE_PART, { params: { id }, body: { job_card_part_id: jobCardPartId } } as never)
|
return this.delete(JOB_CARD_ROUTES.DELETE_PART, { params: { id }, body: { job_card_part_id: jobCardPartId } } as never)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getServices(id: string, params?: Record<string, unknown>) {
|
||||||
|
return this.get(JOB_CARD_ROUTES.GET_SERVICES, { params: { id }, query: params as any })
|
||||||
|
}
|
||||||
|
|
||||||
|
async addService(id: string, payload: ApiRequestBody<typeof JOB_CARD_ROUTES.ADD_SERVICE, "post">) {
|
||||||
|
return this.post(JOB_CARD_ROUTES.ADD_SERVICE, payload, { params: { id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateService(id: string, payload: ApiRequestBody<typeof JOB_CARD_ROUTES.UPDATE_SERVICE, "put">) {
|
||||||
|
return this.put(JOB_CARD_ROUTES.UPDATE_SERVICE, payload, { params: { id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteService(id: string, jobCardServiceId: number) {
|
||||||
|
return this.delete(JOB_CARD_ROUTES.DELETE_SERVICE, { params: { id }, body: { job_card_service_id: jobCardServiceId } } as never)
|
||||||
|
}
|
||||||
|
|
||||||
|
async addServiceAttachment(id: string, jobCardServiceId: number, files: File[]) {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append("job_card_service_id", String(jobCardServiceId))
|
||||||
|
for (const file of files) {
|
||||||
|
fd.append("attachments[]", file)
|
||||||
|
}
|
||||||
|
return this.postFormData(`/api/job-cards/${id}/add-service-attachment`, fd)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteServiceAttachment(id: string, jobCardServiceId: number, attachmentId: number) {
|
||||||
|
return this.delete(JOB_CARD_ROUTES.DELETE_SERVICE_ATTACHMENT, { params: { id }, body: { job_card_service_id: jobCardServiceId, attachment_id: attachmentId } } as never)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user