This commit is contained in:
2026-04-06 02:32:47 +03:00
parent 5f3d208158
commit 24a44481a0
152 changed files with 85913 additions and 44338 deletions
@@ -0,0 +1,75 @@
"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,
} from "@/shared/components/ui/dialog"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Ellipsis, Pencil, Trash2 } from "lucide-react"
import { useState } from "react"
import { EstimateForm } from "./estimate-form"
type EstimateActionsProps = {
estimateId: string
}
export function EstimateActions({ estimateId }: EstimateActionsProps) {
const api = useAuthApi()
const router = useRouter()
const [editOpen, setEditOpen] = useState(false)
const handleDelete = async () => {
await api.estimates.destroy(estimateId)
router.push("/sales/estimates")
}
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 variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<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>
</>
)
}
@@ -0,0 +1,28 @@
"use client"
import { createContext, useContext } from "react"
type EstimateContextValue = {
id: string
label: string
}
const EstimateContext = createContext<EstimateContextValue | null>(null)
export function EstimateProvider({
estimate,
children,
}: {
estimate: EstimateContextValue
children: React.ReactNode
}) {
return (
<EstimateContext.Provider value={estimate}>
{children}
</EstimateContext.Provider>
)
}
export function useEstimate() {
return useContext(EstimateContext)
}
@@ -23,7 +23,9 @@ import {
estimateFormSchema,
type EstimateFormValues,
} from "./estimate.schema"
import { ESTIMATE_ROUTES, CUSTOMER_ROUTES, VEHICLE_ROUTES, DEPARTMENT_ROUTES, LABEL_ROUTES } from "@garage/api"
import { ESTIMATE_ROUTES, DEPARTMENT_ROUTES, LABEL_ROUTES } from "@garage/api"
import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field"
import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
// ── Props ──
@@ -91,16 +93,6 @@ const mapLookupOption = (item: any) => ({
label: item.name,
})
const mapCustomerOption = (item: any) => ({
value: String(item.id),
label: [item.first_name, item.last_name].filter(Boolean).join(" "),
})
const mapVehicleOption = (item: any) => ({
value: String(item.id),
label: [item.year, item.make_name, item.model_name].filter(Boolean).join(" "),
})
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
// ── Component ──
@@ -157,24 +149,8 @@ export function EstimateForm({ resourceId, initialData, onSuccess }: EstimateFor
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="customer"
label="Customer"
placeholder="Select customer"
queryKey={[CUSTOMER_ROUTES.INDEX]}
listFn={() => api.customers.list()}
mapOption={mapCustomerOption}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="vehicle"
label="Vehicle"
placeholder="Select vehicle"
queryKey={[VEHICLE_ROUTES.INDEX]}
listFn={() => api.vehicles.list()}
mapOption={mapVehicleOption}
{...STORE_OBJECT}
/>
<RhfCustomerSelectField name="customer" />
<RhfVehicleSelectField name="vehicle" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
@@ -0,0 +1,197 @@
import {
FileText,
Hash,
Calendar,
User,
Car,
Building2,
Shield,
Tag,
MessageSquare,
} from "lucide-react"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
type EstimateData = {
id?: number
title?: string
estimate_number?: string
date?: string
customer_id?: number
vehicle_id?: number
department_id?: number
has_insurance?: boolean
enable_digital_authorisation?: boolean
insurance_type_id?: string | number | null
insurer_id?: string | number | null
service_writer_id?: number
footer?: string | null
created_at?: string
updated_at?: string
labels?: {
id?: number
title?: string
color_code?: string
}[]
customer_remarks?: {
id?: number
remark?: string
created_at?: string
}[]
// Joined fields that may come from the API
customer_name?: string
vehicle_name?: string
department_name?: string
}
type EstimateGeneralInfoProps = {
estimate: EstimateData
}
function InfoItem({
icon: Icon,
label,
value,
}: {
icon: React.ComponentType<{ className?: string }>
label: string
value?: string | null
}) {
return (
<div className="flex items-start gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Icon className="size-4" />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-sm font-medium">
{value || <span className="text-muted-foreground">—</span>}
</span>
</div>
</div>
)
}
export function EstimateGeneralInfo({ estimate }: EstimateGeneralInfoProps) {
return (
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="size-4" /> Estimate Details
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem icon={FileText} label="Title" value={estimate.title} />
<InfoItem icon={Hash} label="Estimate #" value={estimate.estimate_number} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem icon={Calendar} label="Date" value={estimate.date} />
<InfoItem
icon={Shield}
label="Insurance"
value={estimate.has_insurance ? "Yes" : "No"}
/>
</div>
<InfoItem
icon={Calendar}
label="Created"
value={
estimate.created_at
? new Date(estimate.created_at).toLocaleDateString()
: null
}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="size-4" /> Related Records
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<InfoItem
icon={User}
label="Customer"
value={estimate.customer_name || (estimate.customer_id ? `#${estimate.customer_id}` : null)}
/>
<InfoItem
icon={Car}
label="Vehicle"
value={estimate.vehicle_name || (estimate.vehicle_id ? `#${estimate.vehicle_id}` : null)}
/>
<InfoItem
icon={Building2}
label="Department"
value={estimate.department_name || (estimate.department_id ? `#${estimate.department_id}` : null)}
/>
</CardContent>
</Card>
{estimate.labels && estimate.labels.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Tag className="size-4" /> Labels
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{estimate.labels.map((label) => (
<Badge
key={label.id}
style={label.color_code ? { backgroundColor: label.color_code } : undefined}
>
{label.title}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{estimate.customer_remarks && estimate.customer_remarks.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageSquare className="size-4" /> Customer Remarks
</CardTitle>
</CardHeader>
<CardContent className="grid gap-3">
{estimate.customer_remarks.map((remark) => (
<div key={remark.id} className="rounded-md border p-3 text-sm">
<p>{remark.remark}</p>
{remark.created_at && (
<p className="mt-1 text-xs text-muted-foreground">
{new Date(remark.created_at).toLocaleDateString()}
</p>
)}
</div>
))}
</CardContent>
</Card>
)}
{estimate.footer && (
<Card className="md:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="size-4" /> Footer
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm">{estimate.footer}</p>
</CardContent>
</Card>
)}
</div>
)
}