feat: add template checkpoint edit dialog and vendor management components

- Implemented TemplateCheckpointEditDialog for creating and editing inspection checkpoints.
- Added VendorActions component for managing vendor actions including edit, activate/deactivate, and delete.
- Created VendorContext for managing vendor state across components.
- Developed VendorGeneralInfo component to display detailed vendor information.
- Introduced AedSymbol and Money components for consistent currency representation.
- Added PromptDialog for user input prompts throughout the application.
- Implemented RelationLink component for unified related-data display in CRUD tables.
- Created InspectionTemplatesClient for API interactions related to inspection templates.
This commit is contained in:
2026-05-18 12:08:42 +04:00
parent 54d11f01b4
commit 4bfd8c84a9
111 changed files with 5002 additions and 1047 deletions
@@ -5,7 +5,7 @@ import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { CreditNoteForm } from "@/modules/credit-notes/credit-note-form"
import { CREDIT_NOTE_ROUTES } from "@garage/api"
import { CREDIT_NOTE_ROUTES, CreditNoteStatus } from "@garage/api"
import type { CreditNotesClient } from "@garage/api"
type CreditNoteItem = {
@@ -25,6 +25,9 @@ export default function CreditNotesPage() {
<ResourcePage<CreditNotesClient>
pageTitle="Credit Notes"
routeKey={CREDIT_NOTE_ROUTES.INDEX}
searchable
searchPlaceholder="Search credit notes..."
statusFilter={{ statuses: CreditNoteStatus }}
getClient={(api) => api.creditNotes}
onRowClick={(row) => router.push(`/sales/credit-notes/${(row as any).id}`)}
headerProps={({ selectedItem, invalidateQuery }) => ({
@@ -32,6 +32,8 @@ export default function CustomerVehiclesPage({ params }: { params: Promise<{ id:
)}
pageTitle="Customer Vehicles"
routeKey={VEHICLE_ROUTES.INDEX}
searchable
searchPlaceholder="Search vehicles..."
getClient={(api) => api.vehicles}
extraParams={{ customer_id: customerId }}
header={null}
@@ -6,7 +6,6 @@ import { ColumnHeader } from '@/shared/data-view/table-view'
import FormDialog from '@/shared/components/form-dialog'
import { ImportDataButton } from '@/shared/components/import-data-button'
import { ExportDataButton } from '@/shared/components/export-data-button'
import { DownloadSampleButton } from '@/shared/components/download-sample-button'
import { useAuthApi } from '@/shared/useApi'
import { CustomerForm } from '@/modules/customers/customer-form'
import { CUSTOMER_ROUTES } from '@garage/api'
@@ -20,19 +19,19 @@ export default function CustomersPage() {
<ResourcePage<CustomersClient>
pageTitle='Customers'
routeKey={CUSTOMER_ROUTES.INDEX}
searchable
searchPlaceholder="Search customers..."
getClient={(api) => api.customers}
onRowClick={(row) => router.push(`/sales/customers/${(row as any).id}`)}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<div className='flex items-center gap-2'>
<DownloadSampleButton
onDownload={() => api.customers.downloadImportSample()}
fileName='customers-import-sample'
/>
<ImportDataButton
onImport={(file) => api.customers.importData(file)}
onSuccess={invalidateQuery}
entityLabel="Customers"
onDownloadSample={() => api.customers.downloadImportSample()}
sampleFileName='customers-import-sample'
/>
<ExportDataButton
onExport={(filters) => api.customers.exportData(filters)}
@@ -8,11 +8,11 @@ import { EstimateForm } from '@/modules/estimates/estimate-form'
import { ESTIMATE_ROUTES } from '@garage/api'
import type { EstimatesClient } from '@garage/api'
import { Car, FileTextIcon, UserIcon } from 'lucide-react'
import { Button } from '@/shared/components/ui/button'
import Link from 'next/link'
import { formatDate } from '@/shared/utils/formatters'
import { getVehicleLabel } from '@/modules/vehicles/utils/getVehicleLabel'
import { getFullName } from '@/shared/utils/getFullName'
import { RelationLink } from '@/shared/components/relation-link'
export default function EstimatesPage() {
const router = useRouter()
@@ -20,6 +20,8 @@ export default function EstimatesPage() {
<ResourcePage<EstimatesClient>
pageTitle="Estimates"
routeKey={ESTIMATE_ROUTES.INDEX}
searchable
searchPlaceholder="Search estimates..."
getClient={(api) => api.estimates}
onRowClick={(row) => router.push(`/sales/estimates/${(row as any).id}`)}
headerProps={({ selectedItem, invalidateQuery }) => ({
@@ -58,28 +60,30 @@ export default function EstimatesPage() {
accessorKey: "customer_name",
header: ({ column }) => <ColumnHeader column={column} title="Customer" />,
cell: ({ row }) => {
const item:any = row.original
if (!item.customer?.id) return "—"
const item: any = row.original
return (
<Button variant="outline" asChild size="sm" onClick={(e) => e.stopPropagation()}>
<Link href={`/sales/customers/${item.customer.id}`}>
<UserIcon /> {getFullName(item.customer) || "—"}
</Link>
</Button>
<RelationLink
href={item.customer?.id ? `/sales/customers/${item.customer.id}` : null}
icon={UserIcon}
label={getFullName(item.customer)}
/>
)
}
},
},
{
accessorKey: "vehicle",
header: ({ column }) => <ColumnHeader column={column} title="Vehicle" />,
cell: ({ row }) => {
const item :any= row.original
return <Button variant="outline" asChild size="sm" onClick={(e) => e.stopPropagation()}>
<Link href={`/sales/vehicles/${item.vehicle?.id}`}>
<Car/> {getVehicleLabel(item.vehicle as any) || "—"}
</Link>
</Button>
}
const item: any = row.original
return (
<RelationLink
href={item.vehicle?.id ? `/sales/vehicles/${item.vehicle.id}` : null}
icon={Car}
label={getVehicleLabel(item.vehicle as any)}
meta={item.vehicle?.license_plate}
/>
)
},
},
{
accessorKey: "date",
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,6 @@
import { getServerApi } from '@garage/api/server'
import { InspectionGeneralInfo } from '@/modules/inspections/inspection-general-info'
import { InspectionDetailHeader } from '@/modules/inspections/inspection-detail-header'
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
export default async function page(props: { params: Promise<{ id: string }> }) {
@@ -11,8 +12,11 @@ export default async function page(props: { params: Promise<{ id: string }> }) {
return <div className="text-muted-foreground">Inspection not found.</div>
}
const data = inspection.data as { title?: string }
return (
<DashboardPage header={null}>
<InspectionDetailHeader inspectionId={id} title={data.title} />
<InspectionGeneralInfo inspection={inspection.data} />
</DashboardPage>
)
@@ -1,46 +1,125 @@
"use client"
import { useState } from "react"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { InspectionForm } from "@/modules/inspections/inspection-form"
import { INSPECTION_ROUTES } from "@garage/api"
import { InspectionFromTemplateForm } from "@/modules/inspections/inspection-from-template-form"
import { InspectionRowActions } from "@/modules/inspections/inspection-row-actions"
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { INSPECTION_ROUTES, InspectionStatus } from "@garage/api"
import type { InspectionsClient } from "@garage/api"
import Link from "next/link"
import { Car, ClipboardList, ListChecks, UserIcon } from "lucide-react"
import { useRouter } from "next/navigation"
import { RelationLink } from "@/shared/components/relation-link"
import { getFullName } from "@/shared/utils/getFullName"
import { getVehicleLabel } from "@/modules/vehicles/utils/getVehicleLabel"
const STATUS_BADGE_CLASS: Record<string, string> = {
in_progress: "bg-amber-100 text-amber-800",
completed: "bg-emerald-100 text-emerald-800",
cancelled: "bg-rose-100 text-rose-800",
}
const STATUS_LABEL: Record<string, string> = {
in_progress: "In Progress",
completed: "Completed",
cancelled: "Cancelled",
}
export default function InspectionsPage() {
const router = useRouter()
const [fromTemplateOpen, setFromTemplateOpen] = useState(false)
return (
<ResourcePage<InspectionsClient>
pageTitle="Inspections"
routeKey={INSPECTION_ROUTES.INDEX}
searchable
searchPlaceholder="Search inspections..."
statusFilter={{ statuses: InspectionStatus }}
getClient={(api) => api.inspections}
onRowClick={(row) => router.push(`/sales/inspections/${(row as any).id}`)}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<FormDialog title="Inspection">
{(resourceId) => (
<InspectionForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" asChild>
<Link href="/settings/inspection-templates">
<ListChecks className="size-4" />
Manage templates
</Link>
</Button>
<Button size="sm" variant="default" onClick={() => setFromTemplateOpen(true)}>
<ClipboardList className="size-4" />
New from template
</Button>
<FormDialog title="Inspection" classNames={{ trigger: "" }}>
{(resourceId) => (
<InspectionForm
resourceId={resourceId}
initialData={selectedItem}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
<Dialog open={fromTemplateOpen} onOpenChange={setFromTemplateOpen}>
<DialogContent className="min-w-2xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
New inspection from template
</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[80vh] px-4">
<InspectionFromTemplateForm
onSuccess={(id) => {
setFromTemplateOpen(false)
invalidateQuery()
if (id) router.push(`/sales/inspections/${id}/checkpoints`)
}}
/>
</ScrollArea>
</DialogContent>
</Dialog>
</div>
),
})}
columns={({ actionsColumn }) => [
columns={({ openEdit, deleteItem }) => [
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
cell: ({ row }) => {
const r = row.original as any
return (
<div className="flex flex-col">
<span className="font-medium">{r.title ?? "—"}</span>
{r.order_number && (
<span className="text-xs text-muted-foreground">{r.order_number}</span>
)}
</div>
)
},
},
{
accessorKey: "customer",
header: ({ column }) => <ColumnHeader column={column} title="Customer" />,
cell: ({ row }) => {
const c = (row.original as any).customer
return c ? `${c.first_name ?? ""} ${c.last_name ?? ""}`.trim() : "—"
return (
<RelationLink
href={c?.id ? `/sales/customers/${c.id}` : null}
icon={UserIcon}
label={getFullName(c)}
/>
)
},
},
{
@@ -48,27 +127,51 @@ export default function InspectionsPage() {
header: ({ column }) => <ColumnHeader column={column} title="Vehicle" />,
cell: ({ row }) => {
const v = (row.original as any).vehicle
return v ? `${v.make ?? ""} ${v.model ?? ""}`.trim() : "—"
return (
<RelationLink
href={v?.id ? `/sales/vehicles/${v.id}` : null}
icon={Car}
label={getVehicleLabel(v as any)}
meta={v?.license_plate}
/>
)
},
},
{
accessorKey: "inspection_category",
header: ({ column }) => <ColumnHeader column={column} title="Category" />,
cell: ({ row }) => (row.original as any).inspection_category?.name ?? "—",
cell: ({ row }) => {
const ic = (row.original as any).inspection_category
return ic?.inspection_name ?? ic?.name ?? "—"
},
},
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const status = (row.original as any).status
const status = (row.original as any).status as string | undefined
if (!status) return "—"
const cls = STATUS_BADGE_CLASS[status] ?? "bg-gray-100 text-gray-700"
return (
<span className={status === "completed" ? "text-green-600" : "text-yellow-600"}>
{status ?? "—"}
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${cls}`}>
{STATUS_LABEL[status] ?? status}
</span>
)
},
},
actionsColumn(),
{
id: "actions",
header: () => <span className="sr-only">Actions</span>,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<InspectionRowActions
inspection={row.original as any}
onEdit={() => openEdit(row.original as any)}
onDelete={() => deleteItem(String((row.original as any).id))}
/>
),
},
]}
/>
)
@@ -1,17 +1,17 @@
"use client"
import Link from "next/link"
import { UserIcon } from "lucide-react"
import { Car, UserIcon } from "lucide-react"
import { useRouter } from "next/navigation"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { InvoiceForm } from "@/modules/invoices/invoice-form"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { formatCurrency, formatDate, formatEnum } from "@/shared/utils/formatters"
import { INVOICE_ROUTES } from "@garage/api"
import { formatDate, formatEnum } from "@/shared/utils/formatters"
import { INVOICE_ROUTES, InvoiceStatus } from "@garage/api"
import type { InvoicesClient } from "@garage/api"
import { Money } from "@/shared/components/money"
import { RelationLink } from "@/shared/components/relation-link"
type InvoiceItem = {
id: number
@@ -86,6 +86,9 @@ export default function InvoicesPage() {
<ResourcePage<InvoicesClient>
pageTitle="Invoices"
routeKey={INVOICE_ROUTES.INDEX}
searchable
searchPlaceholder="Search invoices..."
statusFilter={{ statuses: InvoiceStatus }}
getClient={(api) => api.invoices}
onRowClick={(row) => router.push(`/sales/invoice/${(row as any).id}`)}
headerProps={({ selectedItem, invalidateQuery }) => ({
@@ -137,29 +140,13 @@ export default function InvoicesPage() {
header: ({ column }) => <ColumnHeader column={column} title="Customer" />,
cell: ({ row }) => {
const item = row.original as unknown as InvoiceItem
const customerLabel = getCustomerLabel(item)
const phone = item.customer?.phone
return (
<div className="min-w-[190px]">
{item.customer?.id ? (
<Button variant="outline" asChild size="sm" onClick={(e) => e.stopPropagation()}>
<Link href={`/sales/customers/${item.customer.id}`}>
<UserIcon /> {customerLabel}
{phone && (
<span className="ms-1 text-muted-foreground">· {phone}</span>
)}
</Link>
</Button>
) : (
<p className="font-medium leading-none">
{customerLabel}
{phone && (
<span className="ms-1 text-muted-foreground">· {phone}</span>
)}
</p>
)}
</div>
<RelationLink
href={item.customer?.id ? `/sales/customers/${item.customer.id}` : null}
icon={UserIcon}
label={getCustomerLabel(item)}
meta={item.customer?.phone}
/>
)
},
},
@@ -168,14 +155,14 @@ export default function InvoicesPage() {
header: ({ column }) => <ColumnHeader column={column} title="Vehicle" />,
cell: ({ row }) => {
const item = row.original as unknown as InvoiceItem
const vehicleId = (item.vehicle as any)?.id
return (
<div className="min-w-[220px]">
<p className="font-medium leading-none">{getVehicleLabel(item)}</p>
<p className="mt-1 text-xs text-muted-foreground">
{item.vehicle?.license_plate || "—"}
</p>
</div>
<RelationLink
href={vehicleId ? `/sales/vehicles/${vehicleId}` : null}
icon={Car}
label={getVehicleLabel(item)}
meta={item.vehicle?.license_plate}
/>
)
},
},
@@ -242,10 +229,10 @@ export default function InvoicesPage() {
return (
<div className="min-w-[150px]">
<p className="font-medium leading-none">
Total: {formatCurrency(item.total ?? 0)}
Total: {<Money value={item.total ?? 0} />}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Due: {formatCurrency(item.balance_due ?? 0)}
Due: {<Money value={item.balance_due ?? 0} />}
</p>
</div>
)
@@ -7,7 +7,7 @@ import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { AppointmentForm } from "@/modules/appointments/appointment-form"
import { APPOINTMENT_ROUTES } from "@garage/api"
import { APPOINTMENT_ROUTES, AppointmentStatus } from "@garage/api"
import type { AppointmentsClient } from "@garage/api"
import { CalendarCheck2Icon, ClockIcon } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
@@ -69,6 +69,9 @@ export default function JobCardAppointmentsPage({
return (
<ResourcePage<AppointmentsClient>
routeKey={APPOINTMENT_ROUTES.INDEX}
searchable
searchPlaceholder="Search appointments..."
statusFilter={{ statuses: AppointmentStatus }}
getClient={(api) => api.appointments}
extraParams={{ job_card_id: jobCardId }}
header={null}
@@ -6,9 +6,12 @@ import { Badge } from "@/shared/components/ui/badge"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import { BillForm } from "@/modules/bills/bill-form"
import { BILL_ROUTES } from "@garage/api"
import { BILL_ROUTES, BillStatus } from "@garage/api"
import type { BillsClient } from "@garage/api"
import { useJobCard } from "@/modules/job-cards/job-card-context"
import { RelationLink } from "@/shared/components/relation-link"
import { Building2, FileTextIcon } from "lucide-react"
import { getFullName } from "@/shared/utils/getFullName"
export default function JobCardBillsPage({
params,
@@ -25,6 +28,9 @@ export default function JobCardBillsPage({
return (
<ResourcePage<BillsClient>
routeKey={BILL_ROUTES.INDEX}
searchable
searchPlaceholder="Search bills..."
statusFilter={{ statuses: BillStatus }}
getClient={(api) => api.bills}
extraParams={{ job_card_id: jobCardId }}
header={null}
@@ -46,7 +52,16 @@ export default function JobCardBillsPage({
{
accessorKey: "bill_number",
header: ({ column }) => <ColumnHeader column={column} title="Bill #" />,
cell: ({ row }) => (row.original as any).bill_number || "—",
cell: ({ row }) => {
const item = row.original as any
return (
<RelationLink
href={item.id ? `/purchase/bill/${item.id}` : null}
icon={FileTextIcon}
label={item.bill_number}
/>
)
},
},
{
accessorKey: "title",
@@ -55,7 +70,16 @@ export default function JobCardBillsPage({
{
accessorKey: "vendor_name",
header: ({ column }) => <ColumnHeader column={column} title="Vendor" />,
cell: ({ row }) => (row.original as any).vendor_name || "—",
cell: ({ row }) => {
const item = row.original as any
return (
<RelationLink
href={item.vendor?.id ? `/purchase/vendor/${item.vendor.id}` : null}
icon={Building2}
label={item.vendor?.company_name || getFullName(item.vendor) || item.vendor_name}
/>
)
},
},
{
accessorKey: "bill_date",
@@ -4,10 +4,14 @@ import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { InspectionForm } from "@/modules/inspections/inspection-form"
import { INSPECTION_ROUTES } from "@garage/api"
import { INSPECTION_ROUTES, InspectionStatus } from "@garage/api"
import type { InspectionsClient } from "@garage/api"
import { useRouter } from "next/navigation"
import { useJobCard } from "@/modules/job-cards/job-card-context"
import { RelationLink } from "@/shared/components/relation-link"
import { Car, UserIcon } from "lucide-react"
import { getFullName } from "@/shared/utils/getFullName"
import { getVehicleLabel } from "@/modules/vehicles/utils/getVehicleLabel"
export default function InspectionsPage() {
const router = useRouter()
@@ -17,6 +21,9 @@ export default function InspectionsPage() {
pageTitle="Inspections"
extraParams={{job_card_id: jobCard?.id}}
routeKey={INSPECTION_ROUTES.INDEX}
searchable
searchPlaceholder="Search inspections..."
statusFilter={{ statuses: InspectionStatus }}
getClient={(api) => api.inspections}
onRowClick={(row) => router.push(`/sales/inspections/${(row as any).id}`)}
headerProps={({ selectedItem, invalidateQuery }) => ({
@@ -42,7 +49,13 @@ export default function InspectionsPage() {
header: ({ column }) => <ColumnHeader column={column} title="Customer" />,
cell: ({ row }) => {
const c = (row.original as any).customer
return c ? `${c.first_name ?? ""} ${c.last_name ?? ""}`.trim() : "—"
return (
<RelationLink
href={c?.id ? `/sales/customers/${c.id}` : null}
icon={UserIcon}
label={getFullName(c)}
/>
)
},
},
{
@@ -50,7 +63,14 @@ export default function InspectionsPage() {
header: ({ column }) => <ColumnHeader column={column} title="Vehicle" />,
cell: ({ row }) => {
const v = (row.original as any).vehicle
return v ? `${v.make ?? ""} ${v.model ?? ""}`.trim() : "—"
return (
<RelationLink
href={v?.id ? `/sales/vehicles/${v.id}` : null}
icon={Car}
label={getVehicleLabel(v as any)}
meta={v?.license_plate}
/>
)
},
},
{
@@ -8,6 +8,9 @@ import { PurchaseOrderForm } from "@/modules/purchase-orders/purchase-order-form
import { PURCHASE_ORDER_ROUTES } from "@garage/api"
import type { PurchaseOrdersClient } from "@garage/api"
import { useJobCard } from "@/modules/job-cards/job-card-context"
import { RelationLink } from "@/shared/components/relation-link"
import { Building2, FileTextIcon } from "lucide-react"
import { getFullName } from "@/shared/utils/getFullName"
export default function JobCardPurchaseOrdersPage({
params,
@@ -24,6 +27,8 @@ export default function JobCardPurchaseOrdersPage({
return (
<ResourcePage<PurchaseOrdersClient>
routeKey={PURCHASE_ORDER_ROUTES.INDEX}
searchable
searchPlaceholder="Search purchase orders..."
getClient={(api) => api.purchaseOrders}
extraParams={{ job_card_id: jobCardId }}
header={null}
@@ -46,7 +51,16 @@ export default function JobCardPurchaseOrdersPage({
{
accessorKey: "order_number",
header: ({ column }) => <ColumnHeader column={column} title="Order #" />,
cell: ({ row }) => (row.original as any).order_number || "—",
cell: ({ row }) => {
const item = row.original as any
return (
<RelationLink
href={item.id ? `/purchase/purchase-order/${item.id}` : null}
icon={FileTextIcon}
label={item.order_number}
/>
)
},
},
{
accessorKey: "title",
@@ -55,7 +69,16 @@ export default function JobCardPurchaseOrdersPage({
{
accessorKey: "vendor_name",
header: ({ column }) => <ColumnHeader column={column} title="Vendor" />,
cell: ({ row }) => (row.original as any).vendor_name || "—",
cell: ({ row }) => {
const item = row.original as any
return (
<RelationLink
href={item.vendor?.id ? `/purchase/vendor/${item.vendor.id}` : null}
icon={Building2}
label={item.vendor?.company_name || getFullName(item.vendor) || item.vendor_name}
/>
)
},
},
{
accessorKey: "order_date",
@@ -5,7 +5,7 @@ import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { TaskForm } from "@/modules/tasks/task-form"
import { TASK_ROUTES } from "@garage/api"
import { TASK_ROUTES, TaskStatus } from "@garage/api"
import type { TasksClient } from "@garage/api"
import { Badge } from "@/shared/components/ui/badge"
import { useJobCard } from "@/modules/job-cards/job-card-context"
@@ -26,6 +26,9 @@ export default function JobCardTasksPage({
return (
<ResourcePage<TasksClient>
routeKey={TASK_ROUTES.INDEX}
searchable
searchPlaceholder="Search tasks..."
statusFilter={{ statuses: TaskStatus }}
getClient={(api) => api.tasks}
extraParams={{ job_card_id: jobCardId }}
header={null}
@@ -6,12 +6,9 @@ import FormDialog from '@/shared/components/form-dialog'
import { JobCardForm } from '@/modules/job-cards/job-card-form'
import { JOB_CARD_ROUTES, JobCardStatus } from '@garage/api'
import type { JobCardsClient } from '@garage/api'
import { Tabs, TabsList, TabsTrigger } from '@/shared/components/ui/tabs'
import { ClipboardListIcon, SearchIcon } from 'lucide-react'
import { ClipboardListIcon } from 'lucide-react'
import { Badge } from '@/shared/components/ui/badge'
import { Input } from '@/shared/components/ui/input'
import { useRouter } from 'next/navigation'
import { useState, useEffect, useMemo } from 'react'
import { formatDate, formatEnum, formatNumber } from '@/shared/utils/formatters'
import { useFilterParams } from '@/shared/hooks/use-filter-params'
import { FilterDrawer, FilterTrigger } from '@/shared/components/filter-drawer'
@@ -37,30 +34,17 @@ const statusColorMap: Record<string, string> = {
export default function JobCardsPage() {
const router = useRouter()
const [searchInput, setSearchInput] = useState("")
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<string>("all")
const filter = useFilterParams(jobCardFilterConfig)
useEffect(() => {
const timer = setTimeout(() => setSearch(searchInput), 400)
return () => clearTimeout(timer)
}, [searchInput])
const extraParams = useMemo(() => {
const params: Record<string, unknown> = { ...filter.appliedParams }
if (search) params.search = search
if (statusFilter !== "all") params.status = statusFilter
return params
}, [filter.appliedParams, search, statusFilter])
return (
<>
<ResourcePage<JobCardsClient>
routeKey={JOB_CARD_ROUTES.INDEX}
getClient={(api) => api.jobCards}
extraParams={extraParams}
searchable
searchPlaceholder="Search job cards..."
statusFilter={{ statuses: JobCardStatus }}
extraParams={filter.appliedParams}
onRowClick={(row) => router.push(`/sales/job-cards/${row.id}`)}
headerProps={({ selectedItem, invalidateQuery }) => ({
title: "Job Cards",
@@ -135,30 +119,6 @@ export default function JobCardsPage() {
},
actionsColumn(),
]}
tableHeader={
<div className="flex justify-between">
<Tabs value={statusFilter} onValueChange={setStatusFilter}>
<TabsList variant="line">
<TabsTrigger value="all">All</TabsTrigger>
{JobCardStatus.map((status) => (
<TabsTrigger key={status} value={status}>
{formatEnum(status)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="relative w-64">
<SearchIcon className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search job cards..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-8"
/>
</div>
</div>
}
/>
<FilterDrawer
@@ -14,6 +14,7 @@ import {
ClipboardListIcon,
} from "lucide-react"
import { getFullName } from "@/shared/utils/getFullName"
import { RelationLink } from "@/shared/components/relation-link"
type PaymentReceivedItem = {
id: number
@@ -33,6 +34,8 @@ export default function PaymentReceivedPage() {
return (
<ResourcePage<{ list(query?: any): Promise<any>; destroy(id: string): Promise<any> }>
routeKey={PAYMENT_RECEIVED_ROUTES.INDEX}
searchable
searchPlaceholder="Search payments..."
getClient={(api) => ({
list: (query?: any) => api.paymentReceived.list(query),
destroy: (id: string) => api.paymentReceived.destroy(id),
@@ -67,12 +70,14 @@ export default function PaymentReceivedPage() {
accessorKey: "customer",
header: ({ column }) => <ColumnHeader column={column} title="Customer" />,
cell: ({ row }) => {
const item:any = row.original as unknown as PaymentReceivedItem
const item: any = row.original as unknown as PaymentReceivedItem
const customer = item.customer
return (
<div className="flex items-center gap-2">
<UserIcon className="h-4 w-4 text-muted-foreground" />
<span>{getFullName(item.customer) || "—"}</span>
</div>
<RelationLink
href={customer?.id ? `/sales/customers/${customer.id}` : null}
icon={UserIcon}
label={getFullName(customer) || item.customer_name}
/>
)
},
},
@@ -80,13 +85,14 @@ export default function PaymentReceivedPage() {
accessorKey: "job_card",
header: ({ column }) => <ColumnHeader column={column} title="Job Card" />,
cell: ({ row }) => {
const item:any = row.original as unknown as PaymentReceivedItem
const label = item.job_card?.title
const item: any = row.original as unknown as PaymentReceivedItem
const jobCard = item.job_card
return (
<div className="flex items-center gap-2">
<ClipboardListIcon className="h-4 w-4 text-muted-foreground" />
<span>{label || "—"}</span>
</div>
<RelationLink
href={jobCard?.id ? `/sales/job-cards/${jobCard.id}` : null}
icon={ClipboardListIcon}
label={jobCard?.title || item.job_card_name}
/>
)
},
},
@@ -8,8 +8,10 @@ import FormDialog from '@/shared/components/form-dialog'
import { EstimateForm } from '@/modules/estimates/estimate-form'
import { ESTIMATE_ROUTES } from '@garage/api'
import type { EstimatesClient } from '@garage/api'
import { FileTextIcon } from 'lucide-react'
import { FileTextIcon, UserIcon } from 'lucide-react'
import { useVehicle } from '@/modules/vehicles/vehicle-context'
import { RelationLink } from '@/shared/components/relation-link'
import { getFullName } from '@/shared/utils/getFullName'
export default function VehicleEstimatesPage({ params }: { params: Promise<{ id: string }> }) {
const { id: vehicleId } = use(params)
@@ -37,6 +39,8 @@ export default function VehicleEstimatesPage({ params }: { params: Promise<{ id:
}
pageTitle="Vehicle Estimates"
routeKey={ESTIMATE_ROUTES.INDEX}
searchable
searchPlaceholder="Search estimates..."
getClient={(api) => api.estimates}
extraParams={{ vehicle_id: vehicleId }}
header={
@@ -63,6 +67,16 @@ export default function VehicleEstimatesPage({ params }: { params: Promise<{ id:
{
accessorKey: "customer_name",
header: ({ column }) => <ColumnHeader column={column} title="Customer" />,
cell: ({ row }) => {
const item = row.original as any
return (
<RelationLink
href={item.customer?.id ? `/sales/customers/${item.customer.id}` : null}
icon={UserIcon}
label={getFullName(item.customer) || item.customer_name}
/>
)
},
},
{
accessorKey: "date",
@@ -7,14 +7,14 @@ import type { ColumnDef } from '@tanstack/react-table'
import FormDialog from '@/shared/components/form-dialog'
import { ImportDataButton } from '@/shared/components/import-data-button'
import { ExportDataButton } from '@/shared/components/export-data-button'
import { DownloadSampleButton } from '@/shared/components/download-sample-button'
import { useAuthApi } from '@/shared/useApi'
import type { CrudResourceColumnHelpers, ResourceItem } from '@/shared/data-view/resource-page'
import { VehicleForm } from '@/modules/vehicles/vehicle-form'
import { VEHICLE_ROUTES } from '@garage/api'
import type { VehiclesClient } from '@garage/api'
import { CarIcon } from 'lucide-react'
import { CarIcon, UserIcon } from 'lucide-react'
import { getFullName } from '@/shared/utils/getFullName'
import { RelationLink } from '@/shared/components/relation-link'
export default function VehiclesPage() {
const router = useRouter()
@@ -23,19 +23,19 @@ export default function VehiclesPage() {
<ResourcePage<VehiclesClient>
pageTitle="Vehicles"
routeKey={VEHICLE_ROUTES.INDEX}
searchable
searchPlaceholder="Search vehicles..."
getClient={(api) => api.vehicles}
onRowClick={(row) => router.push(`/sales/vehicles/${(row as any).id}`)}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<div className='flex items-center gap-2'>
<DownloadSampleButton
onDownload={() => api.vehicles.downloadImportSample()}
fileName='vehicles-import-sample'
/>
<ImportDataButton
onImport={(file) => api.vehicles.importData(file)}
onSuccess={invalidateQuery}
entityLabel="Vehicles"
onDownloadSample={() => api.vehicles.downloadImportSample()}
sampleFileName='vehicles-import-sample'
/>
<ExportDataButton
onExport={(filters) => api.vehicles.exportData(filters)}
@@ -81,8 +81,14 @@ export default function VehiclesPage() {
accessorKey: "customers",
header: ({ column }: any) => <ColumnHeader column={column} title="Customer" />,
cell: ({ row }: any) => {
const val = (row.original as any).customers?.[0]
return getFullName(val)
const customer = (row.original as any).customers?.[0]
return (
<RelationLink
href={customer?.id ? `/sales/customers/${customer.id}` : null}
icon={UserIcon}
label={getFullName(customer)}
/>
)
},
} as any,
{