fix estimate table and expense items

This commit is contained in:
humam kerdiah 2026-05-13 15:55:40 +04:00
parent cc7dc1bd17
commit 6b356d2855
11 changed files with 96 additions and 40 deletions

View File

@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(pnpm --version)",
"Bash(grep -E \"\\\\.\\(tsx?|jsx?\\)$\")",
"Bash(grep -E \"\\\\.\\(tsx?\\)$\")"
]
}
}

2
.env.prod Normal file
View File

@ -0,0 +1,2 @@
NIXPACKS_NODE_VERSION=22
NEXT_PUBLIC_API_URL=http://reparee.test

View File

@ -16,11 +16,14 @@ export default function ExpenseItemPage() {
headerProps={({ selectedItem, invalidateQuery }) => ({ headerProps={({ selectedItem, invalidateQuery }) => ({
actions: ( actions: (
<FormDialog title="Expense Item"> <FormDialog title="Expense Item">
{(resourceId) => ( {(resourceId, { close }) => (
<ExpenseItemForm <ExpenseItemForm
resourceId={resourceId} resourceId={resourceId}
initialData={selectedItem} initialData={selectedItem}
onSuccess={invalidateQuery} onSuccess={() => {
invalidateQuery()
close()
}}
/> />
)} )}
</FormDialog> </FormDialog>

View File

@ -1,5 +1,6 @@
"use client" "use client"
import { useRouter } from 'next/navigation'
import { ResourcePage } from '@/shared/data-view/resource-page' import { ResourcePage } from '@/shared/data-view/resource-page'
import { ColumnHeader } from '@/shared/data-view/table-view' import { ColumnHeader } from '@/shared/data-view/table-view'
import FormDialog from '@/shared/components/form-dialog' import FormDialog from '@/shared/components/form-dialog'
@ -14,11 +15,13 @@ import { getVehicleLabel } from '@/modules/vehicles/utils/getVehicleLabel'
import { getFullName } from '@/shared/utils/getFullName' import { getFullName } from '@/shared/utils/getFullName'
export default function EstimatesPage() { export default function EstimatesPage() {
const router = useRouter()
return ( return (
<ResourcePage<EstimatesClient> <ResourcePage<EstimatesClient>
pageTitle="Estimates" pageTitle="Estimates"
routeKey={ESTIMATE_ROUTES.INDEX} routeKey={ESTIMATE_ROUTES.INDEX}
getClient={(api) => api.estimates} getClient={(api) => api.estimates}
onRowClick={(row) => router.push(`/sales/estimates/${(row as any).id}`)}
headerProps={({ selectedItem, invalidateQuery }) => ({ headerProps={({ selectedItem, invalidateQuery }) => ({
actions: ( actions: (
<FormDialog title="Estimate"> <FormDialog title="Estimate">
@ -39,7 +42,7 @@ export default function EstimatesPage() {
const item = row.original const item = row.original
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link href={`/sales/estimates/${item.id}`} className="flex items-center gap-2 hover:underline"> <Link href={`/sales/estimates/${item.id}`} className="flex items-center gap-2 hover:underline" onClick={(e) => e.stopPropagation()}>
<FileTextIcon className="text-muted-foreground h-4 w-4" /> <FileTextIcon className="text-muted-foreground h-4 w-4" />
<span>{item.title}</span> <span>{item.title}</span>
</Link> </Link>
@ -56,11 +59,13 @@ export default function EstimatesPage() {
header: ({ column }) => <ColumnHeader column={column} title="Customer" />, header: ({ column }) => <ColumnHeader column={column} title="Customer" />,
cell: ({ row }) => { cell: ({ row }) => {
const item:any = row.original const item:any = row.original
if (!item.customer?.id) return "—"
return ( return (
<div className="flex items-center gap-2"> <Button variant="outline" asChild size="sm" onClick={(e) => e.stopPropagation()}>
<UserIcon className="h-4 w-4 text-muted-foreground" /> <Link href={`/sales/customers/${item.customer.id}`}>
<span>{getFullName(item.customer) || "—"}</span> <UserIcon /> {getFullName(item.customer) || "—"}
</div> </Link>
</Button>
) )
} }
}, },
@ -69,7 +74,7 @@ export default function EstimatesPage() {
header: ({ column }) => <ColumnHeader column={column} title="Vehicle" />, header: ({ column }) => <ColumnHeader column={column} title="Vehicle" />,
cell: ({ row }) => { cell: ({ row }) => {
const item :any= row.original const item :any= row.original
return <Button variant="outline" asChild size="sm"> return <Button variant="outline" asChild size="sm" onClick={(e) => e.stopPropagation()}>
<Link href={`/sales/vehicles/${item.vehicle?.id}`}> <Link href={`/sales/vehicles/${item.vehicle?.id}`}>
<Car/> {getVehicleLabel(item.vehicle as any) || "—"} <Car/> {getVehicleLabel(item.vehicle as any) || "—"}
</Link> </Link>

View File

@ -1,11 +1,14 @@
"use client" "use client"
import Link from "next/link"
import { UserIcon } from "lucide-react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { ResourcePage } from "@/shared/data-view/resource-page" import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view" import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog" import FormDialog from "@/shared/components/form-dialog"
import { InvoiceForm } from "@/modules/invoices/invoice-form" import { InvoiceForm } from "@/modules/invoices/invoice-form"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { formatCurrency, formatDate, formatEnum } from "@/shared/utils/formatters" import { formatCurrency, formatDate, formatEnum } from "@/shared/utils/formatters"
import { INVOICE_ROUTES } from "@garage/api" import { INVOICE_ROUTES } from "@garage/api"
import type { InvoicesClient } from "@garage/api" import type { InvoicesClient } from "@garage/api"
@ -23,6 +26,7 @@ type InvoiceItem = {
total?: number | string total?: number | string
balance_due?: number | string balance_due?: number | string
customer?: { customer?: {
id?: number | string
first_name?: string first_name?: string
last_name?: string last_name?: string
company_name?: string company_name?: string
@ -134,13 +138,20 @@ export default function InvoicesPage() {
cell: ({ row }) => { cell: ({ row }) => {
const item = row.original as unknown as InvoiceItem const item = row.original as unknown as InvoiceItem
const customerLabel = getCustomerLabel(item) const customerLabel = getCustomerLabel(item)
const subline = item.customer?.phone || item.customer?.company_name || "—"
return ( return (
<div className="min-w-[190px]"> <div className="min-w-[190px]">
<p className="font-medium leading-none">{customerLabel}</p> {item.customer?.id ? (
<p className="mt-1 text-xs text-muted-foreground"> <Button variant="outline" asChild size="sm" onClick={(e) => e.stopPropagation()}>
{item.customer?.phone || item.customer?.company_name || "—"} <Link href={`/sales/customers/${item.customer.id}`}>
</p> <UserIcon /> {customerLabel}
</Link>
</Button>
) : (
<p className="font-medium leading-none">{customerLabel}</p>
)}
<p className="mt-1 text-xs text-muted-foreground">{subline}</p>
</div> </div>
) )
}, },

View File

@ -140,12 +140,13 @@ const DISCOUNT_OPTIONS = DiscountType.map((value) => ({
export function EstimateForm({ resourceId, initialData, onSuccess }: EstimateFormProps) { export function EstimateForm({ resourceId, initialData, onSuccess }: EstimateFormProps) {
const api = useAuthApi() const api = useAuthApi()
const { form, isEditing } = useResourceForm<EstimateFormValues, any>({ const { form, isEditing, invalidate } = useResourceForm<EstimateFormValues, any>({
schema: estimateFormSchema, schema: estimateFormSchema,
defaultValues: DEFAULT_VALUES, defaultValues: DEFAULT_VALUES,
resourceId, resourceId,
initialData, initialData,
queryKey: [ESTIMATE_ROUTES.BY_ID, resourceId], queryKey: [ESTIMATE_ROUTES.BY_ID, resourceId],
initialize: (id) => api.estimates.show(id),
mapToFormValues, mapToFormValues,
}) })
@ -163,7 +164,7 @@ export function EstimateForm({ resourceId, initialData, onSuccess }: EstimateFor
return promise return promise
}, },
onSuccess: () => { onSuccess: () => {
form.reset() if (!isEditing) form.reset()
onSuccess?.() onSuccess?.()
}, },
}) })

View File

@ -27,9 +27,9 @@ import {
INVENTORY_CATEGORY_ROUTES, INVENTORY_CATEGORY_ROUTES,
INVENTORY_ROUTES, INVENTORY_ROUTES,
DEPARTMENT_ROUTES, DEPARTMENT_ROUTES,
VENDOR_ROUTES,
} from "@garage/api" } from "@garage/api"
import { InventoryCategoryCrudDialog } from "./inventory-category-crud-dialog" import { InventoryCategoryCrudDialog } from "./inventory-category-crud-dialog"
import { RhfVendorSelectField } from "@/modules/vendors/rhf-vendor-select-field"
// ── Constants ── // ── Constants ──
@ -84,15 +84,26 @@ function mapToFormValues(data: unknown): ExpenseItemFormValues {
sku: d.sku || "", sku: d.sku || "",
item_code: d.item_code || "", item_code: d.item_code || "",
description: d.description || "", description: d.description || "",
category: toRelation(d.category_id, d.category_title ?? d.category_name), category: toRelation(
unit_type: toRelation(d.unit_type_id, d.unit_type_title ?? d.unit_type_name), d.category_id ?? d.category?.id,
department: toRelation(d.department_id, d.department_name ?? d.department_title), d.category_title ?? d.category_name ?? d.category?.title ?? d.category?.name,
),
unit_type: toRelation(
d.unit_type_id ?? d.unit_type?.id,
d.unit_type_title ?? d.unit_type_name ?? d.unit_type?.title ?? d.unit_type?.name,
),
department: toRelation(
d.department_id ?? d.department?.id,
d.department_name ?? d.department_title ?? d.department?.name ?? d.department?.title,
),
purchase_information: d.purchase_information ?? true, purchase_information: d.purchase_information ?? true,
purchase_price: d.purchase_price ?? undefined, purchase_price: d.purchase_price ?? undefined,
purchase_chart_of_account: d.purchase_chart_of_account || "", purchase_chart_of_account: d.purchase_chart_of_account || "",
purchase_preferred_vendor: toRelation( purchase_preferred_vendor: toRelation(
d.purchase_preferred_vendor_id, d.purchase_preferred_vendor_id ?? d.purchase_preferred_vendor?.id,
d.purchase_preferred_vendor_name, d.purchase_preferred_vendor_name
?? d.purchase_preferred_vendor?.company_name
?? [d.purchase_preferred_vendor?.first_name, d.purchase_preferred_vendor?.last_name].filter(Boolean).join(" "),
), ),
sales_information: d.sales_information ?? false, sales_information: d.sales_information ?? false,
selling_price: d.selling_price ?? undefined, selling_price: d.selling_price ?? undefined,
@ -127,7 +138,7 @@ function mapFormToPayload(values: ExpenseItemFormValues) {
export function ExpenseItemForm({ resourceId, initialData, onSuccess }: ExpenseItemFormProps) { export function ExpenseItemForm({ resourceId, initialData, onSuccess }: ExpenseItemFormProps) {
const api = useAuthApi() const api = useAuthApi()
const { form, isEditing } = useResourceForm<ExpenseItemFormValues, any>({ const { form, isEditing, invalidate } = useResourceForm<ExpenseItemFormValues, any>({
schema: expenseItemFormSchema, schema: expenseItemFormSchema,
defaultValues: DEFAULT_VALUES, defaultValues: DEFAULT_VALUES,
resourceId, resourceId,
@ -150,7 +161,7 @@ export function ExpenseItemForm({ resourceId, initialData, onSuccess }: ExpenseI
return promise return promise
}, },
onSuccess: () => { onSuccess: () => {
form.reset() if (!isEditing) form.reset()
onSuccess?.() onSuccess?.()
}, },
}) })
@ -247,7 +258,7 @@ export function ExpenseItemForm({ resourceId, initialData, onSuccess }: ExpenseI
</div> </div>
{/* Purchase Information */} {/* Purchase Information */}
{/* <RhfCheckboxField <RhfCheckboxField
name="purchase_information" name="purchase_information"
label="Purchase Information" label="Purchase Information"
/> />
@ -259,22 +270,21 @@ export function ExpenseItemForm({ resourceId, initialData, onSuccess }: ExpenseI
placeholder="0.00" placeholder="0.00"
type="number" type="number"
/> />
{/* TODO(phase-2): wire Purchase Chart of Account to the chart-of-accounts module (currently disabled, marked "Coming soon"). */}
<RhfTextField <RhfTextField
name="purchase_chart_of_account" name="purchase_chart_of_account"
label="Purchase Chart of Account" label="Purchase Chart of Account"
placeholder="e.g. Expenses" placeholder="e.g. Expenses"
description="Coming soon"
disabled
/> />
</div> </div>
<RhfAsyncSelectField <RhfVendorSelectField
name="purchase_preferred_vendor" name="purchase_preferred_vendor"
label="Preferred Vendor" label="Preferred Vendor"
placeholder="Select vendor" placeholder="Select vendor"
queryKey={[VENDOR_ROUTES.INDEX]} />
listFn={() => api.vendors.list()}
mapOption={(item: any) => ({ value: String(item.id), label: item.name ?? String(item.id) })}
{...STORE_OBJECT}
/> */}
{/* Sales Information */} {/* Sales Information */}
{/* <RhfCheckboxField {/* <RhfCheckboxField

View File

@ -251,12 +251,13 @@ function TransactionDiscountField() {
export function InvoiceForm({ resourceId, initialData, onSuccess }: InvoiceFormProps) { export function InvoiceForm({ resourceId, initialData, onSuccess }: InvoiceFormProps) {
const api = useAuthApi() const api = useAuthApi()
const { form, isEditing } = useResourceForm<InvoiceFormValues, any>({ const { form, isEditing, invalidate } = useResourceForm<InvoiceFormValues, any>({
schema: invoiceFormSchema, schema: invoiceFormSchema,
defaultValues: DEFAULT_VALUES, defaultValues: DEFAULT_VALUES,
resourceId, resourceId,
initialData, initialData,
queryKey: [INVOICE_ROUTES.BY_ID, resourceId], queryKey: [INVOICE_ROUTES.BY_ID, resourceId],
initialize: (id) => api.invoices.show(id),
mapToFormValues, mapToFormValues,
}) })
@ -277,7 +278,7 @@ export function InvoiceForm({ resourceId, initialData, onSuccess }: InvoiceFormP
return promise return promise
}, },
onSuccess: () => { onSuccess: () => {
form.reset() if (!isEditing) form.reset()
onSuccess?.() onSuccess?.()
}, },
}) })

View File

@ -231,12 +231,13 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP
const api = useAuthApi() const api = useAuthApi()
const [isCheckInDialogOpen, setIsCheckInDialogOpen] = useState(false) const [isCheckInDialogOpen, setIsCheckInDialogOpen] = useState(false)
const { form, isEditing } = useResourceForm<JobCardFormValues, any>({ const { form, isEditing, invalidate } = useResourceForm<JobCardFormValues, any>({
schema: jobCardFormSchema, schema: jobCardFormSchema,
defaultValues: DEFAULT_VALUES, defaultValues: DEFAULT_VALUES,
resourceId, resourceId,
initialData, initialData,
queryKey: [JOB_CARD_ROUTES.BY_ID, resourceId], queryKey: [JOB_CARD_ROUTES.BY_ID, resourceId],
initialize: (id) => api.jobCards.show(id),
mapToFormValues, mapToFormValues,
}) })
@ -260,7 +261,7 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP
}, },
onSuccess: () => { onSuccess: () => {
setIsCheckInDialogOpen(false) setIsCheckInDialogOpen(false)
form.reset() if (!isEditing) form.reset()
onSuccess?.() onSuccess?.()
}, },
}) })

View File

@ -1,6 +1,6 @@
"use client" "use client"
import { useState } from "react" import { useEffect, useState } from "react"
import { useMutation } from "@tanstack/react-query" import { useMutation } from "@tanstack/react-query"
import { toast } from "sonner" import { toast } from "sonner"
import { confirm } from "@/shared/components/confirm-dialog" import { confirm } from "@/shared/components/confirm-dialog"
@ -42,6 +42,10 @@ export function useResourcePage<TClient extends ResourcePageClient>({
const { open: openDialog, close: closeDialog, isOpen, resourceId } = useFormDialog(paramKey) const { open: openDialog, close: closeDialog, isOpen, resourceId } = useFormDialog(paramKey)
const [selectedItem, setSelectedItem] = useState<TItem | null>(null) const [selectedItem, setSelectedItem] = useState<TItem | null>(null)
useEffect(() => {
if (!resourceId) setSelectedItem(null)
}, [resourceId])
const tableQuery = useDataTableQuery({ const tableQuery = useDataTableQuery({
queryKey: [routeKey], queryKey: [routeKey],
client, client,

View File

@ -3,7 +3,7 @@
import { useEffect } from "react" import { useEffect } from "react"
import { useForm, type DefaultValues, type FieldValues, type UseFormReturn } from "react-hook-form" import { useForm, type DefaultValues, type FieldValues, type UseFormReturn } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { useQuery, type QueryKey } from "@tanstack/react-query" import { useQuery, useQueryClient, type QueryKey } from "@tanstack/react-query"
import type { ZodType } from "zod" import type { ZodType } from "zod"
type UseResourceFormOptions<TFormValues extends FieldValues, TApiData = unknown> = { type UseResourceFormOptions<TFormValues extends FieldValues, TApiData = unknown> = {
@ -20,6 +20,7 @@ type UseResourceFormReturn<TFormValues extends FieldValues> = {
form: UseFormReturn<TFormValues> form: UseFormReturn<TFormValues>
isEditing: boolean isEditing: boolean
isInitializing: boolean isInitializing: boolean
invalidate: () => void
} }
export function useResourceForm<TFormValues extends FieldValues, TApiData = unknown>({ export function useResourceForm<TFormValues extends FieldValues, TApiData = unknown>({
@ -32,11 +33,15 @@ export function useResourceForm<TFormValues extends FieldValues, TApiData = unkn
queryKey, queryKey,
}: UseResourceFormOptions<TFormValues, TApiData>): UseResourceFormReturn<TFormValues> { }: UseResourceFormOptions<TFormValues, TApiData>): UseResourceFormReturn<TFormValues> {
const isEditing = !!resourceId const isEditing = !!resourceId
const queryClient = useQueryClient()
const resolvedQueryKey = queryKey ?? ["resource", resourceId]
const { data: queriedData, isLoading: isQueryLoading } = useQuery<TApiData>({ const { data: queriedData, isLoading: isQueryLoading } = useQuery<TApiData>({
queryKey: queryKey ?? ["resource", resourceId], queryKey: resolvedQueryKey,
queryFn: () => initialize!(resourceId!), queryFn: () => initialize!(resourceId!),
enabled: isEditing && !!initialize, enabled: isEditing && !!initialize,
staleTime: 0,
refetchOnMount: "always",
}) })
const resolvedData = queriedData ?? (isEditing ? initialData : undefined) const resolvedData = queriedData ?? (isEditing ? initialData : undefined)
@ -49,7 +54,7 @@ export function useResourceForm<TFormValues extends FieldValues, TApiData = unkn
useEffect(() => { useEffect(() => {
if (!isEditing) { if (!isEditing) {
if (initialData) { if (initialData) {
form.reset({ ...defaultValues, ...initialData } as any) form.reset({ ...defaultValues, ...mapToFormValues(initialData) } as any)
} else { } else {
form.reset(defaultValues) form.reset(defaultValues)
} }
@ -61,5 +66,9 @@ export function useResourceForm<TFormValues extends FieldValues, TApiData = unkn
} }
}, [isEditing, resolvedData, initialData]) // eslint-disable-line react-hooks/exhaustive-deps }, [isEditing, resolvedData, initialData]) // eslint-disable-line react-hooks/exhaustive-deps
return { form, isEditing, isInitializing: isEditing && !!initialize && isQueryLoading } const invalidate = () => {
queryClient.invalidateQueries({ queryKey: resolvedQueryKey })
}
return { form, isEditing, isInitializing: isEditing && !!initialize && isQueryLoading, invalidate }
} }