garage-erp/apps/dashboard/modules/estimates/estimate-expense-items-section.tsx

207 lines
8.6 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 { ESTIMATE_ROUTES, EXPENSE_ITEM_ROUTES } from "@garage/api"
import type { ExpenseItemsClient } from "@garage/api"
import { expenseItemColumns } from "@/modules/expense-items/expense-items-columns"
import { EstimateExpenseItemConfigForm } from "./estimate-expense-item-config-form"
import { toast } from "sonner"
type ExpenseLine = {
id: number
expense_item_id?: number
item_name?: string
title?: string
quantity: number
rate: number | string
description?: string
expense_item: any
}
type SelectedExpenseItem = {
id: number
name?: string
purchase_price?: string | number
}
export function EstimateExpenseItemsSection({ estimateId }: { estimateId: string }) {
const api = useAuthApi()
const queryClient = useQueryClient()
const [pickerOpen, setPickerOpen] = useState(false)
const [configExpenseItem, setConfigExpenseItem] = useState<SelectedExpenseItem | null>(null)
const queryKey = [ESTIMATE_ROUTES.EXPENSE_ITEMS, estimateId]
const { data, isLoading } = useQuery({
queryKey,
queryFn: async () => {
const res = await api.estimates.listExpenseItems(estimateId)
return ((res as any)?.data ?? []) as ExpenseLine[]
},
})
const items: ExpenseLine[] = data ?? []
const invalidate = () => queryClient.invalidateQueries({ queryKey })
const updateMutation = useMutation({
mutationFn: ({ lineId, payload }: { lineId: string; payload: { quantity?: number } }) =>
api.estimates.updateExpenseItem(estimateId, lineId, payload),
onSuccess: () => {
toast.success("Expense item updated")
invalidate()
},
onError: () => toast.error("Failed to update expense item"),
})
const removeMutation = useMutation({
mutationFn: (lineId: string) => api.estimates.removeExpenseItem(estimateId, lineId),
onSuccess: invalidate,
onError: () => toast.error("Failed to remove expense item"),
})
const handlePickerConfirm = (rows: any[]) => {
const row = rows[0]
if (!row) return
setConfigExpenseItem({
id: row.id,
name: row.name ?? row.title,
purchase_price: row.purchase_price,
})
}
const getDisplayName = (item: ExpenseLine) =>
item.expense_item.item_name ?? item.expense_item.title ?? `Item #${item.expense_item_id ?? item.id}`
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Expense Items</CardTitle>
<Button size="sm" variant="outline" onClick={() => setPickerOpen(true)}>
<Plus className="size-4 mr-1" />
Add Expense Item
</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 expense items added yet.</p>
)}
{items.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-100">Item</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 className="text-sm text-muted-foreground tabular-nums">
{Number(item.rate).toFixed(2)}
</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 an expense item (single-select) */}
<ResourceSelectorDialog<ExpenseItemsClient>
title="Select Expense Item"
open={pickerOpen}
onOpenChange={setPickerOpen}
selectionMode="single"
onConfirm={handlePickerConfirm}
crudProps={{
routeKey: EXPENSE_ITEM_ROUTES.INDEX,
getClient: (api) => api.expenseItems,
columns: [
expenseItemColumns.name,
expenseItemColumns.purchasePrice,
expenseItemColumns.chartOfAccount,
],
}}
/>
{/* Step 2: Configure and add the selected expense item */}
<Dialog open={!!configExpenseItem} onOpenChange={(v) => { if (!v) setConfigExpenseItem(null) }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-xl font-bold">Configure Expense Item</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[70vh] px-1">
{configExpenseItem && (
<EstimateExpenseItemConfigForm
expenseItem={configExpenseItem}
estimateId={estimateId}
onSuccess={() => {
setConfigExpenseItem(null)
invalidate()
}}
onCancel={() => setConfigExpenseItem(null)}
/>
)}
</ScrollArea>
</DialogContent>
</Dialog>
</Card>
)
}