diff --git a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/layout.tsx b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/layout.tsx index a2b4fc1..3aa1a35 100644 --- a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/layout.tsx +++ b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/layout.tsx @@ -63,6 +63,10 @@ export default async function JobCardDetailLayout(props: { params: Promise<{ id: href: `/sales/job-cards/${id}/parts`, label: `Parts (${jobCard?.parts_count || 0})` }, + { + href: `/sales/job-cards/${id}/services`, + label: `Services` + }, ]} > {props.children} diff --git a/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/services/page.tsx b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/services/page.tsx new file mode 100644 index 0000000..a84ce6a --- /dev/null +++ b/apps/dashboard/app/(authenticated)/sales/job-cards/[id]/services/page.tsx @@ -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(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[] = [ + { + accessorKey: "service.labor_name", + header: ({ column }) => , + cell: ({ row }) => { + const service = row.original.service + return service ? ( +
+ {service.labor_name} + {service.service_code && ( + {service.service_code} + )} +
+ ) : "—" + }, + }, + { + accessorKey: "rate_type", + header: ({ column }) => , + cell: ({ row }) => { + const val = row.original.rate_type + if (!val) return "—" + return ( + + {val === "flat_rate" ? "Flat Rate" : val === "hourly" ? "Hourly" : val} + + ) + }, + }, + { + accessorKey: "quantity", + header: ({ column }) => , + cell: ({ row }) => row.original.quantity ?? "—", + }, + { + accessorKey: "rate", + header: ({ column }) => , + cell: ({ row }) => { + const val = row.original.rate + return val != null ? `$${Number(val).toFixed(2)}` : "—" + }, + }, + { + accessorKey: "labor_rate.title", + header: ({ column }) => , + cell: ({ row }) => { + const lr = row.original.labor_rate + return lr ? `${lr.title} ($${Number(lr.rate).toFixed(2)})` : "—" + }, + }, + { + accessorKey: "working_hours", + header: ({ column }) => , + cell: ({ row }) => row.original.working_hours ?? "—", + }, + { + accessorKey: "labor_hours", + header: ({ column }) => , + cell: ({ row }) => row.original.labor_hours ?? "—", + }, + { + accessorKey: "tax", + header: ({ column }) => , + cell: ({ row }) => row.original.tax || "—", + }, + { + accessorKey: "department.name", + header: ({ column }) => , + cell: ({ row }) => row.original.department?.name || "—", + }, + { + accessorKey: "description", + header: ({ column }) => , + cell: ({ row }) => row.original.description || "—", + }, + { + accessorKey: "created_at", + header: ({ column }) => , + cell: ({ row }) => formatDate(row.original.created_at), + }, + { + id: "actions", + cell: ({ row }) => ( + + + + + + { + setEditItem(row.original) + setDialogOpen(true) + }} + > + Edit + + handleDelete(row.original)} + > + Delete + + + + ), + }, + ] + + return ( +
+
+ { + setDialogOpen(open) + if (!open) setEditItem(null) + }} + > + + + + + + {editItem ? "Edit Service" : "Add Service"} + + { + setDialogOpen(false) + setEditItem(null) + invalidate() + }} + onCancel={() => { + setDialogOpen(false) + setEditItem(null) + }} + /> + + +
+ + +
+ ) +} diff --git a/apps/dashboard/modules/job-cards/job-card-service-form.tsx b/apps/dashboard/modules/job-cards/job-card-service-form.tsx new file mode 100644 index 0000000..05ff31f --- /dev/null +++ b/apps/dashboard/modules/job-cards/job-card-service-form.tsx @@ -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 + +// ── 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({ + resolver: zodResolver(jobCardServiceFormSchema) as any, + defaultValues: initialData + ? mapToFormValues(initialData) + : DEFAULT_VALUES, + }) + + const [error, setError] = React.useState(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 ( + + {error && ( + + + + {isEditing ? "Failed to update service" : "Failed to add service"} + + {error} + + )} + + + {!isEditing && ( + api.services.list()} + mapOption={(item: any) => ({ + value: String(item.id), + label: item.labor_name ?? String(item.id), + })} + {...STORE_OBJECT} + /> + )} + +
+ + +
+ + {!isEditing && ( + <> +
+ + 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), + })} + createForm={(props) => } + createLabel="Department" + {...STORE_OBJECT} + /> + +
+ + + + )} + + +
+ +
+ {onCancel && ( + + )} + +
+
+ ) +} diff --git a/packages/api/src/clients/job-cards.ts b/packages/api/src/clients/job-cards.ts index 2d79a52..9348e67 100644 --- a/packages/api/src/clients/job-cards.ts +++ b/packages/api/src/clients/job-cards.ts @@ -1,7 +1,7 @@ import { CrudClient } from "../infra/crud-client" import type { ApiClientOptions } from "../infra/client" 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 = { INDEX: "/api/job-cards", @@ -24,6 +24,12 @@ export const JOB_CARD_ROUTES = { ADD_PART: "/api/job-cards/{id}/add-part", UPDATE_PART: "/api/job-cards/{id}/update-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 @@ -122,4 +128,33 @@ export class JobCardsClient extends CrudClient< async deletePart(id: string, jobCardPartId: number) { return this.delete(JOB_CARD_ROUTES.DELETE_PART, { params: { id }, body: { job_card_part_id: jobCardPartId } } as never) } + + async getServices(id: string, params?: Record) { + return this.get(JOB_CARD_ROUTES.GET_SERVICES, { params: { id }, query: params as any }) + } + + async addService(id: string, payload: ApiRequestBody) { + return this.post(JOB_CARD_ROUTES.ADD_SERVICE, payload, { params: { id } }) + } + + async updateService(id: string, payload: ApiRequestBody) { + 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) + } }