feat: enhance invoice and job card forms with new fields, validation, and print functionality

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-05-09 02:51:19 +03:00
co-authored by Copilot
parent 25c3125894
commit 349a458c3c
13 changed files with 319 additions and 77 deletions
@@ -26,9 +26,18 @@ import { useJobCard } from "./job-card-context"
const relationFieldSchema = z.object({ value: z.string(), label: z.string() }).nullable()
const requiredRelationFieldSchema = relationFieldSchema.superRefine((value, ctx) => {
if (!value?.value) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "This field is required",
})
}
})
const jobCardExpenseItemFormSchema = z.object({
expense_item: relationFieldSchema,
department: relationFieldSchema.optional(),
department: requiredRelationFieldSchema,
tax: relationFieldSchema.optional(),
quantity: z.coerce.number().min(1, "Quantity is required"),
rate: z.coerce.number().min(0, "Rate is required"),
@@ -62,14 +71,22 @@ const DEFAULT_VALUES: JobCardExpenseItemFormValues = {
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
function mapDepartmentOption(item: any) {
const id = item?.id ?? item?.department_id
return {
value: String(id),
label: item?.name ?? item?.title ?? item?.department_name ?? String(id),
}
}
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) }
department: (d.department || d.department_id != null)
? mapDepartmentOption(d.department ?? { id: d.department_id, name: d.department_name ?? d.department_title })
: null,
tax: d.tax_id != null
? { value: String(d.tax_id), label: formatTaxLabel(d.tax, String(d.tax_id)) }
@@ -82,6 +99,18 @@ function mapToFormValues(data: unknown): JobCardExpenseItemFormValues {
}
}
function mapJobCardDepartmentToRelation(jobCard: unknown) {
const d = (jobCard as any) ?? {}
const departmentId = d.department_id ?? d.department?.id
if (departmentId == null) {
return null
}
return {
value: String(departmentId),
label: d.department?.name ?? d.department_name ?? d.department_title ?? String(departmentId),
}
}
// ── Component ──
export function JobCardExpenseItemForm({
@@ -100,7 +129,10 @@ export function JobCardExpenseItemForm({
resolver: zodResolver(jobCardExpenseItemFormSchema) as any,
defaultValues: initialData
? mapToFormValues(initialData)
: DEFAULT_VALUES,
: {
...DEFAULT_VALUES,
department: mapJobCardDepartmentToRelation(jobCard),
},
})
const [error, setError] = React.useState<string | null>(null)
@@ -126,10 +158,15 @@ export function JobCardExpenseItemForm({
}
)
} else {
if (!values.department?.value) {
setError("Department is required")
return
}
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,
department_id: Number(values.department.value),
tax_id: values.tax ? Number(values.tax.value) : undefined,
quantity: values.quantity,
rate: values.rate,
@@ -215,12 +252,10 @@ export function JobCardExpenseItemForm({
name="department"
label="Department"
placeholder="Select department"
required
queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.departments.list()}
mapOption={(item: any) => ({
value: String(item.id),
label: item.name ?? String(item.id),
})}
mapOption={mapDepartmentOption}
createForm={(props) => <DepartmentInlineForm {...props} />}
createLabel="Department"
{...STORE_OBJECT}
@@ -118,7 +118,7 @@ function mapToFormValues(data: unknown): JobCardFormValues {
customer: toRelation(d.customer_id, d.customer ? `${d.customer.first_name} ${d.customer.last_name}` : undefined),
vehicle: toRelation(d.vehicle_id, d.vehicle ? (d.vehicle.plate_number ?? `${d.vehicle.make ?? ""} ${d.vehicle.model ?? ""}`.trim()) : undefined),
estimate: toRelation(d.estimate_id, d.estimate?.estimate_number),
department: toRelation(d.department_id, d.department?.name),
department: toRelation(d.department_id, d.department?.name ?? d.department_name ?? d.department_title),
service_writer: toRelation(d.service_writer_id, d.service_writer ? `${d.service_writer.first_name} ${d.service_writer.last_name}` : undefined),
primary_technician: toRelation(d.primary_technician_id, d.primary_technician ? `${d.primary_technician.first_name} ${d.primary_technician.last_name}` : undefined),
sales_person: toRelation(d.sales_person_id, d.sales_person ? `${d.sales_person.first_name} ${d.sales_person.last_name}` : undefined),
@@ -211,11 +211,13 @@ function mapFormToPayload(values: JobCardFormValues) {
// ── Shared mapOption for async selects ──
const mapLookupOption = (item: any) => ({
const mapLookupOption = (item: any, labelKey: string = "name") => ({
value: String(item.id),
label: item.name,
label: item[labelKey],
})
const mapEmployeeOption = (item: any) => ({
value: String(item.id),
label: `${item.first_name} ${item.last_name}`,
@@ -320,7 +322,7 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP
placeholder="Select insurance type"
queryKey={[INSURANCE_TYPE_ROUTES.INDEX]}
listFn={() => api.insuranceTypes.list()}
mapOption={mapLookupOption}
mapOption={(op) => (mapLookupOption(op, 'title'))}
{...STORE_OBJECT}
/>
</div>
@@ -341,7 +343,7 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP
label="Service Writer"
placeholder="Select service writer"
{...STORE_OBJECT}
/>
/>
<RhfEmployeeSelectField
showCreate
name="primary_technician"
@@ -436,6 +438,7 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP
name="department"
label="Department"
placeholder="Select department"
required
queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.departments.list()}
mapOption={mapLookupOption}
@@ -62,14 +62,22 @@ const DEFAULT_VALUES: JobCardPartFormValues = {
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
function mapDepartmentOption(item: any) {
const id = item?.id ?? item?.department_id
return {
value: String(id),
label: item?.name ?? item?.title ?? item?.department_name ?? String(id),
}
}
function mapToFormValues(data: unknown): JobCardPartFormValues {
const d = (data as any) ?? {}
return {
part: d.part
? { value: String(d.part.id), label: d.part.title ?? String(d.part.id) }
: null,
department: d.department
? { value: String(d.department.id), label: d.department.name ?? String(d.department.id) }
department: (d.department || d.department_id != null)
? mapDepartmentOption(d.department ?? { id: d.department_id, name: d.department_name ?? d.department_title })
: null,
tax: d.tax_id != null
? { value: String(d.tax_id), label: formatTaxLabel(d.tax, String(d.tax_id)) }
@@ -82,6 +90,18 @@ function mapToFormValues(data: unknown): JobCardPartFormValues {
}
}
function mapJobCardDepartmentToRelation(jobCard: unknown) {
const d = (jobCard as any) ?? {}
const departmentId = d.department_id ?? d.department?.id
if (departmentId == null) {
return null
}
return {
value: String(departmentId),
label: d.department?.name ?? d.department_name ?? d.department_title ?? String(departmentId),
}
}
// ── Component ──
export function JobCardPartForm({
@@ -100,7 +120,10 @@ export function JobCardPartForm({
resolver: zodResolver(jobCardPartFormSchema) as any,
defaultValues: initialData
? mapToFormValues(initialData)
: DEFAULT_VALUES,
: {
...DEFAULT_VALUES,
department: mapJobCardDepartmentToRelation(jobCard),
},
})
const [error, setError] = React.useState<string | null>(null)
@@ -126,10 +149,15 @@ export function JobCardPartForm({
}
)
} else {
if (!values.department?.value) {
setError("Department is required")
return
}
await toast.promise(
api.jobCards.addPart(jobCardId, {
part_id: values.part ? Number(values.part.value) : undefined,
department_id: values.department ? Number(values.department.value) : undefined,
department_id: Number(values.department.value),
tax_id: values.tax ? Number(values.tax.value) : undefined,
quantity: values.quantity,
rate: values.rate,
@@ -215,12 +243,10 @@ export function JobCardPartForm({
name="department"
label="Department"
placeholder="Select department"
required
queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.departments.list()}
mapOption={(item: any) => ({
value: String(item.id),
label: item.name ?? String(item.id),
})}
mapOption={mapDepartmentOption}
createForm={(props) => <DepartmentInlineForm {...props} />}
createLabel="Department"
{...STORE_OBJECT}
@@ -71,14 +71,22 @@ const DEFAULT_VALUES: JobCardServiceFormValues = {
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
function mapDepartmentOption(item: any) {
const id = item?.id ?? item?.department_id
return {
value: String(id),
label: item?.name ?? item?.title ?? item?.department_name ?? String(id),
}
}
function mapToFormValues(data: unknown): JobCardServiceFormValues {
const d = (data as any) ?? {}
return {
service: d.service
? { value: String(d.service.id), label: d.service.labor_name ?? String(d.service.id) }
: null,
department: d.department
? { value: String(d.department.id), label: d.department.name ?? String(d.department.id) }
department: (d.department || d.department_id != null)
? mapDepartmentOption(d.department ?? { id: d.department_id, name: d.department_name ?? d.department_title })
: undefined as any,
tax: d.tax_id != null
? { value: String(d.tax_id), label: formatTaxLabel(d.tax, String(d.tax_id)) }
@@ -97,6 +105,18 @@ function mapToFormValues(data: unknown): JobCardServiceFormValues {
}
}
function mapJobCardDepartmentToRelation(jobCard: unknown) {
const d = (jobCard as any) ?? {}
const departmentId = d.department_id ?? d.department?.id
if (departmentId == null) {
return undefined as any
}
return {
value: String(departmentId),
label: d.department?.name ?? d.department_name ?? d.department_title ?? String(departmentId),
}
}
const RATE_TYPE_OPTIONS = RateType.map((v) => ({
value: v,
label: v === "flat_rate" ? "Flat Rate" : "Hourly",
@@ -120,7 +140,10 @@ export function JobCardServiceForm({
resolver: zodResolver(jobCardServiceFormSchema) as any,
defaultValues: initialData
? mapToFormValues(initialData)
: DEFAULT_VALUES,
: {
...DEFAULT_VALUES,
department: mapJobCardDepartmentToRelation(jobCard),
},
})
const rateType = form.watch("rate_type")
@@ -149,10 +172,15 @@ export function JobCardServiceForm({
}
)
} else {
if (!values.department?.value) {
setError("Department is required")
return
}
await toast.promise(
api.jobCards.addService(jobCardId, {
service_id: values.service ? Number(values.service.value) : undefined,
department_id: values.department ? Number(values.department.value) : undefined,
department_id: Number(values.department.value),
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,
@@ -283,10 +311,7 @@ export function JobCardServiceForm({
required
queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.departments.list()}
mapOption={(item: any) => ({
value: String(item.id),
label: item.name ?? String(item.id),
})}
mapOption={mapDepartmentOption}
createForm={(props) => <DepartmentInlineForm {...props} />}
createLabel="Department"
{...STORE_OBJECT}