updates
This commit is contained in:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user