updates
This commit is contained in:
@@ -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 { BillForm } from "@/modules/bills/bill-form"
|
||||
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
|
||||
import { BILL_ROUTES } from "@garage/api"
|
||||
|
||||
export default function BillEditPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
const api = useAuthApi()
|
||||
const router = useRouter()
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: [BILL_ROUTES.BY_ID, id],
|
||||
queryFn: () => api.bills.show(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="p-6">
|
||||
<BillForm
|
||||
resourceId={id}
|
||||
initialData={data}
|
||||
onSuccess={() => router.push(`/purchase/bill/${id}`)}
|
||||
/>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { DashboardDetailsPage } from '@/base/components/layout/dashboard'
|
||||
import { getServerApi } from '@garage/api/server'
|
||||
import { BillActions } from '@/modules/bills/bill-actions'
|
||||
import { BillProvider, type BillResponse } from '@/modules/bills/bill-context'
|
||||
import BillStatusBadge from '@/modules/bills/bill-status-badge'
|
||||
import { ReceiptIcon } from 'lucide-react'
|
||||
import React from 'react'
|
||||
|
||||
export default async function BillDetailLayout(props: {
|
||||
params: Promise<{ id: string }>
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { id } = await props.params
|
||||
const api = await getServerApi()
|
||||
const bill = await api.bills.show(id)
|
||||
const data = bill.data as BillResponse
|
||||
const title = data?.title || data?.bill_number || 'Bill Details'
|
||||
return (
|
||||
<BillProvider bill={data}>
|
||||
<DashboardDetailsPage
|
||||
className="p-0 lg:p-0"
|
||||
title={title}
|
||||
description={data?.bill_number ? `Bill #: ${data.bill_number}` : undefined}
|
||||
icon={<ReceiptIcon className="size-5" />}
|
||||
backHref="/purchase/bill"
|
||||
actions={
|
||||
<div className="flex space-x-2 items-center">
|
||||
<BillStatusBadge bill={{id, status:data?.status}} />
|
||||
<BillActions billId={id} />
|
||||
</div>
|
||||
}
|
||||
tabs={[
|
||||
{
|
||||
href: `/purchase/bill/${id}`,
|
||||
label: 'Details',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{props.children}
|
||||
</DashboardDetailsPage>
|
||||
</BillProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { getServerApi } from '@garage/api/server'
|
||||
import { BillGeneralInfo } from '@/modules/bills/bill-general-info'
|
||||
import { BillPartsSection } from '@/modules/bills/bill-parts-section'
|
||||
import { BillServicesSection } from '@/modules/bills/bill-services-section'
|
||||
import { BillExpensesSection } from '@/modules/bills/bill-expenses-section'
|
||||
import { BillTotalsSummary } from '@/modules/bills/bill-totals-summary'
|
||||
import { BillPaymentsSection } from '@/modules/bills/bill-payments-section'
|
||||
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
|
||||
|
||||
export default async function BillDetailPage(props: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await props.params
|
||||
const api = await getServerApi()
|
||||
const bill = await api.bills.show(id)
|
||||
const data = (bill as any)?.data ?? bill
|
||||
|
||||
if (!data) {
|
||||
return <div className="text-muted-foreground">Bill not found.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardPage header={null}>
|
||||
<div className="grid gap-6">
|
||||
<BillGeneralInfo />
|
||||
<BillPartsSection parts={data.parts} />
|
||||
<BillServicesSection services={data.services} />
|
||||
<BillExpensesSection expenses={data.expenses} />
|
||||
<BillPaymentsSection />
|
||||
<BillTotalsSummary />
|
||||
</div>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import FormDialog from "@/shared/components/form-dialog"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||
@@ -7,16 +8,20 @@ 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 { formatDate } from "@/shared/utils/formatters"
|
||||
|
||||
export default function BillsPage() {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<ResourcePage<BillsClient>
|
||||
pageTitle="Bills"
|
||||
routeKey={BILL_ROUTES.INDEX}
|
||||
getClient={(api) => api.bills}
|
||||
onRowClick={(row) => router.push(`/purchase/bill/${(row as any).id}`)}
|
||||
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||
actions: (
|
||||
<FormDialog title="Bill">
|
||||
<FormDialog classNames={{ dialogContent: "lg:min-w-6xl" }} title="Bill">
|
||||
{(resourceId) => (
|
||||
<BillForm
|
||||
resourceId={resourceId}
|
||||
@@ -33,37 +38,45 @@ export default function BillsPage() {
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Bill #" />,
|
||||
cell: ({ row }) => (row.original as any).bill_number || "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "total",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Total" />,
|
||||
cell: ({ row }) => (row.original as any).total || "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "balance_due",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Balance Due" />,
|
||||
cell: ({ row }) => (row.original as any).balance_due || "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
|
||||
},
|
||||
{
|
||||
accessorKey: "vendor_name",
|
||||
accessorKey: "vendor",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Vendor" />,
|
||||
cell: ({ row }) => (row.original as any).vendor_name || "—",
|
||||
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() : "—"
|
||||
},
|
||||
cell: ({ row }) => formatDate((row.original as any).bill_date) || "—",
|
||||
},
|
||||
{
|
||||
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() : "—"
|
||||
},
|
||||
cell: ({ row }) => formatDate((row.original as any).bill_due_date) || "—",
|
||||
},
|
||||
{
|
||||
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>
|
||||
return (
|
||||
<Badge variant={status === "paid" ? "default" : "secondary"}>
|
||||
{status?.replace(/_/g, " ") || "—"}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
actionsColumn(),
|
||||
@@ -71,3 +84,4 @@ export default function BillsPage() {
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { DashboardDetailsPage } from '@/base/components/layout/dashboard'
|
||||
import { getServerApi } from '@garage/api/server'
|
||||
import { ExpenseActions } from '@/modules/expenses/expense-actions'
|
||||
import { ExpenseProvider, type ExpenseContextValue } from '@/modules/expenses/expense-context'
|
||||
import { ReceiptIcon } from 'lucide-react'
|
||||
import React from 'react'
|
||||
|
||||
export default async function ExpenseDetailLayout(props: {
|
||||
params: Promise<{ id: string }>
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { id } = await props.params
|
||||
const api = await getServerApi()
|
||||
const expense = await api.expenses.getById(id)
|
||||
const data = expense.data as ExpenseContextValue
|
||||
const title = data?.title || data?.invoice_number || 'Expense Details'
|
||||
|
||||
return (
|
||||
<ExpenseProvider expense={data}>
|
||||
<DashboardDetailsPage
|
||||
className="p-0 lg:p-0"
|
||||
title={title}
|
||||
description={data?.invoice_number ? `Invoice #: ${data.invoice_number}` : undefined}
|
||||
icon={<ReceiptIcon className="size-5" />}
|
||||
backHref="/purchase/expense"
|
||||
actions={<ExpenseActions expenseId={id} />}
|
||||
tabs={[
|
||||
{
|
||||
href: `/purchase/expense/${id}`,
|
||||
label: 'Details',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{props.children}
|
||||
</DashboardDetailsPage>
|
||||
</ExpenseProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
|
||||
import { ExpenseGeneralInfo } from '@/modules/expenses/expense-general-info'
|
||||
import { ExpenseItemsSection } from '@/modules/expenses/expense-items-section'
|
||||
import { ExpensePaymentsSection } from '@/modules/expenses/expense-payments-section'
|
||||
import { getServerApi } from '@garage/api/server'
|
||||
|
||||
export default async function ExpenseDetailPage(props: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await props.params
|
||||
const api = await getServerApi()
|
||||
const expense = await api.expenses.show(id)
|
||||
const data = (expense as any)?.data ?? expense
|
||||
|
||||
if (!data) {
|
||||
return <div className="text-muted-foreground">Expense not found.</div>
|
||||
}
|
||||
|
||||
const taxLabel = data.tax?.title ? `${data.tax.title} (${data.tax.rate}%)` : undefined
|
||||
|
||||
return (
|
||||
<DashboardPage header={null}>
|
||||
<div className="grid gap-6">
|
||||
<ExpenseGeneralInfo />
|
||||
<ExpensePaymentsSection />
|
||||
<ExpenseItemsSection
|
||||
items={data.expense_items}
|
||||
discountType={data.discount}
|
||||
subTotal={data.sub_total}
|
||||
discountAmount={data.discount_amount_major}
|
||||
taxAmount={data.tax_amount}
|
||||
total={data.total}
|
||||
taxLabel={taxLabel}
|
||||
/>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
@@ -7,16 +7,23 @@ 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 { useRouter } from "next/navigation"
|
||||
import { formatCurrency, formatDate, formatEnum } from "@/shared/utils/formatters"
|
||||
|
||||
export default function ExpensesPage() {
|
||||
const router = useRouter()
|
||||
return (
|
||||
<ResourcePage<ExpensesClient>
|
||||
pageTitle="Expenses"
|
||||
routeKey={EXPENSE_ROUTES.INDEX}
|
||||
getClient={(api) => api.expenses}
|
||||
onRowClick={(row)=>router.push(`/purchase/expense/${row.id}`)}
|
||||
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||
actions: (
|
||||
<FormDialog title="Expense">
|
||||
<FormDialog
|
||||
title="Expense"
|
||||
classNames={{ dialogContent: "lg:min-w-6xl" }}
|
||||
>
|
||||
{(resourceId) => (
|
||||
<ExpenseForm
|
||||
resourceId={resourceId}
|
||||
@@ -38,38 +45,47 @@ export default function ExpensesPage() {
|
||||
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: "vendor",
|
||||
header: () => "Vendor",
|
||||
cell: ({ row }) => {
|
||||
const vendor = (row.original as any).vendor
|
||||
return vendor?.company_name || 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() : "—"
|
||||
},
|
||||
cell: ({ row }) => formatDate((row.original as any).expense_date),
|
||||
},
|
||||
{
|
||||
accessorKey: "total",
|
||||
header: () => "Total",
|
||||
cell: ({ row }) => formatCurrency((row.original as any).total ?? 0),
|
||||
},
|
||||
{
|
||||
accessorKey: "balance_due",
|
||||
header: () => "Balance Due",
|
||||
cell: ({ row }) => formatCurrency((row.original as any).balance_due ?? 0),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
|
||||
cell: ({ row }) => {
|
||||
const status = (row.original as any).status
|
||||
const variantMap: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
draft: "secondary",
|
||||
open: "default",
|
||||
un_paid: "destructive",
|
||||
partially_paid: "secondary",
|
||||
paid: "default",
|
||||
}
|
||||
return (
|
||||
<Badge variant={status === "paid" ? "default" : "secondary"}>
|
||||
{status || "—"}
|
||||
<Badge variant={variantMap[status] ?? "outline"}>
|
||||
{formatEnum(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(),
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -19,12 +19,12 @@ export default function PurchaseOrdersPage() {
|
||||
onRowClick={(row) => router.push(`/purchase/purchase-order/${(row as any).id}`)}
|
||||
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||
actions: (
|
||||
<FormDialog classNames={{dialogContent:"min-w-6xl"}} title="Purchase Order">
|
||||
{(resourceId) => (
|
||||
<FormDialog classNames={{ dialogContent: "min-w-6xl" }} title="Purchase Order">
|
||||
{(resourceId, { close }) => (
|
||||
<PurchaseOrderForm
|
||||
resourceId={resourceId}
|
||||
initialData={selectedItem}
|
||||
onSuccess={invalidateQuery}
|
||||
onSuccess={() => { invalidateQuery(); close()}}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getServerApi } from '@garage/api/server'
|
||||
import { EstimateActions } from '@/modules/estimates/estimate-actions'
|
||||
import { EstimateProvider } from '@/modules/estimates/estimate-context'
|
||||
import { CreateInvoiceFromEstimateButton } from '@/modules/estimates/create-invoice-from-estimate-button'
|
||||
import { CreateJobCardFromEstimateButton } from '@/modules/estimates/create-job-card-from-estimate-button'
|
||||
import { FileTextIcon } from 'lucide-react'
|
||||
import React from 'react'
|
||||
|
||||
@@ -12,9 +13,9 @@ export default async function layout(props: {
|
||||
}) {
|
||||
const { id } = await props.params
|
||||
const api = await getServerApi()
|
||||
const estimate = await api.estimates.getById(id)
|
||||
const estimate = await api.estimates.show(id)
|
||||
|
||||
const estimateData = (estimate as any)?.data
|
||||
const estimateData = estimate?.data
|
||||
const title = estimateData?.title || estimateData?.estimate_number || `Estimate #${id}`
|
||||
const estimateLabel = estimateData?.estimate_number
|
||||
? `${estimateData.estimate_number}${estimateData.title ? ` — ${estimateData.title}` : ''}`
|
||||
@@ -33,6 +34,7 @@ export default async function layout(props: {
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<CreateInvoiceFromEstimateButton />
|
||||
<CreateJobCardFromEstimateButton />
|
||||
<EstimateActions estimateId={id} />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ 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 estimate = await api.estimates.show(id)
|
||||
|
||||
const estimateData = (estimate as any)?.data
|
||||
const estimateData = estimate?.data
|
||||
|
||||
if (!estimateData) {
|
||||
return <div className="text-muted-foreground p-4">Estimate not found.</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { InvoiceActions } from '@/modules/invoices/invoice-actions'
|
||||
import { InvoiceProvider } from '@/modules/invoices/invoice-context'
|
||||
import { ReceiptIcon } from 'lucide-react'
|
||||
import React from 'react'
|
||||
import InvoiceStatusBadge from '@/modules/invoices/invoice-status-badge'
|
||||
|
||||
export default async function InvoiceDetailLayout(props: { params: Promise<{ id: string }>, children: React.ReactNode }) {
|
||||
const { id } = await props.params
|
||||
@@ -13,19 +14,26 @@ export default async function InvoiceDetailLayout(props: { params: Promise<{ id:
|
||||
const title = data?.subject || data?.invoice_number || 'Invoice Details'
|
||||
|
||||
return (
|
||||
<InvoiceProvider invoice={{ id, label: title }}>
|
||||
<InvoiceProvider invoice={data}>
|
||||
<DashboardDetailsPage
|
||||
className='p-0 lg:p-0'
|
||||
title={title}
|
||||
description={data?.invoice_number ? `Invoice #: ${data.invoice_number}` : undefined}
|
||||
icon={<ReceiptIcon className="size-5" />}
|
||||
backHref="/sales/invoice"
|
||||
actions={<InvoiceActions invoiceId={id} />}
|
||||
actions={
|
||||
<div className="flex space-x-2 items-center">
|
||||
|
||||
<InvoiceStatusBadge invoice={{id, status:data?.status}} />
|
||||
<InvoiceActions invoiceId={id} />
|
||||
</div>
|
||||
}
|
||||
tabs={[
|
||||
{
|
||||
href: `/sales/invoice/${id}`,
|
||||
label: 'Details'
|
||||
},
|
||||
|
||||
{
|
||||
href: `/sales/invoice/${id}/documents`,
|
||||
label: 'Documents'
|
||||
|
||||
@@ -3,7 +3,10 @@ import { InvoiceGeneralInfo } from '@/modules/invoices/invoice-general-info'
|
||||
import { InvoicePartsSection } from '@/modules/invoices/invoice-parts-section'
|
||||
import { InvoiceServicesSection } from '@/modules/invoices/invoice-services-section'
|
||||
import { InvoiceExpensesSection } from '@/modules/invoices/invoice-expenses-section'
|
||||
import { InvoiceTotalsSummary } from '@/modules/invoices/invoice-totals-summary'
|
||||
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
|
||||
import InvoicePaymentsSection from '@/modules/invoices/invoice-payments-section'
|
||||
|
||||
|
||||
export default async function InvoiceDetailPage(props: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await props.params
|
||||
@@ -18,10 +21,12 @@ export default async function InvoiceDetailPage(props: { params: Promise<{ id: s
|
||||
return (
|
||||
<DashboardPage header={null}>
|
||||
<div className="grid gap-6">
|
||||
<InvoiceGeneralInfo invoice={data} />
|
||||
<InvoiceGeneralInfo />
|
||||
<InvoicePartsSection parts={data.invoice_parts} />
|
||||
<InvoiceServicesSection services={data.invoice_services} />
|
||||
<InvoiceExpensesSection expenses={data.invoice_expenses} />
|
||||
<InvoicePaymentsSection></InvoicePaymentsSection>
|
||||
<InvoiceTotalsSummary />
|
||||
</div>
|
||||
</DashboardPage>
|
||||
)
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function InvoicesPage() {
|
||||
onRowClick={(row) => router.push(`/sales/invoice/${(row as any).id}`)}
|
||||
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||
actions: (
|
||||
<FormDialog title="Invoice">
|
||||
<FormDialog classNames={{dialogContent:'lg:min-w-6xl'}} title="Invoice">
|
||||
{(resourceId) => (
|
||||
<InvoiceForm
|
||||
resourceId={resourceId}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
"use client"
|
||||
|
||||
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
|
||||
import { SettingsForm } from "@/modules/settings/company/settings-form"
|
||||
|
||||
export default function CompanySettingsPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
<DashboardPage headerProps={{title:'Company Settings'}}>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Company Settings</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Manage your workshop profile, contact details, and preferences.
|
||||
</p>
|
||||
</div>
|
||||
<SettingsForm />
|
||||
</div>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user