fix(dashboard): resolve tutorial QA notes (clips 2–6)

- settings/company: show success toast on save (2.1)
- inspection templates: add explicit Save button (2.6)
- parts: label price fields with AED currency (3.1)
- service groups: add parts/services picker to packages (3.3)
- vehicles: add insurance (has_insurance + insurance type) field (4.2)
- appointments: add calendar view with list/calendar toggle (5.1)
- job card check-in: require department (5.4)
- job card payments: refresh job card/invoice queries so PAID shows (5.6)
- vendors: add phone + address fields (6.1)
- bill payments: surface real vendor/employee validation errors (6.4)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 11:39:07 +04:00
co-authored by Claude Opus 4.8
parent c71b5a19ed
commit fdce301495
14 changed files with 562 additions and 39 deletions
@@ -1,6 +1,7 @@
"use client"
import { AlertTriangle, Plus, Save } from "lucide-react"
import { AlertTriangle, Plus, Save, Trash2 } from "lucide-react"
import { useFieldArray } from "react-hook-form"
import { Button } from "@/shared/components/ui/button"
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
@@ -49,6 +50,8 @@ const DEFAULT_VALUES: ServiceGroupFormValues = {
mark_as_recommended: false,
set_packaged_pricing: false,
is_active: true,
parts: [],
services: [],
}
// ── Mapping helpers ──
@@ -60,6 +63,20 @@ const mapLookupOption = (item: any) => ({
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
const partLineToForm = (r: any) => ({
id: r.id,
part: r.part ? { value: String(r.part.id), label: r.part.title ?? r.part.name ?? `#${r.part.id}` } : null,
quantity: r.quantity != null ? Number(r.quantity) : 1,
rate: r.rate != null ? Number(r.rate) : 0,
})
const serviceLineToForm = (r: any) => ({
id: r.id,
service: r.service ? { value: String(r.service.id), label: r.service.title ?? r.service.name ?? `#${r.service.id}` } : null,
rate: r.rate != null ? Number(r.rate) : 0,
hours: r.hours != null ? Number(r.hours) : 0,
})
function mapToFormValues(data: unknown): ServiceGroupFormValues {
const d = (data as any)?.data ?? data ?? {}
@@ -77,6 +94,8 @@ function mapToFormValues(data: unknown): ServiceGroupFormValues {
mark_as_recommended: d.mark_as_recommended ?? false,
set_packaged_pricing: d.set_packaged_pricing ?? false,
is_active: d.is_active ?? true,
parts: Array.isArray(d._parts) ? d._parts.map(partLineToForm) : [],
services: Array.isArray(d._services) ? d._services.map(serviceLineToForm) : [],
}
}
@@ -116,18 +135,87 @@ export function ServiceGroupForm({ resourceId, initialData, onSuccess }: Service
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
queryKey: [SERVICE_GROUP_ROUTES.BY_ID, resourceId],
// Fetch the group together with its existing parts/services so the
// pickers can be pre-populated when editing.
initialize: async (id) => {
const [group, parts, services] = await Promise.all([
api.serviceGroups.show(id),
api.serviceGroupParts.list({ service_group_id: id } as any),
api.serviceGroupServices.list({ service_group_id: id } as any),
])
const g = (group as any)?.data ?? group ?? {}
return {
data: {
...g,
_parts: (parts as any)?.data ?? [],
_services: (services as any)?.data ?? [],
},
}
},
mapToFormValues,
})
const partsArray = useFieldArray({ control: form.control, name: "parts" })
const servicesArray = useFieldArray({ control: form.control, name: "services" })
// Replace the package's parts/services on the server with the current form
// rows: delete the existing rows, then recreate from the form. Mirrors the
// "replace details" approach used elsewhere (e.g. payments).
const syncIncludes = async (groupId: string | number, values: ServiceGroupFormValues) => {
const gid = Number(groupId)
const existingParts = await api.serviceGroupParts.list({ service_group_id: gid } as any)
const existingPartIds = (((existingParts as any)?.data) ?? []).map((r: any) => r.id)
await Promise.all(existingPartIds.map((id: any) => api.serviceGroupParts.destroy(String(id))))
await Promise.all(
(values.parts ?? [])
.filter((p) => p.part?.value)
.map((p) =>
api.serviceGroupParts.create({
service_group_id: gid,
part_id: Number(p.part!.value),
quantity: Number(p.quantity ?? 1),
rate: Number(p.rate ?? 0),
} as any),
),
)
const existingServices = await api.serviceGroupServices.list({ service_group_id: gid } as any)
const existingServiceIds = (((existingServices as any)?.data) ?? []).map((r: any) => r.id)
await Promise.all(existingServiceIds.map((id: any) => api.serviceGroupServices.destroy(String(id))))
await Promise.all(
(values.services ?? [])
.filter((s) => s.service?.value)
.map((s) =>
api.serviceGroupServices.create({
service_group_id: gid,
service_id: Number(s.service!.value),
rate: Number(s.rate ?? 0),
hours: Number(s.hours ?? 0),
} as any),
),
)
}
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: ServiceGroupFormValues) => {
const promise = isEditing && resourceId
? api.serviceGroups.update(resourceId, mapUpdatePayload(values))
: api.serviceGroups.create(mapCreatePayload(values))
const run = async () => {
const groupRes: any =
isEditing && resourceId
? await api.serviceGroups.update(resourceId, mapUpdatePayload(values))
: await api.serviceGroups.create(mapCreatePayload(values))
const groupId = resourceId ?? groupRes?.data?.id ?? groupRes?.id
if (groupId) {
await syncIncludes(groupId, values)
}
return groupRes
}
const promise = run()
toast.promise(promise, {
loading: isEditing ? "Updating service group..." : "Creating service group...",
success: isEditing ? "Service group updated" : "Service group created",
error: isEditing ? "Failed to update service group" : "Failed to create service group",
loading: isEditing ? "Updating package..." : "Creating package...",
success: isEditing ? "Package updated" : "Package created",
error: isEditing ? "Failed to update package" : "Failed to create package",
})
return promise
},
@@ -221,7 +309,7 @@ export function ServiceGroupForm({ resourceId, initialData, onSuccess }: Service
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="selling_price"
label="Selling Price"
label="Selling Price (AED)"
placeholder="0.00"
type="number"
/>
@@ -239,6 +327,100 @@ export function ServiceGroupForm({ resourceId, initialData, onSuccess }: Service
rows={3}
/>
{/* ── Parts in this package ── */}
<div className="border-t pt-4">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold">Parts in this package</h3>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => partsArray.append({ part: null, quantity: 1, rate: 0 })}
>
<Plus className="me-1 h-4 w-4" /> Add Part
</Button>
</div>
{partsArray.fields.length === 0 && (
<p className="text-muted-foreground text-sm">No parts added yet.</p>
)}
<div className="space-y-3">
{partsArray.fields.map((field, index) => (
<div
key={field.id}
className="grid grid-cols-1 gap-2 sm:grid-cols-[1fr_110px_130px_auto] sm:items-end"
>
<RhfAsyncSelectField
name={`parts.${index}.part`}
label="Part"
placeholder="Select part (e.g. Engine Oil 5W-30)"
queryKey={["parts"]}
listFn={() => api.parts.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
<RhfTextField name={`parts.${index}.quantity`} label="Qty" type="number" placeholder="1" />
<RhfTextField name={`parts.${index}.rate`} label="Rate (AED)" type="number" placeholder="0.00" />
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => partsArray.remove(index)}
aria-label="Remove part"
>
<Trash2 className="h-4 w-4 text-red-600" />
</Button>
</div>
))}
</div>
</div>
{/* ── Services in this package ── */}
<div className="border-t pt-4">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold">Services in this package</h3>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => servicesArray.append({ service: null, rate: 0, hours: 0 })}
>
<Plus className="me-1 h-4 w-4" /> Add Service
</Button>
</div>
{servicesArray.fields.length === 0 && (
<p className="text-muted-foreground text-sm">No services added yet.</p>
)}
<div className="space-y-3">
{servicesArray.fields.map((field, index) => (
<div
key={field.id}
className="grid grid-cols-1 gap-2 sm:grid-cols-[1fr_110px_130px_auto] sm:items-end"
>
<RhfAsyncSelectField
name={`services.${index}.service`}
label="Service"
placeholder="Select service (e.g. Oil change labour)"
queryKey={["services"]}
listFn={() => api.services.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
<RhfTextField name={`services.${index}.hours`} label="Hours" type="number" placeholder="0" />
<RhfTextField name={`services.${index}.rate`} label="Rate (AED)" type="number" placeholder="0.00" />
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => servicesArray.remove(index)}
aria-label="Remove service"
>
<Trash2 className="h-4 w-4 text-red-600" />
</Button>
</div>
))}
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfCheckboxField
name="show_as_lump_sum"
@@ -4,6 +4,20 @@ export const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
const packagePartSchema = z.object({
id: z.union([z.string(), z.number()]).optional(),
part: relationFieldSchema,
quantity: z.coerce.number().min(0).optional(),
rate: z.coerce.number().min(0).optional(),
})
const packageServiceSchema = z.object({
id: z.union([z.string(), z.number()]).optional(),
service: relationFieldSchema,
rate: z.coerce.number().min(0).optional(),
hours: z.coerce.number().min(0).optional(),
})
export const serviceGroupFormSchema = z.object({
shop_type: relationFieldSchema,
inventory_category: relationFieldSchema,
@@ -18,6 +32,9 @@ export const serviceGroupFormSchema = z.object({
mark_as_recommended: z.boolean().optional(),
set_packaged_pricing: z.boolean().optional(),
is_active: z.boolean().optional(),
// ── Package contents ──
parts: z.array(packagePartSchema).optional(),
services: z.array(packageServiceSchema).optional(),
})
export type ServiceGroupFormValues = z.infer<typeof serviceGroupFormSchema>