368 lines
16 KiB
TypeScript
368 lines
16 KiB
TypeScript
"use client"
|
|
|
|
import { useAuthApi } from "@/shared/useApi"
|
|
import { useRouter } from "next/navigation"
|
|
import { Button } from "@/shared/components/ui/button"
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/shared/components/ui/dropdown-menu"
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from "@/shared/components/ui/dialog"
|
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
|
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 (
|
|
<div className="flex items-center gap-4 py-2.5 border-b last:border-0">
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium truncate">{name}</p>
|
|
<div className="flex items-center gap-3 mt-1">
|
|
<span className="text-xs text-muted-foreground">
|
|
<span className="font-medium text-foreground">{quantity}</span> qty
|
|
</span>
|
|
<span className="text-muted-foreground/40">·</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
Rate <span className="font-medium text-foreground">{rateNum.toFixed(2)}</span>
|
|
</span>
|
|
<span className="text-muted-foreground/40">·</span>
|
|
<span className="text-xs font-semibold text-foreground">{amount.toFixed(2)}</span>
|
|
</div>
|
|
{description && (
|
|
<p className="text-xs text-muted-foreground/70 truncate mt-0.5">{description}</p>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
className={cn(
|
|
"h-8 gap-1.5 px-3",
|
|
status === "rejected" && "bg-destructive text-destructive-foreground border-destructive hover:bg-destructive/90",
|
|
)}
|
|
onClick={() => onToggle("rejected")}
|
|
>
|
|
<X className="size-3.5" />
|
|
Reject
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
className={cn(
|
|
"h-8 gap-1.5 px-3",
|
|
status === "accepted" && "bg-primary text-primary-foreground border-primary hover:bg-primary/90",
|
|
)}
|
|
onClick={() => onToggle("accepted")}
|
|
>
|
|
<Check className="size-3.5" />
|
|
Accept
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SectionHeading({ title }: { title: string }) {
|
|
return (
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-1">{title}</p>
|
|
)
|
|
}
|
|
|
|
export function EstimateActions({ estimateId }: EstimateActionsProps) {
|
|
const api = useAuthApi()
|
|
const router = useRouter()
|
|
const [editOpen, setEditOpen] = useState(false)
|
|
const [authOpen, setAuthOpen] = useState(false)
|
|
const [itemStatuses, setItemStatuses] = useState<Record<string, string>>({})
|
|
const [authMethod, setAuthMethod] = useState("in_person")
|
|
const [employee, setEmployee] = useState<EmployeeOption | null>(null)
|
|
const [authDate, setAuthDate] = useState("")
|
|
const [authTime, setAuthTime] = useState("")
|
|
|
|
const { data: servicesData = [], isLoading: loadingServices } = useQuery({
|
|
queryKey: [ESTIMATE_ROUTES.SERVICES, estimateId, "auth"],
|
|
queryFn: async () => {
|
|
const res = await api.estimates.listServices(estimateId)
|
|
return ((res as any)?.data ?? []) as ServiceLine[]
|
|
},
|
|
enabled: authOpen,
|
|
})
|
|
|
|
const { data: partsData = [], isLoading: loadingParts } = useQuery({
|
|
queryKey: [ESTIMATE_ROUTES.PARTS, estimateId, "auth"],
|
|
queryFn: async () => {
|
|
const res = await api.estimates.listParts(estimateId)
|
|
return ((res as any)?.data ?? []) as PartLine[]
|
|
},
|
|
enabled: authOpen,
|
|
})
|
|
|
|
const { data: expenseItemsData = [], isLoading: loadingExpenseItems } = useQuery({
|
|
queryKey: [ESTIMATE_ROUTES.EXPENSE_ITEMS, estimateId, "auth"],
|
|
queryFn: async () => {
|
|
const res = await api.estimates.listExpenseItems(estimateId)
|
|
return ((res as any)?.data ?? []) as ExpenseLine[]
|
|
},
|
|
enabled: authOpen,
|
|
})
|
|
|
|
const isLoading = loadingServices || loadingParts || loadingExpenseItems
|
|
const hasItems = servicesData.length > 0 || partsData.length > 0 || expenseItemsData.length > 0
|
|
|
|
const getStatus = (key: string) => itemStatuses[key] ?? "pending"
|
|
const handleToggle = (key: string, action: "accepted" | "rejected") =>
|
|
setItemStatuses((prev) => ({ ...prev, [key]: toggleStatus(prev[key] ?? "pending", action) }))
|
|
|
|
const handleDelete = async () => {
|
|
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 (
|
|
<>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon">
|
|
<Ellipsis className="size-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={() => setEditOpen(true)}>
|
|
<Pencil className="size-4" />
|
|
Edit
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={openAuthDialog}>
|
|
<ShieldCheck className="size-4" />
|
|
Store Authorisation
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
|
<Trash2 className="size-4" />
|
|
Delete
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{/* Edit Dialog */}
|
|
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
|
<DialogContent className="min-w-xl">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-2xl font-bold">Edit Estimate</DialogTitle>
|
|
</DialogHeader>
|
|
<ScrollArea className="max-h-[80vh] px-4">
|
|
<EstimateForm
|
|
resourceId={estimateId}
|
|
onSuccess={() => {
|
|
setEditOpen(false)
|
|
router.refresh()
|
|
}}
|
|
/>
|
|
</ScrollArea>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Authorisation Dialog */}
|
|
<Dialog open={authOpen} onOpenChange={setAuthOpen}>
|
|
<DialogContent className="sm:max-w-7xl">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-xl font-bold">Store Authorisation</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<ScrollArea className="max-h-[55vh]">
|
|
<div className="grid gap-5 px-1 py-1">
|
|
{isLoading && (
|
|
<p className="text-sm text-muted-foreground">Loading line items…</p>
|
|
)}
|
|
{!isLoading && !hasItems && (
|
|
<p className="text-sm text-muted-foreground">No line items on this estimate.</p>
|
|
)}
|
|
|
|
{servicesData.length > 0 && (
|
|
<div>
|
|
<SectionHeading title="Services" />
|
|
{servicesData.map((s) => (
|
|
<ItemStatusRow
|
|
key={s.id}
|
|
name={s.labor_name || s.title || `#${s.id}`}
|
|
rate={s.rate}
|
|
quantity={s.quantity}
|
|
description={s.description}
|
|
status={getStatus(`s-${s.id}`)}
|
|
onToggle={(a) => handleToggle(`s-${s.id}`, a)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{partsData.length > 0 && (
|
|
<div>
|
|
<SectionHeading title="Parts" />
|
|
{partsData.map((p) => (
|
|
<ItemStatusRow
|
|
key={p.id}
|
|
name={p.title || `#${p.id}`}
|
|
rate={p.rate}
|
|
quantity={p.quantity}
|
|
description={p.description}
|
|
status={getStatus(`p-${p.id}`)}
|
|
onToggle={(a) => handleToggle(`p-${p.id}`, a)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{expenseItemsData.length > 0 && (
|
|
<div>
|
|
<SectionHeading title="Expense Items" />
|
|
{expenseItemsData.map((e) => (
|
|
<ItemStatusRow
|
|
key={e.id}
|
|
name={e.item_name || e.title || `#${e.id}`}
|
|
rate={e.rate}
|
|
quantity={e.quantity}
|
|
description={e.description}
|
|
status={getStatus(`e-${e.id}`)}
|
|
onToggle={(a) => handleToggle(`e-${e.id}`, a)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
|
|
<div className="grid gap-3 border-t pt-4">
|
|
<div className="flex gap-3">
|
|
<div className="flex-1">
|
|
<p className="text-xs text-muted-foreground mb-1.5">Date</p>
|
|
<DatePickerField value={authDate} onChange={(v) => setAuthDate(v ?? "")} />
|
|
</div>
|
|
<div className="flex-1">
|
|
<p className="text-xs text-muted-foreground mb-1.5">Time</p>
|
|
<TimePickerField value={authTime} onChange={(v) => setAuthTime(v ?? "")} />
|
|
</div>
|
|
<div className="flex-1">
|
|
<p className="text-xs text-muted-foreground mb-1.5">Method</p>
|
|
<Select value={authMethod} onValueChange={setAuthMethod}>
|
|
<SelectTrigger className="h-9 w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{AUTHORISATION_METHOD_OPTIONS.map((o) => (
|
|
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-xs text-muted-foreground mb-1.5">Employee</p>
|
|
<EmployeeCombobox
|
|
value={employee}
|
|
onValueChange={setEmployee}
|
|
showClear={!!employee}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setAuthOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={() => authMutation.mutate()} disabled={authMutation.isPending || isLoading}>
|
|
{authMutation.isPending ? "Saving…" : "Save Authorisations"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
)
|
|
}
|
|
|