This commit is contained in:
2026-04-23 14:38:41 +03:00
parent f17dd1486c
commit c0f78c6e18
90 changed files with 10819 additions and 1583 deletions
@@ -0,0 +1,207 @@
"use client"
import { use, useState } from "react"
import { useRouter } from "next/navigation"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { useAuthApi } from "@/shared/useApi"
import { ColumnHeader, DataTable } from "@/shared/data-view/table-view"
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { confirm } from "@/shared/components/confirm-dialog"
import { toast } from "sonner"
import { Ellipsis, Plus } from "lucide-react"
import type { ColumnDef } from "@tanstack/react-table"
import { JobCardExpenseItemForm } from "@/modules/job-cards/job-card-expense-item-form"
import { formatDate } from "@/shared/utils/formatters"
// TODO: expense items invalidation is not working properly when create new expense item line. Need to investigate why and fix it.
export default function JobCardExpenseItemsPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id: jobCardId } = use(params)
const api = useAuthApi()
const queryClient = useQueryClient()
const router = useRouter()
const queryKey = ["job-card-expense-items", jobCardId]
const [dialogOpen, setDialogOpen] = useState(false)
const [editItem, setEditItem] = useState<any | null>(null)
const { data, isLoading } = useQuery({
queryKey,
queryFn: () => api.jobCards.getExpenseItems(jobCardId),
})
const rows = (data as any)?.data ?? []
const invalidate = () => queryClient.invalidateQueries({ queryKey }).then(() => router.refresh())
async function handleDelete(row: any) {
const confirmed = await confirm({
title: "Delete this expense item?",
description: `Remove "${row.expense_item?.item_name ?? "this expense item"}" from the job card?`,
})
if (!confirmed) return
const promise = api.jobCards.deleteExpenseItem(jobCardId, row.id)
toast.promise(promise, {
loading: "Deleting...",
success: "Expense item deleted",
error: "Failed to delete expense item",
})
await promise
invalidate()
}
const columns: ColumnDef<any>[] = [
{
accessorKey: "expense_item.item_name",
header: ({ column }) => <ColumnHeader column={column} title="Expense Item" />,
cell: ({ row }) => {
const item = row.original.expense_item
return item ? (
<div>
<span className="font-medium">{item.item_name}</span>
{item.sku && (
<span className="ml-2 text-xs text-muted-foreground">{item.sku}</span>
)}
</div>
) : "—"
},
},
{
accessorKey: "quantity",
header: ({ column }) => <ColumnHeader column={column} title="Qty" />,
cell: ({ row }) => row.original.quantity ?? "—",
},
{
accessorKey: "rate",
header: ({ column }) => <ColumnHeader column={column} title="Rate" />,
cell: ({ row }) => {
const val = row.original.rate
return val != null ? `$${Number(val).toFixed(2)}` : "—"
},
},
{
accessorKey: "discount_amount",
header: ({ column }) => <ColumnHeader column={column} title="Discount" />,
cell: ({ row }) => {
const val = row.original.discount_amount
return val != null && val > 0 ? `$${Number(val).toFixed(2)}` : "—"
},
},
{
accessorKey: "tax_id",
header: ({ column }) => <ColumnHeader column={column} title="Tax" />,
cell: ({ row }) => row.original.tax_id ?? "—",
},
{
accessorKey: "department.name",
header: ({ column }) => <ColumnHeader column={column} title="Department" />,
cell: ({ row }) => row.original.department?.name || "—",
},
{
accessorKey: "description",
header: ({ column }) => <ColumnHeader column={column} title="Description" />,
cell: ({ row }) => row.original.description || "—",
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Added" />,
cell: ({ row }) => formatDate(row.original.created_at),
},
{
id: "actions",
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Ellipsis className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setEditItem(row.original)
setDialogOpen(true)
}}
>
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => handleDelete(row.original)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
]
return (
<div className="flex flex-col gap-4 p-4">
<div className="flex justify-end">
<Dialog
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open)
if (!open) setEditItem(null)
}}
>
<DialogTrigger asChild>
<Button onClick={() => setEditItem(null)}>
<Plus className="me-2 h-4 w-4" />
Add Expense Item
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{editItem ? "Edit Expense Item" : "Add Expense Item"}</DialogTitle>
</DialogHeader>
<JobCardExpenseItemForm
jobCardId={jobCardId}
jobCardExpenseItemId={editItem?.id ?? null}
initialData={editItem}
onSuccess={() => {
setDialogOpen(false)
setEditItem(null)
invalidate()
}}
onCancel={() => {
setDialogOpen(false)
setEditItem(null)
}}
/>
</DialogContent>
</Dialog>
</div>
<DataTable
columns={columns}
data={rows}
pagination={{
page: 1,
pageSize: rows.length || 15,
pageCount: 1,
total: rows.length,
}}
isLoading={isLoading}
/>
</div>
)
}
@@ -1,92 +0,0 @@
"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>
routeKey={EXPENSE_ROUTES.INDEX}
getClient={(api) => api.expenses}
extraParams={{ job_card_id: jobCardId }}
header={null}
tableHeader={({ invalidateQuery, selectedItem, closeDialog }) => (
<div className="flex justify-end">
<FormDialog title="Expense">
{(resourceId) => (
<ExpenseForm
resourceId={resourceId}
initialData={selectedItem ?? { job_card: defaultJobCard }}
onSuccess={() => { closeDialog(); invalidateQuery() }}
/>
)}
</FormDialog>
</div>
)}
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(),
]}
/>
)
}
@@ -12,14 +12,14 @@ export default async function JobCardDetailLayout(props: { params: Promise<{ id:
const { id } = await props.params
const api = await getServerApi()
const jobCard = await api.jobCards.show(id).then(res => res.data)
const jobCard:any = 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={{ ...jobCard }}>
<JobCardProvider jobCard={{ ...jobCard }}>
<DashboardDetailsPage
className='p-0 lg:p-0'
title={title}
@@ -35,42 +35,53 @@ export default async function JobCardDetailLayout(props: { params: Promise<{ id:
href: `/sales/job-cards/${id}`,
label: 'Details'
},
{
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})`
},
{
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})`
},
{
href: `/sales/job-cards/${id}/parts`,
label: `Parts (${jobCard?.parts_count || 0})`
},
{
href: `/sales/job-cards/${id}/services`,
label: `Services`
label: `Services (${jobCard?.services_count })`
},
{
href: `/sales/job-cards/${id}/expense-items`,
label: `Expense Items (${jobCard?.expense_items_count || 0})`
},
// TODO: Needs refactor from API side then refactor in frontend
{
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}/appointments`,
// label: `Appointments (${jobCard?.appointments_count || 0})`
// },
// {
// href: `/sales/job-cards/${id}/tasks`,
// label: `Tasks (${jobCard?.tasks_count || 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})`
// },
]}
>
{props.children}
@@ -1,19 +1,20 @@
import { getServerApi } from '@garage/api/server'
import { JobCardGeneralInfo } from '@/modules/job-cards/job-card-general-info'
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
import type { JobCardShowData } from '@garage/api'
export default async function JobCardDetailPage(props: { params: Promise<{ id: string }> }) {
const { id } = await props.params
const api = await getServerApi()
const jobCard = await api.jobCards.show(id)
const data = (jobCard as any)?.data ?? jobCard
const response = await api.jobCards.show(id)
const data = response.data
if (!data) {
return <div className="text-muted-foreground">Job card not found.</div>
}
return (
<DashboardPage header={null}>
<DashboardPage header={null}>
<JobCardGeneralInfo jobCard={data} />
</DashboardPage>
)
@@ -1,6 +1,7 @@
"use client"
import { use, useState } from "react"
import { useRouter } from "next/navigation"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { useAuthApi } from "@/shared/useApi"
import { ColumnHeader, DataTable } from "@/shared/data-view/table-view"
@@ -33,6 +34,7 @@ export default function JobCardPartsPage({
const { id: jobCardId } = use(params)
const api = useAuthApi()
const queryClient = useQueryClient()
const router = useRouter()
const queryKey = ["job-card-parts", jobCardId]
const [dialogOpen, setDialogOpen] = useState(false)
@@ -45,7 +47,7 @@ export default function JobCardPartsPage({
const rows = (data as any)?.data ?? []
const invalidate = () => queryClient.invalidateQueries({ queryKey })
const invalidate = () => queryClient.invalidateQueries({ queryKey }).then(() => router.refresh())
async function handleDelete(row: any) {
const confirmed = await confirm({
@@ -93,9 +95,17 @@ export default function JobCardPartsPage({
},
},
{
accessorKey: "tax",
accessorKey: "discount_amount",
header: ({ column }) => <ColumnHeader column={column} title="Discount" />,
cell: ({ row }) => {
const val = row.original.discount_amount
return val != null && val > 0 ? `$${Number(val).toFixed(2)}` : "—"
},
},
{
accessorKey: "tax_id",
header: ({ column }) => <ColumnHeader column={column} title="Tax" />,
cell: ({ row }) => row.original.tax || "—",
cell: ({ row }) => row.original.tax_id ?? "—",
},
{
accessorKey: "department.name",
@@ -1,6 +1,7 @@
"use client"
import { use, useState } from "react"
import { useRouter } from "next/navigation"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { useAuthApi } from "@/shared/useApi"
import { ColumnHeader, DataTable } from "@/shared/data-view/table-view"
@@ -26,6 +27,8 @@ import { JobCardServiceForm } from "@/modules/job-cards/job-card-service-form"
import { formatDate } from "@/shared/utils/formatters"
import { Badge } from "@/shared/components/ui/badge"
// TODO: services invalidation is not working properly when create new service line. Need to investigate why and fix it.
export default function JobCardServicesPage({
params,
}: {
@@ -34,6 +37,7 @@ export default function JobCardServicesPage({
const { id: jobCardId } = use(params)
const api = useAuthApi()
const queryClient = useQueryClient()
const router = useRouter()
const queryKey = ["job-card-services", jobCardId]
const [dialogOpen, setDialogOpen] = useState(false)
@@ -46,7 +50,7 @@ export default function JobCardServicesPage({
const rows = (data as any)?.data ?? []
const invalidate = () => queryClient.invalidateQueries({ queryKey })
const invalidate = () => queryClient.invalidateQueries({ queryKey }).then(() => router.refresh())
async function handleDelete(row: any) {
const confirmed = await confirm({
@@ -125,9 +129,17 @@ export default function JobCardServicesPage({
cell: ({ row }) => row.original.labor_hours ?? "—",
},
{
accessorKey: "tax",
accessorKey: "discount_amount",
header: ({ column }) => <ColumnHeader column={column} title="Discount" />,
cell: ({ row }) => {
const val = row.original.discount_amount
return val != null && val > 0 ? `$${Number(val).toFixed(2)}` : "—"
},
},
{
accessorKey: "tax_id",
header: ({ column }) => <ColumnHeader column={column} title="Tax" />,
cell: ({ row }) => row.original.tax || "—",
cell: ({ row }) => row.original.tax_id ?? "—",
},
{
accessorKey: "department.name",