updates
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import { DashboardDetailsPage } from '@/base/components/layout/dashboard'
|
||||
import { getServerApi } from '@garage/api/server'
|
||||
import { CustomerActions } from '@/modules/customers/customer-actions'
|
||||
import { CustomerProvider } from '@/modules/customers/customer-context'
|
||||
import React from 'react'
|
||||
|
||||
export default async function layout(props: {
|
||||
params: Promise<{ id: string }>
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { id } = await props.params
|
||||
const api = await getServerApi()
|
||||
const customer = await api.customers.getById(id)
|
||||
|
||||
const firstName = customer.data?.first_name ?? ''
|
||||
const lastName = customer.data?.last_name ?? ''
|
||||
const fullName = [firstName, lastName].filter(Boolean).join(' ') || 'Customer Details'
|
||||
const customerLabel = fullName
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomerProvider customer={{ id, label: customerLabel }}>
|
||||
<DashboardDetailsPage
|
||||
className="p-0 lg:p-0"
|
||||
title={fullName}
|
||||
description={customer.data?.email ?? customer.data?.phone ?? undefined}
|
||||
backHref="/sales/customers"
|
||||
actions={<CustomerActions customerId={id} />}
|
||||
tabs={[
|
||||
{ href: `/sales/customers/${id}`, label: 'Details' },
|
||||
{ href: `/sales/customers/${id}/notes`, label: 'Notes' },
|
||||
{ href: `/sales/customers/${id}/vehicles`, label: 'Vehicles' },
|
||||
]}
|
||||
>
|
||||
{props.children}
|
||||
</DashboardDetailsPage>
|
||||
</CustomerProvider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
"use client"
|
||||
|
||||
import { useParams } from "next/navigation"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { type ColumnDef } from "@tanstack/react-table"
|
||||
import { useState } from "react"
|
||||
import { Plus, Trash2, StickyNote } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { z } from "zod"
|
||||
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { DataTable, ColumnHeader } from "@/shared/data-view/table-view"
|
||||
import { confirm } from "@/shared/components/confirm-dialog"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldError,
|
||||
} from "@/shared/components/ui/field"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
|
||||
|
||||
type CustomerNote = {
|
||||
id: number
|
||||
note: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
const addNoteSchema = z.object({
|
||||
note: z.string().min(1, "Note content is required"),
|
||||
})
|
||||
type AddNoteValues = z.infer<typeof addNoteSchema>
|
||||
|
||||
export default function CustomerNotesPage() {
|
||||
const { id: customerId } = useParams<{ id: string }>()
|
||||
const api = useAuthApi()
|
||||
const queryClient = useQueryClient()
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
|
||||
const queryKey = ["customer-notes", customerId]
|
||||
|
||||
const { data: customerData, isLoading } = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => api.customers.getById(customerId),
|
||||
})
|
||||
|
||||
const notes: CustomerNote[] = (customerData?.data as any)?.notes ?? []
|
||||
|
||||
const meta = (customerData as any)?.meta
|
||||
const pagination = {
|
||||
page: meta?.current_page ?? 1,
|
||||
pageSize: meta?.per_page ?? 15,
|
||||
pageCount: meta?.last_page ?? 1,
|
||||
total: meta?.total ?? notes.length,
|
||||
}
|
||||
|
||||
const addNoteMutation = useMutation({
|
||||
mutationFn: (values: AddNoteValues) =>
|
||||
api.customers.addNote(customerId, { note: values.note }),
|
||||
onSuccess: () => {
|
||||
toast.success("Note added successfully.")
|
||||
queryClient.invalidateQueries({ queryKey })
|
||||
setDialogOpen(false)
|
||||
reset()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to add note.")
|
||||
},
|
||||
})
|
||||
|
||||
const deleteNoteMutation = useMutation({
|
||||
mutationFn: (noteId: number) => api.customers.deleteNote(customerId, noteId),
|
||||
onSuccess: () => {
|
||||
toast.success("Note deleted successfully.")
|
||||
queryClient.invalidateQueries({ queryKey })
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to delete note.")
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<AddNoteValues>({
|
||||
resolver: zodResolver(addNoteSchema),
|
||||
defaultValues: { note: "" },
|
||||
})
|
||||
|
||||
const handleDelete = async (note: CustomerNote) => {
|
||||
const confirmed = await confirm({
|
||||
title: "Delete Note",
|
||||
description: "Are you sure you want to delete this note?",
|
||||
confirmLabel: "Delete",
|
||||
variant: "destructive",
|
||||
})
|
||||
if (confirmed) {
|
||||
deleteNoteMutation.mutate(note.id)
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnDef<CustomerNote>[] = [
|
||||
{
|
||||
accessorKey: "note",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Note" />,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="whitespace-pre-wrap text-sm">{getValue<string>()}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "created_at",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Created At" />,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue<string>()
|
||||
return val ? new Date(val).toLocaleDateString() : "—"
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(row.original)}
|
||||
disabled={deleteNoteMutation.isPending}
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<DashboardPage
|
||||
header={null}
|
||||
title="Notes"
|
||||
toolbar={
|
||||
<Button size="sm" onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
Add Note
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={notes}
|
||||
pagination={pagination}
|
||||
sorting={[]}
|
||||
onChange={() => {}}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Note</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={handleSubmit((values) => addNoteMutation.mutate(values))}
|
||||
className="grid gap-4"
|
||||
>
|
||||
<Field>
|
||||
<FieldLabel>Note</FieldLabel>
|
||||
<Textarea
|
||||
{...register("note")}
|
||||
placeholder="Enter note..."
|
||||
rows={4}
|
||||
/>
|
||||
{errors.note && <FieldError>{errors.note.message}</FieldError>}
|
||||
</Field>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDialogOpen(false)
|
||||
reset()
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={addNoteMutation.isPending}>
|
||||
{addNoteMutation.isPending ? "Saving..." : "Save Note"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getServerApi } from '@garage/api/server'
|
||||
import { CustomerGeneralInfo } from '@/modules/customers/customer-general-info'
|
||||
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
|
||||
|
||||
export default async function page(props: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await props.params
|
||||
const api = await getServerApi()
|
||||
const customer = await api.customers.getById(id)
|
||||
|
||||
if (!customer.data) {
|
||||
return <div className="text-muted-foreground p-6">Customer not found.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardPage header={null}>
|
||||
<CustomerGeneralInfo customer={customer.data} />
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client"
|
||||
|
||||
import { use } 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 { VehicleForm } from "@/modules/vehicles/vehicle-form"
|
||||
import { VEHICLE_ROUTES } from "@garage/api"
|
||||
import type { VehiclesClient } from "@garage/api"
|
||||
import { CarIcon } from "lucide-react"
|
||||
import { useCustomer } from "@/modules/customers/customer-context"
|
||||
|
||||
export default function CustomerVehiclesPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id: customerId } = use(params)
|
||||
const customer = useCustomer()
|
||||
|
||||
return (
|
||||
<ResourcePage<VehiclesClient>
|
||||
toolbar={({ invalidateQuery, selectedItem, closeDialog }) => (
|
||||
<FormDialog title="Vehicle">
|
||||
{(resourceId) => (
|
||||
<VehicleForm
|
||||
resourceId={resourceId}
|
||||
initialData={selectedItem}
|
||||
onSuccess={() => {
|
||||
closeDialog()
|
||||
invalidateQuery()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
)}
|
||||
pageTitle="Customer Vehicles"
|
||||
routeKey={VEHICLE_ROUTES.INDEX}
|
||||
getClient={(api) => api.vehicles}
|
||||
extraParams={{ customer_id: customerId }}
|
||||
header={null}
|
||||
columns={({ actionsColumn }) => [
|
||||
{
|
||||
accessorKey: "make",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Vehicle" />,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original as any
|
||||
const make = r.make ?? ""
|
||||
const model = r.model ?? ""
|
||||
const display = `${make} ${model}`.trim() || "—"
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<CarIcon className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium">{display}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "year",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Year" />,
|
||||
cell: ({ row }) => (row.original as any).year ?? "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "license_plate",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="License Plate" />,
|
||||
cell: ({ row }) => {
|
||||
const val = (row.original as any).license_plate
|
||||
return val ? <span className="font-mono text-xs">{val}</span> : "—"
|
||||
},
|
||||
},
|
||||
actionsColumn(),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
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'
|
||||
@@ -9,11 +10,13 @@ import type { CustomersClient } from '@garage/api'
|
||||
import { Building2Icon, UserIcon } from 'lucide-react'
|
||||
|
||||
export default function CustomersPage() {
|
||||
const router = useRouter()
|
||||
return (
|
||||
<ResourcePage<CustomersClient>
|
||||
pageTitle='Customers'
|
||||
routeKey={CUSTOMER_ROUTES.INDEX}
|
||||
getClient={(api) => api.customers}
|
||||
onRowClick={(row) => router.push(`/sales/customers/${(row as any).id}`)}
|
||||
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||
actions: (
|
||||
<FormDialog title="Customer">
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { DashboardDetailsPage } from '@/base/components/layout/dashboard'
|
||||
import { getServerApi } from '@garage/api/server'
|
||||
import { EstimateActions } from '@/modules/estimates/estimate-actions'
|
||||
import { EstimateProvider } from '@/modules/estimates/estimate-context'
|
||||
import { FileTextIcon } from 'lucide-react'
|
||||
import React from 'react'
|
||||
|
||||
export default async function layout(props: {
|
||||
params: Promise<{ id: string }>
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { id } = await props.params
|
||||
const api = await getServerApi()
|
||||
const estimate = await api.estimates.getById(id)
|
||||
|
||||
const estimateData = (estimate as any)?.data
|
||||
const title = estimateData?.title || estimateData?.estimate_number || `Estimate #${id}`
|
||||
const estimateLabel = estimateData?.estimate_number
|
||||
? `${estimateData.estimate_number}${estimateData.title ? ` — ${estimateData.title}` : ''}`
|
||||
: title
|
||||
|
||||
return (
|
||||
<EstimateProvider estimate={{ id, label: estimateLabel }}>
|
||||
<DashboardDetailsPage
|
||||
className="p-0 lg:p-0"
|
||||
icon={<FileTextIcon className="size-5" />}
|
||||
title={title}
|
||||
description={
|
||||
estimateData?.date ? `Date: ${estimateData.date}` : undefined
|
||||
}
|
||||
backHref="/sales/estimates"
|
||||
actions={<EstimateActions estimateId={id} />}
|
||||
tabs={[
|
||||
{ href: `/sales/estimates/${id}`, label: 'Details' },
|
||||
]}
|
||||
>
|
||||
{props.children}
|
||||
</DashboardDetailsPage>
|
||||
</EstimateProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { getServerApi } from '@garage/api/server'
|
||||
import { EstimateGeneralInfo } from '@/modules/estimates/estimate-general-info'
|
||||
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
|
||||
|
||||
export default async function page(props: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await props.params
|
||||
const api = await getServerApi()
|
||||
const estimate = await api.estimates.getById(id)
|
||||
|
||||
const estimateData = (estimate as any)?.data
|
||||
|
||||
if (!estimateData) {
|
||||
return <div className="text-muted-foreground p-4">Estimate not found.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardPage header={null}>
|
||||
<EstimateGeneralInfo estimate={estimateData} />
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
@@ -6,20 +6,11 @@ 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 { Car, FileTextIcon } from 'lucide-react'
|
||||
import { Button } from '@/shared/components/ui/button'
|
||||
import Link from 'next/link'
|
||||
|
||||
type EstimateItem = {
|
||||
id: number
|
||||
title?: string
|
||||
estimate_number?: string
|
||||
date?: string
|
||||
customer_name?: string
|
||||
vehicle_name?: string
|
||||
has_insurance?: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export default function EstimatesPage({ vehicleId }: { vehicleId: string }) {
|
||||
export default function EstimatesPage() {
|
||||
return (
|
||||
<ResourcePage<EstimatesClient>
|
||||
pageTitle="Estimates"
|
||||
@@ -31,7 +22,6 @@ export default function EstimatesPage({ vehicleId }: { vehicleId: string }) {
|
||||
{(resourceId) => (
|
||||
<EstimateForm
|
||||
resourceId={resourceId}
|
||||
initialData={{ vehicle:{label: vehicleId, value: vehicleId}}}
|
||||
onSuccess={invalidateQuery}
|
||||
/>
|
||||
)}
|
||||
@@ -43,11 +33,13 @@ export default function EstimatesPage({ vehicleId }: { vehicleId: string }) {
|
||||
accessorKey: "title",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as unknown as EstimateItem
|
||||
const item = row.original
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<FileTextIcon className="text-muted-foreground h-4 w-4" />
|
||||
<span>{item.title}</span>
|
||||
<Link href={`/sales/estimates/${item.id}`} className="flex items-center gap-2 hover:underline">
|
||||
<FileTextIcon className="text-muted-foreground h-4 w-4" />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
@@ -61,8 +53,16 @@ export default function EstimatesPage({ vehicleId }: { vehicleId: string }) {
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Customer" />,
|
||||
},
|
||||
{
|
||||
accessorKey: "vehicle_name",
|
||||
accessorKey: "vehicle_id",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Vehicle" />,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original
|
||||
return <Button variant="outline" asChild size="sm">
|
||||
<Link href={`/sales/vehicles/${item.vehicle_id}`}>
|
||||
<Car/> Go to vehicle
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "date",
|
||||
@@ -72,7 +72,7 @@ export default function EstimatesPage({ vehicleId }: { vehicleId: string }) {
|
||||
accessorKey: "has_insurance",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Insurance" />,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as unknown as EstimateItem
|
||||
const item = row.original
|
||||
return item.has_insurance ? "Yes" : "No"
|
||||
},
|
||||
},
|
||||
@@ -80,7 +80,7 @@ export default function EstimatesPage({ vehicleId }: { vehicleId: string }) {
|
||||
accessorKey: "created_at",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as unknown as EstimateItem
|
||||
const item = row.original
|
||||
return item.created_at ? new Date(item.created_at).toLocaleDateString() : "—"
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,589 @@
|
||||
"use client"
|
||||
|
||||
import { use, useState, useRef, useCallback } from "react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { DataTable, ColumnHeader } from "@/shared/data-view/table-view"
|
||||
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
Plus,
|
||||
Ellipsis,
|
||||
Pencil,
|
||||
Trash2,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
MinusCircle,
|
||||
CircleDot,
|
||||
Paperclip,
|
||||
FileUp,
|
||||
FileText,
|
||||
FileImage,
|
||||
File,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import { INSPECTION_ROUTES } from "@garage/api"
|
||||
|
||||
// ── Types ──
|
||||
|
||||
type CheckpointItem = {
|
||||
id: number
|
||||
inspection_id?: number
|
||||
name?: string
|
||||
description?: string
|
||||
record_type?: string
|
||||
condition_rate?: number
|
||||
file?: string
|
||||
status?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
const CHECKPOINT_STATUSES = [
|
||||
{ value: "passed", label: "Passed", icon: CheckCircle2, color: "bg-green-100 text-green-800" },
|
||||
{ value: "need_attention", label: "Need Attention", icon: AlertTriangle, color: "bg-yellow-100 text-yellow-800" },
|
||||
{ value: "failed", label: "Failed", icon: XCircle, color: "bg-red-100 text-red-800" },
|
||||
{ value: "not_applicable", label: "Not Applicable", icon: MinusCircle, color: "bg-gray-100 text-gray-800" },
|
||||
{ value: "not_inspected", label: "Not Inspected", icon: CircleDot, color: "bg-blue-100 text-blue-800" },
|
||||
] as const
|
||||
|
||||
const RECORD_TYPES = [
|
||||
{ value: "record_conditions", label: "Record Conditions" },
|
||||
{ value: "record_audio", label: "Record Audio" },
|
||||
{ value: "record_video", label: "Record Video" },
|
||||
{ value: "capture_photo", label: "Capture Photo" },
|
||||
] as const
|
||||
|
||||
function formatStatus(status?: string) {
|
||||
if (!status) return "Not Inspected"
|
||||
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
function getStatusConfig(status?: string) {
|
||||
return CHECKPOINT_STATUSES.find((s) => s.value === status) || CHECKPOINT_STATUSES[4]
|
||||
}
|
||||
|
||||
// ── Checkpoint Form Dialog ──
|
||||
|
||||
function CheckpointFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
inspectionId,
|
||||
checkpoint,
|
||||
onSuccess,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
inspectionId: string
|
||||
checkpoint?: CheckpointItem | null
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const api = useAuthApi()
|
||||
const [name, setName] = useState(checkpoint?.name ?? "")
|
||||
const [description, setDescription] = useState(checkpoint?.description ?? "")
|
||||
const [recordType, setRecordType] = useState(checkpoint?.record_type ?? "record_conditions")
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
|
||||
const isEditing = !!checkpoint
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) {
|
||||
toast.error("Name is required")
|
||||
return
|
||||
}
|
||||
setIsPending(true)
|
||||
try {
|
||||
const payload = {
|
||||
inspection_id: Number(inspectionId),
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
record_type: recordType,
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
const promise = api.inspections.updateCheckpoint(String(checkpoint.id), payload)
|
||||
toast.promise(promise, {
|
||||
loading: "Updating checkpoint...",
|
||||
success: "Checkpoint updated",
|
||||
error: "Failed to update checkpoint",
|
||||
})
|
||||
await promise
|
||||
} else {
|
||||
const promise = api.inspections.createCheckpoint(payload)
|
||||
toast.promise(promise, {
|
||||
loading: "Creating checkpoint...",
|
||||
success: "Checkpoint created",
|
||||
error: "Failed to create checkpoint",
|
||||
})
|
||||
await promise
|
||||
}
|
||||
onSuccess()
|
||||
onOpenChange(false)
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? "Edit Checkpoint" : "Add Checkpoint"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cp-name">Name *</Label>
|
||||
<Input
|
||||
id="cp-name"
|
||||
placeholder="e.g. Engine Oil Level"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cp-description">Description</Label>
|
||||
<Textarea
|
||||
id="cp-description"
|
||||
placeholder="Check oil level and condition"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cp-record-type">Record Type</Label>
|
||||
<Select value={recordType} onValueChange={setRecordType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{RECORD_TYPES.map((rt) => (
|
||||
<SelectItem key={rt.value} value={rt.value}>
|
||||
{rt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isEditing ? <Pencil className="size-4" /> : <Plus className="size-4" />}
|
||||
{isPending
|
||||
? (isEditing ? "Updating..." : "Creating...")
|
||||
: (isEditing ? "Update Checkpoint" : "Create Checkpoint")}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Attachments Dialog ──
|
||||
|
||||
function getFileIcon(url: string) {
|
||||
const ext = url.split(".").pop()?.toLowerCase()
|
||||
if (["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(ext ?? "")) return FileImage
|
||||
if (ext === "pdf") return FileText
|
||||
return File
|
||||
}
|
||||
|
||||
function getFileName(url: string) {
|
||||
try {
|
||||
return decodeURIComponent(url.split("/").pop() ?? "Attachment")
|
||||
} catch {
|
||||
return url.split("/").pop() ?? "Attachment"
|
||||
}
|
||||
}
|
||||
|
||||
function isImageUrl(url: string) {
|
||||
const ext = url.split(".").pop()?.toLowerCase()
|
||||
return ["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(ext ?? "")
|
||||
}
|
||||
|
||||
function CheckpointAttachmentsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
checkpoint,
|
||||
onSuccess,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
checkpoint: CheckpointItem | null
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const api = useAuthApi()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
|
||||
const handleUpload = useCallback(async (file: globalThis.File) => {
|
||||
if (!checkpoint) return
|
||||
setIsUploading(true)
|
||||
try {
|
||||
const promise = api.inspections.uploadCheckpointMedia(
|
||||
String(checkpoint.id),
|
||||
{ file },
|
||||
)
|
||||
toast.promise(promise, {
|
||||
loading: "Uploading attachment...",
|
||||
success: "Attachment uploaded",
|
||||
error: "Failed to upload attachment",
|
||||
})
|
||||
await promise
|
||||
onSuccess()
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ""
|
||||
}
|
||||
}, [api, checkpoint, onSuccess])
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!checkpoint) return
|
||||
const promise = api.inspections.deleteCheckpointMedia(String(checkpoint.id))
|
||||
toast.promise(promise, {
|
||||
loading: "Removing attachment...",
|
||||
success: "Attachment removed",
|
||||
error: "Failed to remove attachment",
|
||||
})
|
||||
await promise
|
||||
onSuccess()
|
||||
}, [api, checkpoint, onSuccess])
|
||||
|
||||
const hasFile = !!checkpoint?.file
|
||||
const FileIcon = checkpoint?.file ? getFileIcon(checkpoint.file) : File
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Attachments — {checkpoint?.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Current attachment */}
|
||||
{hasFile ? (
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0 ">
|
||||
{isImageUrl(checkpoint.file!) ? (
|
||||
<img
|
||||
src={checkpoint.file!}
|
||||
alt="Checkpoint attachment"
|
||||
className="size-12 rounded-md object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-md bg-muted">
|
||||
<FileIcon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex flex-col gap-0.5 max-w-48">
|
||||
<a
|
||||
href={checkpoint.file!}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
{getFileName(checkpoint.file!)}
|
||||
</a>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Current attachment
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-destructive hover:text-destructive"
|
||||
onClick={handleDelete}
|
||||
title="Remove attachment"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 rounded-lg border border-dashed p-6 text-muted-foreground">
|
||||
<Paperclip className="size-8" />
|
||||
<span className="text-sm">No attachments yet</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload area */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,audio/*,video/*"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleUpload(file)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={isUploading}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<FileUp className="size-4" />
|
||||
{isUploading
|
||||
? "Uploading..."
|
||||
: hasFile
|
||||
? "Replace Attachment"
|
||||
: "Add Attachment"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Page ──
|
||||
|
||||
export default function InspectionCheckpointsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id: inspectionId } = use(params)
|
||||
const api = useAuthApi()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false)
|
||||
const [editingCheckpoint, setEditingCheckpoint] = useState<CheckpointItem | null>(null)
|
||||
const [attachmentsCheckpoint, setAttachmentsCheckpoint] = useState<CheckpointItem | null>(null)
|
||||
|
||||
const queryKey = [INSPECTION_ROUTES.CHECKPOINTS, inspectionId]
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => api.inspections.listCheckpoints({ inspection_id: inspectionId } as never),
|
||||
})
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey })
|
||||
}
|
||||
|
||||
// ── Status change mutation ──
|
||||
const changeStatusMutation = useMutation({
|
||||
mutationFn: ({ checkpointId, status }: { checkpointId: number; status: string }) =>
|
||||
api.inspections.changeCheckpointStatus({
|
||||
inspection_id: Number(inspectionId),
|
||||
inspection_check_point_id: String(checkpointId),
|
||||
name: "", // required by API type but server uses checkpoint id from context
|
||||
record_type: "record_conditions",
|
||||
status,
|
||||
} as never),
|
||||
onSuccess: () => invalidate(),
|
||||
})
|
||||
|
||||
// ── Delete checkpoint mutation ──
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (checkpointId: string) => api.inspections.destroyCheckpoint(checkpointId),
|
||||
onSuccess: () => {
|
||||
toast.success("Checkpoint deleted")
|
||||
invalidate()
|
||||
},
|
||||
onError: () => toast.error("Failed to delete checkpoint"),
|
||||
})
|
||||
|
||||
const handleEdit = (checkpoint: CheckpointItem) => {
|
||||
setEditingCheckpoint(checkpoint)
|
||||
setFormOpen(true)
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingCheckpoint(null)
|
||||
setFormOpen(true)
|
||||
}
|
||||
|
||||
const handleStatusChange = (checkpointId: number, status: string) => {
|
||||
const promise = changeStatusMutation.mutateAsync({ checkpointId, status, })
|
||||
toast.promise(promise, {
|
||||
loading: "Updating status...",
|
||||
success: `Status changed to ${formatStatus(status)}`,
|
||||
error: "Failed to update status",
|
||||
})
|
||||
}
|
||||
|
||||
const checkpoints = (data as any)?.data ?? []
|
||||
const meta = (data as any)?.meta
|
||||
|
||||
const pagination = {
|
||||
page: meta?.current_page ?? 1,
|
||||
pageSize: meta?.per_page ?? 15,
|
||||
pageCount: meta?.last_page ?? 1,
|
||||
total: meta?.total ?? 0,
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }: any) => <ColumnHeader column={column} title="Name" />,
|
||||
cell: ({ row }: any) => {
|
||||
const item = row.original as CheckpointItem
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-medium">{item.name}</span>
|
||||
{item.description && (
|
||||
<span className="text-xs text-muted-foreground">{item.description}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "record_type",
|
||||
header: ({ column }: any) => <ColumnHeader column={column} title="Record Type" />,
|
||||
cell: ({ row }: any) => {
|
||||
const item = row.original as CheckpointItem
|
||||
const rt = RECORD_TYPES.find((r) => r.value === item.record_type)
|
||||
return rt?.label ?? item.record_type ?? "—"
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }: any) => <ColumnHeader column={column} title="Status" />,
|
||||
cell: ({ row }: any) => {
|
||||
const item = row.original as CheckpointItem
|
||||
const config = getStatusConfig(item.status)
|
||||
return (
|
||||
<Badge className={config.color}>
|
||||
<config.icon className="mr-1 size-3" />
|
||||
{formatStatus(item.status)}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "condition_rate",
|
||||
header: ({ column }: any) => <ColumnHeader column={column} title="Condition" />,
|
||||
cell: ({ row }: any) => {
|
||||
const item = row.original as CheckpointItem
|
||||
return item.condition_rate != null ? `${item.condition_rate}/10` : "—"
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "attachments",
|
||||
header: () => <span className="text-xs">Attachments</span>,
|
||||
cell: ({ row }: any) => {
|
||||
const item = row.original as CheckpointItem
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => setAttachmentsCheckpoint(item)}
|
||||
>
|
||||
<Paperclip className="size-3.5" />
|
||||
{item.file ? (
|
||||
<Badge variant="secondary" className="px-1.5 py-0 text-xs">1</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">0</span>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }: any) => {
|
||||
const item = row.original as CheckpointItem
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="size-8">
|
||||
<Ellipsis className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{CHECKPOINT_STATUSES.map((s) => (
|
||||
<DropdownMenuItem
|
||||
key={s.value}
|
||||
onClick={() => handleStatusChange(item.id, s.value)}
|
||||
disabled={item.status === s.value}
|
||||
>
|
||||
<s.icon className="size-4" />
|
||||
{s.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => handleEdit(item)}>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => deleteMutation.mutate(String(item.id))}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<DashboardPage header={null}>
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<h3 className="text-lg font-semibold">Checkpoints</h3>
|
||||
<Button onClick={handleAdd} size="sm">
|
||||
<Plus className="size-4" />
|
||||
Add Checkpoint
|
||||
</Button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={checkpoints}
|
||||
pagination={pagination}
|
||||
sorting={[]}
|
||||
onChange={() => {}}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<CheckpointFormDialog
|
||||
open={formOpen}
|
||||
onOpenChange={(open) => {
|
||||
setFormOpen(open)
|
||||
if (!open) setEditingCheckpoint(null)
|
||||
}}
|
||||
inspectionId={inspectionId}
|
||||
checkpoint={editingCheckpoint}
|
||||
onSuccess={invalidate}
|
||||
/>
|
||||
<CheckpointAttachmentsDialog
|
||||
open={!!attachmentsCheckpoint}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setAttachmentsCheckpoint(null)
|
||||
}}
|
||||
checkpoint={attachmentsCheckpoint}
|
||||
onSuccess={invalidate}
|
||||
/>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client"
|
||||
|
||||
import { use } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { InspectionForm } from "@/modules/inspections/inspection-form"
|
||||
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
|
||||
import { INSPECTION_ROUTES } from "@garage/api"
|
||||
|
||||
export default function InspectionEditPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
const api = useAuthApi()
|
||||
const router = useRouter()
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: [INSPECTION_ROUTES.BY_ID, id],
|
||||
queryFn: () => api.inspections.getById(id),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DashboardPage header={null}>
|
||||
<div className="flex items-center justify-center p-8 text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardPage header={null}>
|
||||
<div className="mx-auto max-w-2xl p-6">
|
||||
<InspectionForm
|
||||
resourceId={id}
|
||||
initialData={data}
|
||||
onSuccess={() => router.push(`/sales/inspections/${id}`)}
|
||||
/>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { DashboardDetailsPage } from '@/base/components/layout/dashboard'
|
||||
import { getServerApi } from '@garage/api/server'
|
||||
import { InspectionActions } from '@/modules/inspections/inspection-actions'
|
||||
import { InspectionProvider } from '@/modules/inspections/inspection-context'
|
||||
import React from 'react'
|
||||
|
||||
export default async function layout(props: {
|
||||
params: Promise<{ id: string }>
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { id } = await props.params
|
||||
const api = await getServerApi()
|
||||
const inspection = await api.inspections.getById(id)
|
||||
|
||||
const title = inspection.data?.title || 'Inspection Details'
|
||||
const orderNumber = inspection.data?.order_number
|
||||
const status = inspection.data?.status
|
||||
|
||||
return (
|
||||
<InspectionProvider inspection={{ id, label: title }}>
|
||||
<DashboardDetailsPage
|
||||
className="p-0 lg:p-0"
|
||||
title={title}
|
||||
description={orderNumber ? `Order: ${orderNumber}` : undefined}
|
||||
backHref="/sales/inspections"
|
||||
actions={<InspectionActions inspectionId={id} status={status} />}
|
||||
tabs={[
|
||||
{
|
||||
href: `/sales/inspections/${id}`,
|
||||
label: 'Details',
|
||||
},
|
||||
{
|
||||
href: `/sales/inspections/${id}/checkpoints`,
|
||||
label: 'Checkpoints',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{props.children}
|
||||
</DashboardDetailsPage>
|
||||
</InspectionProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getServerApi } from '@garage/api/server'
|
||||
import { InspectionGeneralInfo } from '@/modules/inspections/inspection-general-info'
|
||||
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
|
||||
|
||||
export default async function page(props: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await props.params
|
||||
const api = await getServerApi()
|
||||
const inspection = await api.inspections.getById(id)
|
||||
|
||||
if (!inspection.data) {
|
||||
return <div className="text-muted-foreground">Inspection not found.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardPage header={null}>
|
||||
<InspectionGeneralInfo inspection={inspection.data} />
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
@@ -6,13 +6,17 @@ import FormDialog from "@/shared/components/form-dialog"
|
||||
import { InspectionForm } from "@/modules/inspections/inspection-form"
|
||||
import { INSPECTION_ROUTES } from "@garage/api"
|
||||
import type { InspectionsClient } from "@garage/api"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
export default function InspectionsPage() {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<ResourcePage<InspectionsClient>
|
||||
pageTitle="Inspections"
|
||||
routeKey={INSPECTION_ROUTES.INDEX}
|
||||
getClient={(api) => api.inspections}
|
||||
onRowClick={(row) => router.push(`/sales/inspections/${(row as any).id}`)}
|
||||
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||
actions: (
|
||||
<FormDialog title="Inspection">
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client"
|
||||
|
||||
import { use } from "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 { AppointmentForm } from "@/modules/appointments/appointment-form"
|
||||
import { APPOINTMENT_ROUTES } from "@garage/api"
|
||||
import type { AppointmentsClient } from "@garage/api"
|
||||
import { CalendarCheck2Icon, ClockIcon } from "lucide-react"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { useJobCard } from "@/modules/job-cards/job-card-context"
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
requested: "bg-yellow-100 text-yellow-800",
|
||||
confirmed: "bg-blue-100 text-blue-800",
|
||||
in_progress: "bg-purple-100 text-purple-800",
|
||||
completed: "bg-green-100 text-green-800",
|
||||
cancelled: "bg-red-100 text-red-800",
|
||||
}
|
||||
|
||||
export default function JobCardAppointmentsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id: jobCardId } = use(params)
|
||||
const router = useRouter()
|
||||
const jobCard = useJobCard()
|
||||
|
||||
const defaultJobCard = jobCard
|
||||
? { value: String((jobCard as any).id), label: (jobCard as any).label || (jobCard as any).title || `Job Card` }
|
||||
: null
|
||||
|
||||
return (
|
||||
<ResourcePage<AppointmentsClient>
|
||||
pageTitle="Appointments"
|
||||
routeKey={APPOINTMENT_ROUTES.INDEX}
|
||||
getClient={(api) => api.appointments}
|
||||
extraParams={{ job_card_id: jobCardId }}
|
||||
header={null}
|
||||
onRowClick={(row) => router.push(`/calendar/appointment/${(row as any).id}`)}
|
||||
toolbar={({ invalidateQuery, selectedItem, closeDialog }) => (
|
||||
<FormDialog title="Appointment">
|
||||
{(resourceId) => (
|
||||
<AppointmentForm
|
||||
resourceId={resourceId}
|
||||
initialData={selectedItem ?? { job_card: defaultJobCard }}
|
||||
onSuccess={() => { closeDialog(); invalidateQuery() }}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
)}
|
||||
columns={({ actionsColumn }) => [
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarCheck2Icon className="size-4 text-muted-foreground" />
|
||||
<span>{(row.original as any).title}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "date",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
|
||||
},
|
||||
{
|
||||
accessorKey: "from_time",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Time" />,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original as any
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<ClockIcon className="size-3 text-muted-foreground" />
|
||||
<span>{r.from_time} - {r.to_time}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
|
||||
cell: ({ row }) => {
|
||||
const status = (row.original as any).status
|
||||
const colorClass = STATUS_COLORS[status] ?? "bg-gray-100 text-gray-800"
|
||||
return (
|
||||
<Badge className={colorClass}>
|
||||
{status?.replace("_", " ") ?? "—"}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
actionsColumn(),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -10,15 +10,11 @@ import { useAuthApi } from "@/shared/useApi"
|
||||
import { confirm } from "@/shared/components/confirm-dialog"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent } from "@/shared/components/ui/card"
|
||||
import { JOB_CARD_ROUTES } from "@garage/api"
|
||||
import { JOB_CARD_ROUTES, JobCardResponseData } from "@garage/api"
|
||||
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
|
||||
import { useJobCard } from "@/modules/job-cards/job-card-context"
|
||||
import { CONSTANTS } from "@/config/constants"
|
||||
|
||||
type Attachment = {
|
||||
id: number
|
||||
file_name: string
|
||||
url: string
|
||||
mime_type?: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
function getFileIcon(mimeType?: string) {
|
||||
if (mimeType?.startsWith("image/")) return ImageIcon
|
||||
@@ -35,13 +31,9 @@ export default function JobCardAttachmentsPage() {
|
||||
|
||||
const queryKey = [JOB_CARD_ROUTES.INDEX, jobCardId, "attachments"]
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => api.jobCards.show(jobCardId),
|
||||
})
|
||||
const jobcard = useJobCard()
|
||||
|
||||
const jobCard = (data as any)?.data ?? data
|
||||
const attachments: Attachment[] = jobCard?.documents ?? jobCard?.attachments ?? []
|
||||
const attachments = jobcard?.attachment_files
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (attachmentId: number) =>
|
||||
@@ -55,10 +47,10 @@ export default function JobCardAttachmentsPage() {
|
||||
},
|
||||
})
|
||||
|
||||
const handleDelete = async (attachment: Attachment) => {
|
||||
const handleDelete = async (attachment: JobCardResponseData['attachment_files'][number]) => {
|
||||
const confirmed = await confirm({
|
||||
title: "Delete Attachment",
|
||||
description: `Are you sure you want to delete "${attachment.file_name}"?`,
|
||||
description: `Are you sure you want to delete "${attachment.original_name}"?`,
|
||||
confirmLabel: "Delete",
|
||||
variant: "destructive",
|
||||
})
|
||||
@@ -91,31 +83,30 @@ export default function JobCardAttachmentsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-end">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{isUploading ? "Uploading..." : "Upload Attachment"}
|
||||
</Button>
|
||||
</div>
|
||||
<DashboardPage
|
||||
header={null}
|
||||
toolbar={
|
||||
<div className="flex items-center justify-end">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{isUploading ? "Uploading..." : "Upload Attachment"}
|
||||
</Button>
|
||||
</div>}
|
||||
title="Attachments"
|
||||
>
|
||||
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
Loading attachments...
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : attachments.length === 0 ? (
|
||||
|
||||
{attachments?.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
No attachments yet. Click "Upload Attachment" to add files.
|
||||
@@ -123,8 +114,8 @@ export default function JobCardAttachmentsPage() {
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{attachments.map((attachment) => {
|
||||
const Icon = getFileIcon(attachment.mime_type)
|
||||
{attachments?.map((attachment) => {
|
||||
const Icon = getFileIcon(attachment.attachment_path)
|
||||
return (
|
||||
<Card key={attachment.id}>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
@@ -133,13 +124,13 @@ export default function JobCardAttachmentsPage() {
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<a
|
||||
href={attachment.url}
|
||||
href={CONSTANTS.getAssetUrl(attachment.attachment_path)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate text-sm font-medium hover:underline"
|
||||
title={attachment.file_name}
|
||||
title={attachment.original_name}
|
||||
>
|
||||
{attachment.file_name}
|
||||
{attachment.original_name}
|
||||
</a>
|
||||
{attachment.created_at && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -161,6 +152,6 @@ export default function JobCardAttachmentsPage() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client"
|
||||
|
||||
import { use } from "react"
|
||||
import FormDialog from "@/shared/components/form-dialog"
|
||||
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 type { BillsClient } from "@garage/api"
|
||||
import { useJobCard } from "@/modules/job-cards/job-card-context"
|
||||
|
||||
export default function JobCardBillsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id: jobCardId } = use(params)
|
||||
const jobCard = useJobCard()
|
||||
|
||||
const defaultJobCard = jobCard
|
||||
? { value: String((jobCard as any).id), label: (jobCard as any).label || (jobCard as any).title || `Job Card` }
|
||||
: null
|
||||
|
||||
return (
|
||||
<ResourcePage<BillsClient>
|
||||
pageTitle="Bills"
|
||||
routeKey={BILL_ROUTES.INDEX}
|
||||
getClient={(api) => api.bills}
|
||||
extraParams={{ job_card_id: jobCardId }}
|
||||
header={null}
|
||||
toolbar={({ invalidateQuery, selectedItem, closeDialog }) => (
|
||||
<FormDialog title="Bill">
|
||||
{(resourceId) => (
|
||||
<BillForm
|
||||
resourceId={resourceId}
|
||||
initialData={selectedItem ?? { job_card: defaultJobCard }}
|
||||
onSuccess={() => { closeDialog(); invalidateQuery() }}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
)}
|
||||
columns={({ actionsColumn }) => [
|
||||
{
|
||||
accessorKey: "bill_number",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Bill #" />,
|
||||
cell: ({ row }) => (row.original as any).bill_number || "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
|
||||
},
|
||||
{
|
||||
accessorKey: "vendor_name",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Vendor" />,
|
||||
cell: ({ row }) => (row.original as any).vendor_name || "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "bill_date",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Bill Date" />,
|
||||
cell: ({ row }) => {
|
||||
const value = (row.original as any).bill_date
|
||||
return value ? new Date(value).toLocaleDateString() : "—"
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "bill_due_date",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Due Date" />,
|
||||
cell: ({ row }) => {
|
||||
const value = (row.original as any).bill_due_date
|
||||
return value ? new Date(value).toLocaleDateString() : "—"
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
|
||||
cell: ({ row }) => {
|
||||
const status = (row.original as any).status
|
||||
return <Badge variant={status === "paid" ? "default" : "secondary"}>{status || "—"}</Badge>
|
||||
},
|
||||
},
|
||||
actionsColumn(),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { JobCardRemarkForm } from "@/modules/job-cards/job-card-remark-form"
|
||||
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
|
||||
|
||||
type CustomerRemark = {
|
||||
id: number
|
||||
@@ -39,9 +40,7 @@ export default function CustomerRemarksPage() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
const result = await api.jobCards.show(jobCardId)
|
||||
const d = (result as any)?.data ?? result
|
||||
return d?.customer_remarks ?? []
|
||||
// const result = await api.jobCards.
|
||||
},
|
||||
})
|
||||
|
||||
@@ -112,41 +111,43 @@ export default function CustomerRemarksPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-end">
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
Add Customer Remark
|
||||
</Button>
|
||||
<DashboardPage header={null} title="Customer Remarks" toolbar={ <div className="flex items-center justify-end">
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
Add Customer Remark
|
||||
</Button>
|
||||
</div>}>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={remarks}
|
||||
pagination={pagination}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Customer Remark</DialogTitle>
|
||||
</DialogHeader>
|
||||
<JobCardRemarkForm
|
||||
jobCardId={jobCardId}
|
||||
onSuccess={() => {
|
||||
setDialogOpen(false)
|
||||
queryClient.invalidateQueries({ queryKey })
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={remarks}
|
||||
pagination={pagination}
|
||||
sorting={[]}
|
||||
onChange={() => {}}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Customer Remark</DialogTitle>
|
||||
</DialogHeader>
|
||||
<JobCardRemarkForm
|
||||
jobCardId={jobCardId}
|
||||
onSuccess={() => {
|
||||
setDialogOpen(false)
|
||||
queryClient.invalidateQueries({ queryKey })
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import { use } 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 { ExpenseForm } from "@/modules/expenses/expense-form"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { EXPENSE_ROUTES } from "@garage/api"
|
||||
import type { ExpensesClient } from "@garage/api"
|
||||
import { useJobCard } from "@/modules/job-cards/job-card-context"
|
||||
|
||||
export default function JobCardExpensesPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id: jobCardId } = use(params)
|
||||
const jobCard = useJobCard()
|
||||
|
||||
const defaultJobCard = jobCard
|
||||
? { value: String((jobCard as any).id), label: (jobCard as any).label || (jobCard as any).title || `Job Card` }
|
||||
: null
|
||||
|
||||
return (
|
||||
<ResourcePage<ExpensesClient>
|
||||
pageTitle="Expenses"
|
||||
routeKey={EXPENSE_ROUTES.INDEX}
|
||||
getClient={(api) => api.expenses}
|
||||
extraParams={{ job_card_id: jobCardId }}
|
||||
header={null}
|
||||
toolbar={({ invalidateQuery, selectedItem, closeDialog }) => (
|
||||
<FormDialog title="Expense">
|
||||
{(resourceId) => (
|
||||
<ExpenseForm
|
||||
resourceId={resourceId}
|
||||
initialData={selectedItem ?? { job_card: defaultJobCard }}
|
||||
onSuccess={() => { closeDialog(); invalidateQuery() }}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
)}
|
||||
columns={({ actionsColumn }) => [
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
|
||||
},
|
||||
{
|
||||
accessorKey: "invoice_number",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Invoice #" />,
|
||||
cell: ({ row }) => (row.original as any).invoice_number || "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "vendor_name",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Vendor" />,
|
||||
cell: ({ row }) => (row.original as any).vendor_name || "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "expense_date",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
|
||||
cell: ({ row }) => {
|
||||
const val = (row.original as any).expense_date
|
||||
return val ? new Date(val).toLocaleDateString() : "—"
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
|
||||
cell: ({ row }) => {
|
||||
const status = (row.original as any).status
|
||||
return (
|
||||
<Badge variant={status === "paid" ? "default" : "secondary"}>
|
||||
{status || "—"}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "created_at",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
|
||||
cell: ({ row }) => {
|
||||
const val = (row.original as any).created_at
|
||||
return val ? new Date(val).toLocaleDateString() : "—"
|
||||
},
|
||||
},
|
||||
actionsColumn(),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -3,26 +3,32 @@ import { getServerApi } from '@garage/api/server'
|
||||
import { JobCardActions } from '@/modules/job-cards/job-card-actions'
|
||||
import { JobCardProvider } from '@/modules/job-cards/job-card-context'
|
||||
import { JobCardStatusStepper } from '@/modules/job-cards/job-card-status-stepper'
|
||||
import { ClipboardListIcon } from 'lucide-react'
|
||||
import { ClipboardListIcon, Ellipsis, Pencil, Trash2 } from 'lucide-react'
|
||||
import React from 'react'
|
||||
import JobCardDropdown from '@/modules/job-cards/job-card-dropdown'
|
||||
|
||||
|
||||
export default async function JobCardDetailLayout(props: { params: Promise<{ id: string }>, children: React.ReactNode }) {
|
||||
const { id } = await props.params
|
||||
const api = await getServerApi()
|
||||
const jobCard = await api.jobCards.show(id)
|
||||
const data = (jobCard as any)?.data ?? jobCard
|
||||
const title = data?.title || 'Job Card Details'
|
||||
const status = data?.status || 'draft'
|
||||
|
||||
const jobCard = await api.jobCards.show(id).then(res => res.data)
|
||||
|
||||
const title = jobCard?.title || 'Job Card Details'
|
||||
const status = jobCard?.status || 'draft'
|
||||
const docs = jobCard?.documents
|
||||
|
||||
return (
|
||||
<JobCardProvider jobCard={{ id, label: title, status }}>
|
||||
<JobCardProvider jobCard={{ ...jobCard, label: title }}>
|
||||
<DashboardDetailsPage
|
||||
className='p-0 lg:p-0'
|
||||
title={title}
|
||||
description={data?.status ? `Status: ${data.status.split("_").map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")}` : undefined}
|
||||
description={status ? `Status: ${status.split("_").map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")}` : undefined}
|
||||
icon={<ClipboardListIcon className="size-5" />}
|
||||
backHref="/sales/job-cards"
|
||||
actions={<JobCardActions jobCardId={id} />}
|
||||
actions={
|
||||
<JobCardDropdown id={id} />
|
||||
}
|
||||
subHeader={<JobCardStatusStepper jobCardId={id} />}
|
||||
tabs={[
|
||||
{
|
||||
@@ -39,7 +45,27 @@ export default async function JobCardDetailLayout(props: { params: Promise<{ id:
|
||||
},
|
||||
{
|
||||
href: `/sales/job-cards/${id}/attachments`,
|
||||
label: 'Attachments'
|
||||
label: `Attachments (${docs?.length || 0})`
|
||||
},
|
||||
{
|
||||
href: `/sales/job-cards/${id}/purchase-orders`,
|
||||
label: `Purchase Orders (${jobCard?.purchase_orders_count || 0})`
|
||||
},
|
||||
{
|
||||
href: `/sales/job-cards/${id}/bills`,
|
||||
label: `Bills (${jobCard?.bills_count || 0})`
|
||||
},
|
||||
{
|
||||
href: `/sales/job-cards/${id}/appointments`,
|
||||
label: `Appointments (${jobCard?.appointments_count || 0})`
|
||||
},
|
||||
{
|
||||
href: `/sales/job-cards/${id}/expenses`,
|
||||
label: `Expenses (${jobCard?.expenses_count || 0})`
|
||||
},
|
||||
{
|
||||
href: `/sales/job-cards/${id}/tasks`,
|
||||
label: `Tasks (${jobCard?.tasks_count || 0})`
|
||||
},
|
||||
]}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client"
|
||||
|
||||
import { use } 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 { 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"
|
||||
|
||||
export default function JobCardPurchaseOrdersPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id: jobCardId } = use(params)
|
||||
const jobCard = useJobCard()
|
||||
|
||||
const defaultJobCard = jobCard
|
||||
? { value: String((jobCard as any).id), label: (jobCard as any).label || (jobCard as any).title || `Job Card` }
|
||||
: null
|
||||
|
||||
return (
|
||||
<ResourcePage<PurchaseOrdersClient>
|
||||
pageTitle="Purchase Orders"
|
||||
routeKey={PURCHASE_ORDER_ROUTES.INDEX}
|
||||
getClient={(api) => api.purchaseOrders}
|
||||
extraParams={{ job_card_id: jobCardId }}
|
||||
header={null}
|
||||
toolbar={({ invalidateQuery, selectedItem, closeDialog }) => (
|
||||
<FormDialog title="Purchase Order">
|
||||
{(resourceId) => (
|
||||
<PurchaseOrderForm
|
||||
resourceId={resourceId}
|
||||
initialData={selectedItem ?? { job_card: defaultJobCard }}
|
||||
onSuccess={() => { closeDialog(); invalidateQuery() }}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
)}
|
||||
columns={({ actionsColumn }) => [
|
||||
{
|
||||
accessorKey: "order_number",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Order #" />,
|
||||
cell: ({ row }) => (row.original as any).order_number || "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
|
||||
},
|
||||
{
|
||||
accessorKey: "vendor_name",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Vendor" />,
|
||||
cell: ({ row }) => (row.original as any).vendor_name || "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "order_date",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Order Date" />,
|
||||
cell: ({ row }) => {
|
||||
const val = (row.original as any).order_date
|
||||
return val ? new Date(val).toLocaleDateString() : "—"
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "delivery_date",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Delivery Date" />,
|
||||
cell: ({ row }) => {
|
||||
const val = (row.original as any).delivery_date
|
||||
return val ? new Date(val).toLocaleDateString() : "—"
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "created_at",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
|
||||
cell: ({ row }) => {
|
||||
const val = (row.original as any).created_at
|
||||
return val ? new Date(val).toLocaleDateString() : "—"
|
||||
},
|
||||
},
|
||||
actionsColumn(),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+13
-8
@@ -19,6 +19,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { JobCardRecommendationForm } from "@/modules/job-cards/job-card-recommendation-form"
|
||||
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
|
||||
|
||||
type ShopRecommendation = {
|
||||
id: number
|
||||
@@ -112,13 +113,17 @@ export default function ShopRecommendationsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-end">
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
Add Shop Recommendation
|
||||
</Button>
|
||||
</div>
|
||||
<DashboardPage
|
||||
header={null}
|
||||
title="Shop Recommendations"
|
||||
toolbar={
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="size-4 me-2" />
|
||||
Add Recommendation
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
@@ -147,6 +152,6 @@ export default function ShopRecommendationsPage() {
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client"
|
||||
|
||||
import { use } from "react"
|
||||
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||
import { TASK_ROUTES } from "@garage/api"
|
||||
import type { TasksClient } from "@garage/api"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
|
||||
export default function JobCardTasksPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id: jobCardId } = use(params)
|
||||
|
||||
return (
|
||||
<ResourcePage<TasksClient>
|
||||
pageTitle="Tasks"
|
||||
routeKey={TASK_ROUTES.TASKS}
|
||||
getClient={(api) => api.tasks}
|
||||
extraParams={{ job_card_id: jobCardId }}
|
||||
header={null}
|
||||
columns={() => [
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Task" />,
|
||||
cell: ({ row }) => (row.original as any).title || "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "task_type_name",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Type" />,
|
||||
cell: ({ row }) => (row.original as any).task_type_name || "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "section_name",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Section" />,
|
||||
cell: ({ row }) => (row.original as any).section_name || "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "due_date",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Due Date" />,
|
||||
cell: ({ row }) => {
|
||||
const val = (row.original as any).due_date
|
||||
return val ? new Date(val).toLocaleDateString() : "—"
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "priority",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Priority" />,
|
||||
cell: ({ row }) => {
|
||||
const value = (row.original as any).priority
|
||||
return value ? <Badge variant="outline">{value}</Badge> : "—"
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
|
||||
cell: ({ row }) => {
|
||||
const value = (row.original as any).status
|
||||
return value ? <Badge>{value}</Badge> : "—"
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -4,11 +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 { JobCardForm } from '@/modules/job-cards/job-card-form'
|
||||
import { JOB_CARD_ROUTES } from '@garage/api'
|
||||
import { JOB_CARD_ROUTES, JobCardStatus } from '@garage/api'
|
||||
import type { JobCardsClient } from '@garage/api'
|
||||
import { ClipboardListIcon } from 'lucide-react'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/shared/components/ui/tabs'
|
||||
import { ClipboardListIcon, SearchIcon } 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 } from 'react'
|
||||
|
||||
type JobCardItem = {
|
||||
id: number
|
||||
@@ -38,12 +41,25 @@ const formatStatus = (status?: string) => {
|
||||
|
||||
export default function JobCardsPage() {
|
||||
const router = useRouter()
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [search, setSearch] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all")
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSearch(searchInput), 400)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchInput])
|
||||
|
||||
const extraParams: Record<string, unknown> = {}
|
||||
if (search) extraParams.search = search
|
||||
if (statusFilter !== "all") extraParams.status = statusFilter
|
||||
|
||||
return (
|
||||
<ResourcePage<JobCardsClient>
|
||||
pageTitle="Job Cards"
|
||||
routeKey={JOB_CARD_ROUTES.INDEX}
|
||||
getClient={(api) => api.jobCards}
|
||||
extraParams={extraParams}
|
||||
onRowClick={(row) => router.push(`/sales/job-cards/${row.id}`)}
|
||||
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||
actions: (
|
||||
@@ -106,6 +122,32 @@ export default function JobCardsPage() {
|
||||
},
|
||||
actionsColumn(),
|
||||
]}
|
||||
toolbar={
|
||||
<div className="flex gap-3 w-full">
|
||||
|
||||
<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>
|
||||
}
|
||||
tableHeader={
|
||||
<Tabs value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<TabsList variant="default">
|
||||
<TabsTrigger value="all" className='data-[state=active]:bg-primary/10 data-[state=active]:text-primary'>All</TabsTrigger>
|
||||
{JobCardStatus.map((status) => (
|
||||
<TabsTrigger className='data-[state=active]:bg-primary/10 data-[state=active]:text-primary ' key={status} value={status}>
|
||||
{formatStatus(status)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,16 +11,6 @@ import type { EstimatesClient } from '@garage/api'
|
||||
import { FileTextIcon } from 'lucide-react'
|
||||
import { useVehicle } from '@/modules/vehicles/vehicle-context'
|
||||
|
||||
type EstimateItem = {
|
||||
id: number
|
||||
title?: string
|
||||
estimate_number?: string
|
||||
date?: string
|
||||
customer_name?: string
|
||||
has_insurance?: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export default function VehicleEstimatesPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id: vehicleId } = use(params)
|
||||
const vehicle = useVehicle()
|
||||
@@ -57,7 +47,7 @@ export default function VehicleEstimatesPage({ params }: { params: Promise<{ id:
|
||||
accessorKey: "title",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as unknown as EstimateItem
|
||||
const item = row.original
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<FileTextIcon className="text-muted-foreground h-4 w-4" />
|
||||
@@ -82,7 +72,7 @@ export default function VehicleEstimatesPage({ params }: { params: Promise<{ id:
|
||||
accessorKey: "has_insurance",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Insurance" />,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as unknown as EstimateItem
|
||||
const item = row.original
|
||||
return item.has_insurance ? "Yes" : "No"
|
||||
},
|
||||
},
|
||||
@@ -90,7 +80,7 @@ export default function VehicleEstimatesPage({ params }: { params: Promise<{ id:
|
||||
accessorKey: "created_at",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as unknown as EstimateItem
|
||||
const item = row.original
|
||||
return item.created_at ? new Date(item.created_at).toLocaleDateString() : "—"
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user