220 lines
9.1 KiB
TypeScript
220 lines
9.1 KiB
TypeScript
"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
|
|
service:any
|
|
}
|
|
|
|
type SelectedService = {
|
|
id: number
|
|
labor_name?: string
|
|
selling_price?: string | number
|
|
}
|
|
|
|
export function EstimateServicesSection({ estimateId }: { estimateId: string }) {
|
|
const api = useAuthApi()
|
|
const queryClient = useQueryClient()
|
|
const [pickerOpen, setPickerOpen] = useState(false)
|
|
const [configService, setConfigService] = useState<SelectedService | null>(null)
|
|
|
|
const queryKey = [ESTIMATE_ROUTES.SERVICES, estimateId]
|
|
|
|
const { data, isLoading } = useQuery({
|
|
queryKey,
|
|
queryFn: async () => {
|
|
const res = await api.estimates.listServices(estimateId)
|
|
return ((res as any)?.data ?? []) as ServiceLine[]
|
|
},
|
|
})
|
|
|
|
const items: ServiceLine[] = data ?? []
|
|
|
|
const invalidate = () => queryClient.invalidateQueries({ queryKey })
|
|
|
|
const updateMutation = useMutation({
|
|
mutationFn: ({ lineId, payload }: { lineId: string; payload: { rate?: string; quantity?: number } }) =>
|
|
api.estimates.updateService(estimateId, lineId, payload),
|
|
onSuccess: () => {
|
|
toast.success("Service updated")
|
|
invalidate()
|
|
},
|
|
onError: () => toast.error("Failed to update service"),
|
|
})
|
|
|
|
const removeMutation = useMutation({
|
|
mutationFn: (lineId: string) => api.estimates.removeService(estimateId, lineId),
|
|
onSuccess: invalidate,
|
|
onError: () => toast.error("Failed to remove service"),
|
|
})
|
|
|
|
const handlePickerConfirm = (rows: any[]) => {
|
|
const row = rows[0]
|
|
if (!row) return
|
|
setConfigService({
|
|
id: row.id,
|
|
labor_name: row.labor_name ?? row.name,
|
|
selling_price: row.selling_price,
|
|
})
|
|
}
|
|
|
|
const getDisplayName = (item: ServiceLine) =>
|
|
item.service.labor_name ?? item.service.title ?? `Service #${item.service_id ?? item.id}`
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
|
<CardTitle className="text-base">Services</CardTitle>
|
|
<Button size="sm" variant="outline" onClick={() => setPickerOpen(true)}>
|
|
<Plus className="size-4 mr-1" />
|
|
Add Service
|
|
</Button>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
|
{!isLoading && items.length === 0 && (
|
|
<p className="text-sm text-muted-foreground">No services added yet.</p>
|
|
)}
|
|
{items.length > 0 && (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-100">Service</TableHead>
|
|
<TableHead className="w-24">Qty</TableHead>
|
|
<TableHead className="w-28">Rate</TableHead>
|
|
<TableHead>Description</TableHead>
|
|
<TableHead className="w-12" />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{items.map((item) => (
|
|
<TableRow key={item.id}>
|
|
<TableCell className="font-medium">{getDisplayName(item)}</TableCell>
|
|
<TableCell>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
defaultValue={item.quantity}
|
|
onBlur={(e) =>
|
|
updateMutation.mutate({
|
|
lineId: String(item.id),
|
|
payload: { quantity: Number(e.target.value) || 1 },
|
|
})
|
|
}
|
|
className="h-8 w-20"
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
step={0.01}
|
|
defaultValue={Number(item.rate)}
|
|
onBlur={(e) =>
|
|
updateMutation.mutate({
|
|
lineId: String(item.id),
|
|
payload: { rate: e.target.value },
|
|
})
|
|
}
|
|
className="h-8 w-24"
|
|
/>
|
|
</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">
|
|
{item.description || "—"}
|
|
</TableCell>
|
|
<TableCell>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => removeMutation.mutate(String(item.id))}
|
|
disabled={removeMutation.isPending}
|
|
>
|
|
<Trash2 className="h-4 w-4 text-destructive" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</CardContent>
|
|
|
|
{/* Step 1: Pick a service (single-select) */}
|
|
<ResourceSelectorDialog<ServicesClient>
|
|
title="Select Service"
|
|
open={pickerOpen}
|
|
onOpenChange={setPickerOpen}
|
|
selectionMode="single"
|
|
onConfirm={handlePickerConfirm}
|
|
crudProps={{
|
|
routeKey: SERVICE_ROUTES.INDEX,
|
|
getClient: (api) => api.services,
|
|
columns: [
|
|
serviceColumns.name,
|
|
serviceColumns.description,
|
|
serviceColumns.sellingPrice,
|
|
],
|
|
}}
|
|
/>
|
|
|
|
{/* Step 2: Configure and add the selected service */}
|
|
<Dialog open={!!configService} onOpenChange={(v) => { if (!v) setConfigService(null) }}>
|
|
<DialogContent className="sm:max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-xl font-bold">Configure Service</DialogTitle>
|
|
</DialogHeader>
|
|
<ScrollArea className="max-h-[70vh] px-1">
|
|
{configService && (
|
|
<EstimateServiceConfigForm
|
|
service={configService}
|
|
estimateId={estimateId}
|
|
onSuccess={() => {
|
|
setConfigService(null)
|
|
invalidate()
|
|
}}
|
|
onCancel={() => setConfigService(null)}
|
|
/>
|
|
)}
|
|
</ScrollArea>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
|