garage-erp/apps/dashboard/modules/estimates/estimate-actions.tsx
2026-04-15 04:59:05 +03:00

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>
</>
)
}