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
@@ -1,17 +1,26 @@
"use client"
import { confirm } from '@/shared/components/confirm-dialog';
import { api } from '@garage/api';
import { useRouter } from 'next/dist/client/components/navigation';
import { useRouter } from 'next/dist/client/components/navigation';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/shared/components/ui/dropdown-menu'
import { Button } from '@/shared/components/ui/button'
import { toast } from 'sonner'
import { Ellipsis, Pencil, Printer, Trash2 } from 'lucide-react';
import { Ellipsis, FileText, Pencil, Printer, Trash2 } from 'lucide-react';
import { useDocumentPrint } from '@/shared/hooks/use-document-print';
import { useJobCard } from './job-card-context';
import { useState } from 'react';
import { useAuthApi } from '@/shared/useApi';
// TODO: setting a sales person not working
// TODO: unable to set a Primary technician for the job card. Need to investigate and fix it.
export default function JobCardDropdown({ id }: { id: string }) {
const api = useAuthApi()
const router = useRouter();
const { print, isPrinting } = useDocumentPrint()
const jobCard = useJobCard()
const [isConverting, setIsConverting] = useState(false)
const handleEdit = () => {
router.push(`/sales/job-cards/${id}/edit`)
@@ -21,6 +30,35 @@ export default function JobCardDropdown({ id }: { id: string }) {
print("job_card", id, "print")
}
const handleConvertToInvoice = async () => {
const confirmed = await confirm({
title: "Convert to Invoice",
description: "This will create a new invoice from this job card. Do you want to continue?",
confirmLabel: "Convert",
})
if (!confirmed) return
setIsConverting(true)
try {
const res = await api.jobCards.convertToInvoice(id, {}) as any
const invoiceId = res?.data?.id
toast.success("Job card converted to invoice successfully")
if (invoiceId) {
router.push(`/sales/invoice/${invoiceId}`)
}
} catch (err: any) {
const conflictId = err?.response?.data?.data?.invoice_id ?? err?.data?.data?.invoice_id
if (conflictId) {
toast.info("An invoice already exists for this job card.")
router.push(`/sales/invoice/${conflictId}`)
} else {
toast.error("Failed to convert job card to invoice")
}
} finally {
setIsConverting(false)
}
}
const handleDelete = async () => {
const confirmed = await confirm({
title: "Delete Job Card",
@@ -57,6 +95,15 @@ export default function JobCardDropdown({ id }: { id: string }) {
<Printer className="size-4" />
{isPrinting ? "Printing..." : "Print"}
</DropdownMenuItem>
{jobCard?.status !== "draft" && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleConvertToInvoice} disabled={isConverting}>
<FileText className="size-4" />
{isConverting ? "Converting..." : "Convert to Invoice"}
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
@@ -0,0 +1,280 @@
"use client"
import React from "react"
import { AlertTriangle, Plus, Save } from "lucide-react"
import { z } from "zod"
import { Button } from "@/shared/components/ui/button"
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
import { FieldGroup } from "@/shared/components/ui/field"
import {
Rhform,
RhfTextField,
RhfTextareaField,
RhfAsyncSelectField,
} from "@/shared/components/form"
import { DepartmentInlineForm } from "@/modules/services/inline-forms/department-inline-form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { DEPARTMENT_ROUTES, TAX_ROUTES } from "@garage/api"
import { useJobCard } from "./job-card-context"
// ── Schema ──
const relationFieldSchema = z.object({ value: z.string(), label: z.string() }).nullable()
const jobCardExpenseItemFormSchema = z.object({
expense_item: relationFieldSchema,
department: relationFieldSchema.optional(),
tax: relationFieldSchema.optional(),
quantity: z.coerce.number().min(1, "Quantity is required"),
rate: z.coerce.number().min(0, "Rate is required"),
discount_amount: z.coerce.number().min(0).optional(),
chart_of_account: z.string().optional(),
description: z.string().optional(),
})
type JobCardExpenseItemFormValues = z.infer<typeof jobCardExpenseItemFormSchema>
// ── Props ──
export type JobCardExpenseItemFormProps = {
jobCardId: string
jobCardExpenseItemId?: number | null
initialData?: unknown
onSuccess?: () => void
onCancel?: () => void
}
const DEFAULT_VALUES: JobCardExpenseItemFormValues = {
expense_item: null,
department: null,
tax: null,
quantity: 1,
rate: 0,
discount_amount: undefined,
chart_of_account: "",
description: "",
}
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
function mapToFormValues(data: unknown): JobCardExpenseItemFormValues {
const d = (data as any) ?? {}
return {
expense_item: d.expense_item
? { value: String(d.expense_item.id), label: d.expense_item.item_name ?? String(d.expense_item.id) }
: null,
department: d.department
? { value: String(d.department.id), label: d.department.name ?? String(d.department.id) }
: null,
tax: d.tax_id != null
? { value: String(d.tax_id), label: d.tax ? `${d.tax.title} (${d.tax.rate}%)` : String(d.tax_id) }
: null,
quantity: d.quantity ?? 1,
rate: d.rate != null ? Number(d.rate) : 0,
discount_amount: d.discount_amount != null ? Number(d.discount_amount) : undefined,
chart_of_account: d.chart_of_account ?? "",
description: d.description ?? "",
}
}
// ── Component ──
export function JobCardExpenseItemForm({
jobCardId,
jobCardExpenseItemId,
initialData,
onSuccess,
onCancel,
}: JobCardExpenseItemFormProps) {
const api = useAuthApi()
const jobCard = useJobCard()
const isEditing = !!jobCardExpenseItemId
const isLineItemDiscount = (jobCard as any)?.discount_type === "line_item_level"
const form = useForm<JobCardExpenseItemFormValues>({
resolver: zodResolver(jobCardExpenseItemFormSchema) as any,
defaultValues: initialData
? mapToFormValues(initialData)
: DEFAULT_VALUES,
})
const [error, setError] = React.useState<string | null>(null)
const [isPending, setIsPending] = React.useState(false)
async function handleSubmit(values: JobCardExpenseItemFormValues) {
setError(null)
setIsPending(true)
try {
if (isEditing && jobCardExpenseItemId) {
await toast.promise(
api.jobCards.updateExpenseItem(jobCardId, {
job_card_expense_item_id: jobCardExpenseItemId,
quantity: values.quantity,
rate: values.rate,
discount_amount: isLineItemDiscount ? (values.discount_amount ?? 0) : undefined,
description: values.description || undefined,
}),
{
loading: "Updating expense item...",
success: "Expense item updated successfully",
error: "Failed to update expense item",
}
)
} else {
await toast.promise(
api.jobCards.addExpenseItem(jobCardId, {
expense_item_id: values.expense_item ? Number(values.expense_item.value) : undefined,
department_id: values.department ? Number(values.department.value) : undefined,
tax_id: values.tax ? Number(values.tax.value) : undefined,
quantity: values.quantity,
rate: values.rate,
discount_amount: isLineItemDiscount ? (values.discount_amount ?? 0) : undefined,
chart_of_account: values.chart_of_account || undefined,
description: values.description || undefined,
}),
{
loading: "Adding expense item...",
success: "Expense item added successfully",
error: "Failed to add expense item",
}
)
}
form.reset()
onSuccess?.()
} catch (err: any) {
setError(err?.message ?? "An unexpected error occurred")
} finally {
setIsPending(false)
}
}
return (
<Rhform form={form} onSubmit={handleSubmit}>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="me-2 h-4 w-4" />
<AlertTitle>
{isEditing ? "Failed to update expense item" : "Failed to add expense item"}
</AlertTitle>
{error}
</Alert>
)}
<FieldGroup>
{!isEditing && (
<RhfAsyncSelectField
name="expense_item"
label="Expense Item"
placeholder="Select expense item"
required
queryKey={["expense-items"]}
listFn={() => api.expenses.listItems()}
mapOption={(item: any) => ({
value: String(item.id),
label: item.item_name ?? item.title ?? String(item.id),
})}
{...STORE_OBJECT}
/>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="quantity"
label="Quantity"
type="number"
placeholder="1"
required
/>
<RhfTextField
name="rate"
label="Rate"
type="number"
placeholder="0.00"
required
/>
</div>
{isLineItemDiscount && (
<RhfTextField
name="discount_amount"
label="Discount Amount"
type="number"
placeholder="0.00"
/>
)}
{!isEditing && (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department"
queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.departments.list()}
mapOption={(item: any) => ({
value: String(item.id),
label: item.name ?? String(item.id),
})}
createForm={(props) => <DepartmentInlineForm {...props} />}
createLabel="Department"
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="tax"
label="Tax"
placeholder="Select tax rate"
queryKey={[TAX_ROUTES.INDEX]}
listFn={() => api.taxes.list()}
mapOption={(item: any) => ({
value: String(item.id),
label: item.title ? `${item.title} (${item.rate}%)` : `#${item.id}`,
})}
{...STORE_OBJECT}
/>
</div>
<RhfTextField
name="chart_of_account"
label="Chart of Account"
placeholder="e.g. COA-400"
/>
</>
)}
<RhfTextareaField
name="description"
label="Description"
placeholder="Optional notes"
/>
</FieldGroup>
<div className="flex justify-end gap-2 pt-2">
{onCancel && (
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
)}
<Button type="submit" disabled={isPending}>
{isPending ? (
isEditing ? "Saving..." : "Adding..."
) : isEditing ? (
<>
<Save className="me-2 h-4 w-4" />
Save Changes
</>
) : (
<>
<Plus className="me-2 h-4 w-4" />
Add Expense Item
</>
)}
</Button>
</div>
</Rhform>
)
}
@@ -26,8 +26,9 @@ import {
type JobCardFormValues,
FUEL_LEVEL_OPTIONS,
JOB_CARD_STATUS_OPTIONS,
DISCOUNT_TYPE_OPTIONS,
} from "./job-card.schema"
import { JOB_CARD_ROUTES, EMPLOYEE_ROUTES, DEPARTMENT_ROUTES, INSURANCE_TYPE_ROUTES } from "@garage/api"
import { JOB_CARD_ROUTES, EMPLOYEE_ROUTES, DEPARTMENT_ROUTES, INSURANCE_TYPE_ROUTES, TAX_ROUTES } from "@garage/api"
import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field"
import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
import { RhfLabelPickerField } from "@/modules/labels/rhf-label-picker-field"
@@ -55,6 +56,7 @@ const DEFAULT_VALUES: JobCardFormValues = {
sales_person: null,
insurance_type: null,
insurer: null,
tax: null,
order_number: "",
estimate_number: "",
status: "check_in",
@@ -104,6 +106,7 @@ function mapToFormValues(data: unknown): JobCardFormValues {
sales_person: toRelation(d.sales_person_id, d.sales_person ? `${d.sales_person.first_name} ${d.sales_person.last_name}` : undefined),
insurance_type: toRelation(d.insurance_type_id, d.insurance_type?.name),
insurer: toRelation(d.insurer_id, d.insurer ? `${d.insurer.first_name} ${d.insurer.last_name}`.trim() || d.insurer.company_name : undefined),
tax: toRelation(d.tax_id, d.tax ? `${d.tax.title} (${d.tax.rate}%)` : undefined),
order_number: d.order_number || "",
estimate_number: d.estimate_number || "",
status: d.status || "draft",
@@ -148,6 +151,7 @@ function mapFormToPayload(values: JobCardFormValues) {
sales_person_id: toId(values.sales_person),
insurance_type_id: values.insurance_type ? String(toId(values.insurance_type)) : null,
insurer_id: values.insurer ? String(toId(values.insurer)) : null,
tax_id: toId(values.tax) ?? undefined,
estimate_number: values.estimate_number || undefined,
order_number: values.order_number || undefined,
status: values.status || undefined,
@@ -347,6 +351,24 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP
placeholder="Select status"
options={JOB_CARD_STATUS_OPTIONS}
/>
<RhfSelectField
name="discount_type"
label="Discount Type"
placeholder="Select discount type"
options={DISCOUNT_TYPE_OPTIONS}
/>
<RhfAsyncSelectField
name="tax"
label="Tax"
placeholder="Select tax rate"
queryKey={[TAX_ROUTES.INDEX]}
listFn={() => api.taxes.list()}
mapOption={(item: any) => ({
value: String(item.id),
label: item.title ? `${item.title} (${item.rate}%)` : `#${item.id}`,
})}
{...STORE_OBJECT}
/>
<RhfCustomerRemarksField name="customer_remarks" />
<RhfTextareaField name="footer" label="Estimate Footer" placeholder="Thank you for your business." rows={6} />
<RhfCheckboxField
@@ -29,10 +29,9 @@ import { ResourcePage } from "@/shared/data-view/resource-page"
import PaymentReceivedPage from "@/app/(authenticated)/sales/payment-received/page"
import JobCardPaymentsReceived from "./job-card-payments-received"
import { formatDate } from "@/shared/utils/formatters"
import type { JobCardShowData } from "@garage/api"
type JobCard = NonNullable<CrudShowResponse<JobCardsClient>['data']>
type JobCard = JobCardShowData
function InfoItem({
@@ -1,4 +1,4 @@
"use client"
"use client"
import React from "react"
import { AlertTriangle, Plus, Save } from "lucide-react"
@@ -18,26 +18,27 @@ import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { DEPARTMENT_ROUTES, TAX_ROUTES } from "@garage/api"
import { useJobCard } from "./job-card-context"
// ── Schema ──
// ── Schema ──
const relationFieldSchema = z.object({ value: z.string(), label: z.string() }).nullable()
const jobCardPartFormSchema = z.object({
part: z
.object({ value: z.string(), label: z.string() })
.nullable(),
department: z
.object({ value: z.string(), label: z.string() })
.nullable()
.optional(),
part: relationFieldSchema,
department: relationFieldSchema.optional(),
tax: relationFieldSchema.optional(),
quantity: z.coerce.number().min(1, "Quantity is required"),
rate: z.coerce.number().min(0, "Rate is required"),
tax: z.string().optional(),
discount_amount: z.coerce.number().min(0).optional(),
chart_of_account: z.string().optional(),
description: z.string().optional(),
})
type JobCardPartFormValues = z.infer<typeof jobCardPartFormSchema>
// ── Props ──
// ── Props ──
export type JobCardPartFormProps = {
jobCardId: string
@@ -50,9 +51,11 @@ export type JobCardPartFormProps = {
const DEFAULT_VALUES: JobCardPartFormValues = {
part: null,
department: null,
tax: null,
quantity: 1,
rate: 0,
tax: "",
discount_amount: undefined,
chart_of_account: "",
description: "",
}
@@ -67,14 +70,18 @@ function mapToFormValues(data: unknown): JobCardPartFormValues {
department: d.department
? { value: String(d.department.id), label: d.department.name ?? String(d.department.id) }
: null,
tax: d.tax_id != null
? { value: String(d.tax_id), label: d.tax ? `${d.tax.title} (${d.tax.rate}%)` : String(d.tax_id) }
: null,
quantity: d.quantity ?? 1,
rate: d.rate != null ? Number(d.rate) : 0,
tax: d.tax ?? "",
discount_amount: d.discount_amount != null ? Number(d.discount_amount) : undefined,
chart_of_account: d.chart_of_account ?? "",
description: d.description ?? "",
}
}
// ── Component ──
// ── Component ──
export function JobCardPartForm({
jobCardId,
@@ -84,7 +91,9 @@ export function JobCardPartForm({
onCancel,
}: JobCardPartFormProps) {
const api = useAuthApi()
const jobCard = useJobCard()
const isEditing = !!jobCardPartId
const isLineItemDiscount = (jobCard as any)?.discount_type === "line_item_level"
const form = useForm<JobCardPartFormValues>({
resolver: zodResolver(jobCardPartFormSchema) as any,
@@ -106,6 +115,7 @@ export function JobCardPartForm({
job_card_part_id: jobCardPartId,
quantity: values.quantity,
rate: values.rate,
discount_amount: isLineItemDiscount ? (values.discount_amount ?? 0) : undefined,
description: values.description || undefined,
}),
{
@@ -119,9 +129,11 @@ export function JobCardPartForm({
api.jobCards.addPart(jobCardId, {
part_id: values.part ? Number(values.part.value) : undefined,
department_id: values.department ? Number(values.department.value) : undefined,
tax_id: values.tax ? Number(values.tax.value) : undefined,
quantity: values.quantity,
rate: values.rate,
tax: values.tax || undefined,
discount_amount: isLineItemDiscount ? (values.discount_amount ?? 0) : undefined,
chart_of_account: values.chart_of_account || undefined,
description: values.description || undefined,
}),
{
@@ -186,28 +198,52 @@ export function JobCardPartForm({
/>
</div>
{isLineItemDiscount && (
<RhfTextField
name="discount_amount"
label="Discount Amount"
type="number"
placeholder="0.00"
/>
)}
{!isEditing && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department"
queryKey={["departments"]}
listFn={() => api.departments.list()}
mapOption={(item: any) => ({
value: String(item.id),
label: item.name ?? String(item.id),
})}
createForm={(props) => <DepartmentInlineForm {...props} />}
createLabel="Department"
{...STORE_OBJECT}
/>
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department"
queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.departments.list()}
mapOption={(item: any) => ({
value: String(item.id),
label: item.name ?? String(item.id),
})}
createForm={(props) => <DepartmentInlineForm {...props} />}
createLabel="Department"
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="tax"
label="Tax"
placeholder="Select tax rate"
queryKey={[TAX_ROUTES.INDEX]}
listFn={() => api.taxes.list()}
mapOption={(item: any) => ({
value: String(item.id),
label: item.title ? `${item.title} (${item.rate}%)` : `#${item.id}`,
})}
{...STORE_OBJECT}
/>
</div>
<RhfTextField
name="tax"
label="Tax"
placeholder="e.g. 5%"
name="chart_of_account"
label="Chart of Account"
placeholder="e.g. COA-300"
/>
</div>
</>
)}
<RhfTextareaField
@@ -1,7 +1,7 @@
"use client"
import React from "react"
import { AlertTriangle } from "lucide-react"
import { AlertTriangle, Plus, Save } from "lucide-react"
import { z } from "zod"
import { Button } from "@/shared/components/ui/button"
@@ -19,28 +19,24 @@ import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { RateType } from "@garage/api"
import { RateType, DEPARTMENT_ROUTES, TAX_ROUTES } from "@garage/api"
import { useJobCard } from "./job-card-context"
// ── Schema ──
const relationFieldSchema = z.object({ value: z.string(), label: z.string() }).nullable()
const jobCardServiceFormSchema = z.object({
service: z
.object({ value: z.string(), label: z.string() })
.nullable(),
department: z
.object({ value: z.string(), label: z.string() })
.nullable()
.optional(),
service: relationFieldSchema,
department: z.object({ value: z.string(), label: z.string() }),
tax: relationFieldSchema.optional(),
rate_type: z.string().optional(),
labor_rate: z
.object({ value: z.string(), label: z.string() })
.nullable()
.optional(),
labor_rate: relationFieldSchema.optional(),
quantity: z.coerce.number().min(1, "Quantity is required"),
rate: z.coerce.number().min(0, "Rate is required"),
working_hours: z.coerce.number().min(0).optional(),
labor_hours: z.coerce.number().min(0).optional(),
tax: z.string().optional(),
discount_amount: z.coerce.number().min(0).optional(),
chart_of_account: z.string().optional(),
description: z.string().optional(),
})
@@ -59,14 +55,15 @@ export type JobCardServiceFormProps = {
const DEFAULT_VALUES: JobCardServiceFormValues = {
service: null,
department: null,
department: undefined as any,
tax: null,
rate_type: "flat_rate",
labor_rate: null,
quantity: 1,
rate: 0,
working_hours: 0,
labor_hours: 0,
tax: "",
discount_amount: undefined,
chart_of_account: "",
description: "",
}
@@ -81,6 +78,9 @@ function mapToFormValues(data: unknown): JobCardServiceFormValues {
: null,
department: d.department
? { value: String(d.department.id), label: d.department.name ?? String(d.department.id) }
: undefined as any,
tax: d.tax_id != null
? { value: String(d.tax_id), label: d.tax ? `${d.tax.title} (${d.tax.rate}%)` : String(d.tax_id) }
: null,
rate_type: d.rate_type ?? "flat_rate",
labor_rate: d.labor_rate
@@ -90,7 +90,7 @@ function mapToFormValues(data: unknown): JobCardServiceFormValues {
rate: d.rate != null ? Number(d.rate) : 0,
working_hours: d.working_hours != null ? Number(d.working_hours) : 0,
labor_hours: d.labor_hours != null ? Number(d.labor_hours) : 0,
tax: d.tax ?? "",
discount_amount: d.discount_amount != null ? Number(d.discount_amount) : undefined,
chart_of_account: d.chart_of_account ?? "",
description: d.description ?? "",
}
@@ -111,7 +111,9 @@ export function JobCardServiceForm({
onCancel,
}: JobCardServiceFormProps) {
const api = useAuthApi()
const jobCard = useJobCard()
const isEditing = !!jobCardServiceId
const isLineItemDiscount = (jobCard as any)?.discount_type === "line_item_level"
const form = useForm<JobCardServiceFormValues>({
resolver: zodResolver(jobCardServiceFormSchema) as any,
@@ -120,6 +122,9 @@ export function JobCardServiceForm({
: DEFAULT_VALUES,
})
const rateType = form.watch("rate_type")
const isHourly = rateType === "hourly"
const [error, setError] = React.useState<string | null>(null)
const [isPending, setIsPending] = React.useState(false)
@@ -133,6 +138,7 @@ export function JobCardServiceForm({
job_card_service_id: jobCardServiceId,
quantity: values.quantity,
rate: values.rate,
discount_amount: isLineItemDiscount ? (values.discount_amount ?? 0) : undefined,
description: values.description || undefined,
}),
{
@@ -146,13 +152,14 @@ export function JobCardServiceForm({
api.jobCards.addService(jobCardId, {
service_id: values.service ? Number(values.service.value) : undefined,
department_id: values.department ? Number(values.department.value) : undefined,
tax_id: values.tax ? Number(values.tax.value) : undefined,
rate_type: values.rate_type || undefined,
labor_rate_id: values.labor_rate ? Number(values.labor_rate.value) : undefined,
quantity: values.quantity,
rate: values.rate,
working_hours: values.working_hours || undefined,
labor_hours: values.labor_hours || undefined,
tax: values.tax || undefined,
discount_amount: isLineItemDiscount ? (values.discount_amount ?? 0) : undefined,
chart_of_account: values.chart_of_account || undefined,
description: values.description || undefined,
}),
@@ -218,6 +225,15 @@ export function JobCardServiceForm({
/>
</div>
{isLineItemDiscount && (
<RhfTextField
name="discount_amount"
label="Discount Amount"
type="number"
placeholder="0.00"
/>
)}
{!isEditing && (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
@@ -241,27 +257,30 @@ export function JobCardServiceForm({
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="working_hours"
label="Working Hours"
type="number"
placeholder="0"
/>
<RhfTextField
name="labor_hours"
label="Labor Hours"
type="number"
placeholder="0"
/>
</div>
{isHourly && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="working_hours"
label="Working Hours"
type="number"
placeholder="0"
/>
<RhfTextField
name="labor_hours"
label="Labor Hours"
type="number"
placeholder="0"
/>
</div>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department"
queryKey={["departments"]}
required
queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.departments.list()}
mapOption={(item: any) => ({
value: String(item.id),
@@ -271,10 +290,17 @@ export function JobCardServiceForm({
createLabel="Department"
{...STORE_OBJECT}
/>
<RhfTextField
<RhfAsyncSelectField
name="tax"
label="Tax"
placeholder="e.g. 5%"
placeholder="Select tax rate"
queryKey={[TAX_ROUTES.INDEX]}
listFn={() => api.taxes.list()}
mapOption={(item: any) => ({
value: String(item.id),
label: item.title ? `${item.title} (${item.rate}%)` : `#${item.id}`,
})}
{...STORE_OBJECT}
/>
</div>
@@ -302,9 +328,21 @@ export function JobCardServiceForm({
<Button type="submit" disabled={isPending}>
{isPending
? isEditing ? "Updating..." : "Adding..."
: isEditing ? "Update Service" : "Add Service"}
: isEditing ? (
<>
<Save className="me-2 h-4 w-4" />
Update Service
</>
) : (
<>
<Plus className="me-2 h-4 w-4" />
Add Service
</>
)}
</Button>
</div>
</Rhform>
)
}
@@ -59,6 +59,7 @@ const jobCardFormSchema = z.object({
sales_person: relationFieldSchema,
insurance_type: relationFieldSchema,
insurer: relationFieldSchema,
tax: relationFieldSchema,
// ── Numbers & identifiers ──
order_number: z.string().optional(),