filters , order inspections , inventory items , credit notes
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { useParams } from "next/navigation"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { useState, useRef } from "react"
|
||||
import { useState, useRef, useTransition } from "react"
|
||||
import { Plus, Trash2, FileIcon, ImageIcon, FileTextIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
@@ -26,6 +26,8 @@ export default function JobCardAttachmentsPage() {
|
||||
const { id: jobCardId } = useParams<{ id: string }>()
|
||||
const api = useAuthApi()
|
||||
const queryClient = useQueryClient()
|
||||
const router = useRouter()
|
||||
const [isRefreshing, startRefreshTransition] = useTransition()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
|
||||
@@ -41,6 +43,7 @@ export default function JobCardAttachmentsPage() {
|
||||
onSuccess: () => {
|
||||
toast.success("Attachment deleted successfully.")
|
||||
queryClient.invalidateQueries({ queryKey })
|
||||
startRefreshTransition(() => router.refresh())
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to delete attachment.")
|
||||
@@ -74,6 +77,7 @@ export default function JobCardAttachmentsPage() {
|
||||
try {
|
||||
await promise
|
||||
queryClient.invalidateQueries({ queryKey })
|
||||
startRefreshTransition(() => router.refresh())
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
if (fileInputRef.current) {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client"
|
||||
|
||||
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 type { InspectionsClient } from "@garage/api"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useJobCard } from "@/modules/job-cards/job-card-context"
|
||||
|
||||
export default function InspectionsPage() {
|
||||
const router = useRouter()
|
||||
const jobCard = useJobCard()
|
||||
return (
|
||||
<ResourcePage<InspectionsClient>
|
||||
pageTitle="Inspections"
|
||||
extraParams={{job_card_id: jobCard?.id}}
|
||||
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">
|
||||
{(resourceId) => (
|
||||
<InspectionForm
|
||||
resourceId={resourceId}
|
||||
initialData={selectedItem ?? {job_card: {value: jobCard?.id, label: jobCard?.title || `Job Card #${jobCard?.id}`}}}
|
||||
onSuccess={invalidateQuery}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
),
|
||||
})}
|
||||
columns={({ actionsColumn }) => [
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
|
||||
},
|
||||
{
|
||||
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() : "—"
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "vehicle",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Vehicle" />,
|
||||
cell: ({ row }) => {
|
||||
const v = (row.original as any).vehicle
|
||||
return v ? `${v.make ?? ""} ${v.model ?? ""}`.trim() : "—"
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "inspection_category",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Category" />,
|
||||
cell: ({ row }) => (row.original as any).inspection_category?.name ?? "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
|
||||
cell: ({ row }) => {
|
||||
const status = (row.original as any).status
|
||||
return (
|
||||
<span className={status === "completed" ? "text-green-600" : "text-yellow-600"}>
|
||||
{status ?? "—"}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
actionsColumn(),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -39,6 +39,10 @@ export default async function JobCardDetailLayout(props: { params: Promise<{ id:
|
||||
href: `/sales/job-cards/${id}/attachments`,
|
||||
label: `Attachments (${docs?.length || 0})`
|
||||
},
|
||||
{
|
||||
href: `/sales/job-cards/${id}/inspections`,
|
||||
label: `Inspections (${(jobCard as any)?.inspections_count || 0})`
|
||||
},
|
||||
{
|
||||
href: `/sales/job-cards/${id}/purchase-orders`,
|
||||
label: `Purchase Orders (${jobCard?.purchase_orders_count || 0})`
|
||||
|
||||
@@ -11,8 +11,11 @@ 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'
|
||||
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'
|
||||
import { jobCardFilterConfig, JobCardFilterFields } from '@/modules/job-cards/job-card-filters'
|
||||
|
||||
type JobCardItem = {
|
||||
id: number
|
||||
@@ -38,118 +41,137 @@ export default function JobCardsPage() {
|
||||
const [search, setSearch] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<string>("check_in")
|
||||
|
||||
const filter = useFilterParams(jobCardFilterConfig)
|
||||
|
||||
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
|
||||
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}
|
||||
onRowClick={(row) => router.push(`/sales/job-cards/${row.id}`)}
|
||||
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||
title: "Job Cards",
|
||||
actions: (
|
||||
<FormDialog classNames={{ dialogContent: 'min-w-6xl' }} title="Job Card" >
|
||||
{(resourceId, {close}) => (
|
||||
<JobCardForm
|
||||
resourceId={resourceId}
|
||||
initialData={selectedItem}
|
||||
onSuccess={()=>{ invalidateQuery(); close();}}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
),
|
||||
})}
|
||||
columns={({ actionsColumn }) => [
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as unknown as JobCardItem
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardListIcon className="text-muted-foreground h-4 w-4" />
|
||||
<span>{item.title}</span>
|
||||
</div>
|
||||
)
|
||||
<>
|
||||
<ResourcePage<JobCardsClient>
|
||||
routeKey={JOB_CARD_ROUTES.INDEX}
|
||||
getClient={(api) => api.jobCards}
|
||||
extraParams={extraParams}
|
||||
onRowClick={(row) => router.push(`/sales/job-cards/${row.id}`)}
|
||||
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||
title: "Job Cards",
|
||||
actions: (
|
||||
<div className="flex items-center gap-2">
|
||||
<FilterTrigger onClick={filter.open} activeFilterCount={filter.activeFilterCount} />
|
||||
<FormDialog classNames={{ dialogContent: 'min-w-6xl' }} title="Job Card" >
|
||||
{(resourceId, {close}) => (
|
||||
<JobCardForm
|
||||
resourceId={resourceId}
|
||||
initialData={selectedItem}
|
||||
onSuccess={()=>{ invalidateQuery(); close();}}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
</div>
|
||||
),
|
||||
})}
|
||||
columns={({ actionsColumn }) => [
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as unknown as JobCardItem
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardListIcon className="text-muted-foreground h-4 w-4" />
|
||||
<span>{item.title}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: "order_number",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Order Number" />,
|
||||
|
||||
},
|
||||
{
|
||||
accessorKey: "check_in_date",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Check-in Date" />,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue<string>()
|
||||
return formatDate(val)
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "vehicle_id",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="KM In" />,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as unknown as JobCardItem
|
||||
return item.km_in ? formatNumber(item.km_in) : "—"
|
||||
{
|
||||
accessorKey: "order_number",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Order Number" />,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "created_at",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as unknown as JobCardItem
|
||||
return formatDate(item.created_at)
|
||||
{
|
||||
accessorKey: "check_in_date",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Check-in Date" />,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue<string>()
|
||||
return formatDate(val)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as unknown as JobCardItem
|
||||
return (
|
||||
<Badge variant={statusColorMap[item.status ?? ""] as any ?? "outline"}>
|
||||
{formatEnum(item.status)}
|
||||
</Badge>
|
||||
)
|
||||
{
|
||||
accessorKey: "vehicle_id",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="KM In" />,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as unknown as JobCardItem
|
||||
return item.km_in ? formatNumber(item.km_in) : "—"
|
||||
},
|
||||
},
|
||||
},
|
||||
actionsColumn(),
|
||||
]}
|
||||
{
|
||||
accessorKey: "created_at",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as unknown as JobCardItem
|
||||
return formatDate(item.created_at)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as unknown as JobCardItem
|
||||
return (
|
||||
<Badge variant={statusColorMap[item.status ?? ""] as any ?? "outline"}>
|
||||
{formatEnum(item.status)}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
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"
|
||||
/>
|
||||
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>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<FilterDrawer
|
||||
form={filter.form}
|
||||
isOpen={filter.isOpen}
|
||||
onOpenChange={(open) => { if (!open) filter.close() }}
|
||||
onSubmit={filter.onSubmit}
|
||||
onReset={filter.reset}
|
||||
activeFilterCount={filter.activeFilterCount}
|
||||
title="Filter Job Cards"
|
||||
>
|
||||
<JobCardFilterFields />
|
||||
</FilterDrawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user