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:
parent
c71b5a19ed
commit
fdce301495
@ -1,14 +1,17 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||
import FormDialog from "@/shared/components/form-dialog"
|
||||
import { AppointmentForm } from "@/modules/appointments/appointment-form"
|
||||
import { AppointmentsCalendarView } from "@/modules/appointments/appointments-calendar-view"
|
||||
import { APPOINTMENT_ROUTES, AppointmentStatus } from "@garage/api"
|
||||
import type { AppointmentsClient } from "@garage/api"
|
||||
import { CalendarCheck2Icon, ClipboardListIcon, ClockIcon } from "lucide-react"
|
||||
import { CalendarCheck2Icon, CalendarDaysIcon, ClipboardListIcon, ClockIcon, ListIcon } from "lucide-react"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { RelationLink } from "@/shared/components/relation-link"
|
||||
import { formatEnum } from "@/shared/utils/formatters"
|
||||
|
||||
@ -22,8 +25,44 @@ const STATUS_COLORS: Record<string, string> = {
|
||||
|
||||
export default function AppointmentsPage() {
|
||||
const router = useRouter()
|
||||
const [view, setView] = useState<"list" | "calendar">("list")
|
||||
|
||||
const viewToggle = (
|
||||
<div className="flex items-center justify-end gap-1 px-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant={view === "list" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setView("list")}
|
||||
>
|
||||
<ListIcon className="me-1 size-4" /> List
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={view === "calendar" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setView("calendar")}
|
||||
>
|
||||
<CalendarDaysIcon className="me-1 size-4" /> Calendar
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (view === "calendar") {
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">Appointments</h1>
|
||||
{viewToggle}
|
||||
</div>
|
||||
<AppointmentsCalendarView />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{viewToggle}
|
||||
<ResourcePage<AppointmentsClient>
|
||||
pageTitle="Appointments"
|
||||
routeKey={APPOINTMENT_ROUTES.INDEX}
|
||||
@ -104,5 +143,6 @@ export default function AppointmentsPage() {
|
||||
actionsColumn(),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useParams } from "next/navigation"
|
||||
import { ChevronLeft, Pencil, Plus, Trash2 } from "lucide-react"
|
||||
import { ChevronLeft, Pencil, Plus, Save, Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@ -44,6 +44,9 @@ export default function InspectionTemplateEditorPage() {
|
||||
const api = useAuthApi()
|
||||
const [template, setTemplate] = useState<InspectionTemplate | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [name, setName] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const [editing, setEditing] = useState<{
|
||||
section: InspectionTemplateSection
|
||||
@ -55,6 +58,8 @@ export default function InspectionTemplateEditorPage() {
|
||||
try {
|
||||
const res = await api.inspectionTemplates.show(id)
|
||||
setTemplate(res.data)
|
||||
setName(res.data.name ?? "")
|
||||
setDescription(res.data.description ?? "")
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to load template")
|
||||
} finally {
|
||||
@ -74,6 +79,20 @@ export default function InspectionTemplateEditorPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const saveTemplate = async () => {
|
||||
if (!template) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await api.inspectionTemplates.update(template.id, { name, description })
|
||||
setTemplate(res.data)
|
||||
toast.success("Template saved")
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to save")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const addSection = async () => {
|
||||
if (!template) return
|
||||
const name = await prompt({
|
||||
@ -153,8 +172,8 @@ export default function InspectionTemplateEditorPage() {
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Name</label>
|
||||
<Input
|
||||
defaultValue={template.name}
|
||||
onBlur={(e) => e.currentTarget.value !== template.name && updateMeta({ name: e.currentTarget.value })}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-5">
|
||||
@ -168,9 +187,9 @@ export default function InspectionTemplateEditorPage() {
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Description</label>
|
||||
<Textarea
|
||||
defaultValue={template.description ?? ""}
|
||||
value={description}
|
||||
rows={2}
|
||||
onBlur={(e) => e.currentTarget.value !== (template.description ?? "") && updateMeta({ description: e.currentTarget.value })}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -259,6 +278,13 @@ export default function InspectionTemplateEditorPage() {
|
||||
onSaved={load}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="sticky bottom-0 flex justify-end gap-2 border-t bg-background py-3">
|
||||
<Button onClick={saveTemplate} disabled={saving}>
|
||||
<Save className="size-4 mr-1" />
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,132 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { CalendarCheck2Icon, ClipboardListIcon, ClockIcon } from "lucide-react"
|
||||
|
||||
import { Calendar } from "@/shared/components/ui/calendar"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { APPOINTMENT_ROUTES } from "@garage/api"
|
||||
import { formatEnum } from "@/shared/utils/formatters"
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
requested: "bg-yellow-100 text-yellow-800",
|
||||
confirmed: "bg-blue-100 text-blue-800",
|
||||
in_progress: "bg-purple-100 text-purple-800",
|
||||
completed: "bg-green-100 text-green-800",
|
||||
cancelled: "bg-red-100 text-red-800",
|
||||
}
|
||||
|
||||
function toDateKey(d: Date): string {
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0")
|
||||
const day = String(d.getDate()).padStart(2, "0")
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
export function AppointmentsCalendarView() {
|
||||
const api = useAuthApi()
|
||||
const router = useRouter()
|
||||
const [selected, setSelected] = useState<Date | undefined>(() => new Date())
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: [APPOINTMENT_ROUTES.INDEX, "calendar"],
|
||||
queryFn: () => api.appointments.list({ per_page: 100 } as any),
|
||||
})
|
||||
|
||||
const appointments: any[] = (data as any)?.data ?? []
|
||||
|
||||
const byDate = useMemo(() => {
|
||||
const map: Record<string, any[]> = {}
|
||||
for (const a of appointments) {
|
||||
const key = String(a.date ?? "").slice(0, 10)
|
||||
if (!key) continue
|
||||
;(map[key] ??= []).push(a)
|
||||
}
|
||||
return map
|
||||
}, [appointments])
|
||||
|
||||
const bookedDays = useMemo(
|
||||
() => Object.keys(byDate).map((k) => new Date(`${k}T00:00:00`)),
|
||||
[byDate],
|
||||
)
|
||||
|
||||
const selectedKey = selected ? toDateKey(selected) : ""
|
||||
const dayAppointments = byDate[selectedKey] ?? []
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-[auto_1fr]">
|
||||
<Card className="w-fit">
|
||||
<CardContent className="p-3">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selected}
|
||||
onSelect={setSelected}
|
||||
modifiers={{ booked: bookedDays }}
|
||||
modifiersClassNames={{
|
||||
booked:
|
||||
"relative after:absolute after:bottom-1 after:left-1/2 after:h-1 after:w-1 after:-translate-x-1/2 after:rounded-full after:bg-primary",
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-semibold">
|
||||
{selected
|
||||
? selected.toLocaleDateString(undefined, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})
|
||||
: "Select a day"}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{isLoading && <p className="text-muted-foreground text-sm">Loading…</p>}
|
||||
{!isLoading && dayAppointments.length === 0 && (
|
||||
<p className="text-muted-foreground text-sm">No appointments on this day.</p>
|
||||
)}
|
||||
{dayAppointments.map((a) => {
|
||||
const jobCardId = a.job_card_id ?? a.job_card?.id
|
||||
return (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
onClick={() => router.push(`/calendar/appointment/${a.id}`)}
|
||||
className="hover:bg-muted/40 w-full rounded border p-3 text-left transition"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 font-medium">
|
||||
<CalendarCheck2Icon className="text-muted-foreground size-4" />
|
||||
{a.title}
|
||||
</span>
|
||||
<Badge className={STATUS_COLORS[a.status] ?? "bg-gray-100 text-gray-800"}>
|
||||
{formatEnum(a.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-1 flex items-center gap-3 text-sm">
|
||||
<span className="flex items-center gap-1">
|
||||
<ClockIcon className="size-3" />
|
||||
{a.from_time} – {a.to_time}
|
||||
</span>
|
||||
{jobCardId && (
|
||||
<span className="flex items-center gap-1">
|
||||
<ClipboardListIcon className="size-3" />
|
||||
{a.job_card?.title || `#${jobCardId}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -87,6 +87,10 @@ export function JobCardCheckInDialog({
|
||||
})
|
||||
|
||||
const handleSubmit = async (values: CheckInFormValues) => {
|
||||
if (!values.department) {
|
||||
form.setError("department", { message: "Department is required" })
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.jobCards.checkIn(jobCardId, {
|
||||
check_in_date: values.check_in_date || undefined,
|
||||
@ -131,6 +135,7 @@ export function JobCardCheckInDialog({
|
||||
name="department"
|
||||
label="Department"
|
||||
placeholder="Select department"
|
||||
required
|
||||
queryKey={[DEPARTMENT_ROUTES.INDEX]}
|
||||
listFn={() => api.departments.list()}
|
||||
mapOption={(op:any)=> ({value: op.id, label: op.name})}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { CrudResource } from "@/shared/data-view/resource-page"
|
||||
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||
import FormDialog from "@/shared/components/form-dialog"
|
||||
@ -22,6 +23,7 @@ import { Money } from "@/shared/components/money"
|
||||
|
||||
export default function JobCardPaymentsReceived() {
|
||||
const jobCard = useJobCard()
|
||||
const queryClient = useQueryClient()
|
||||
const [hasOpened, setHasOpened] = useState(false)
|
||||
|
||||
return (
|
||||
@ -60,7 +62,12 @@ export default function JobCardPaymentsReceived() {
|
||||
invoiceCustomer={jobCard?.customer as any}
|
||||
lockJobCard
|
||||
lockCustomer
|
||||
onSuccess={invalidateQuery}
|
||||
onSuccess={() => {
|
||||
// Refresh the payments list AND the job card / invoice
|
||||
// queries so the PAID status reflects the new payment.
|
||||
invalidateQuery()
|
||||
queryClient.invalidateQueries()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
|
||||
@ -212,14 +212,14 @@ export function PartForm({ resourceId, initialData, onSuccess }: PartFormProps)
|
||||
<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"
|
||||
/>
|
||||
|
||||
<RhfTextField
|
||||
name="purchase_price"
|
||||
label="Purchase Price"
|
||||
label="Purchase Price (AED)"
|
||||
placeholder="0.00"
|
||||
type="number"
|
||||
/>
|
||||
|
||||
@ -213,21 +213,24 @@ export function PaymentMadeForm({ resourceId, initialData, onSuccess, billId, ex
|
||||
return
|
||||
}
|
||||
|
||||
const vendorError = error.validationErrors.vendor_id?.[0]
|
||||
const employeeError = error.validationErrors.employee_id?.[0]
|
||||
const billError = error.validationErrors.bill_id?.[0]
|
||||
// Surface the actual backend validation messages on each field
|
||||
// instead of blanket-replacing them with the "not both" message —
|
||||
// that masked unrelated errors and showed the wrong text when only
|
||||
// one of vendor/employee was selected.
|
||||
const errors = error.validationErrors
|
||||
const billError = errors.bill_id?.[0]
|
||||
const vendorError = errors.vendor_id?.[0]
|
||||
const employeeError = errors.employee_id?.[0]
|
||||
|
||||
if (billError) {
|
||||
form.setError("bill", { message: billError })
|
||||
}
|
||||
|
||||
if (!vendorError && !employeeError) {
|
||||
return
|
||||
if (vendorError) {
|
||||
form.setError("vendor", { message: vendorError })
|
||||
}
|
||||
if (employeeError) {
|
||||
form.setError("employee", { message: employeeError })
|
||||
}
|
||||
|
||||
const message = "Select either a vendor or an employee, not both"
|
||||
form.setError("vendor", { message })
|
||||
form.setError("employee", { message })
|
||||
},
|
||||
onSuccess: () => {
|
||||
form.reset()
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -151,15 +151,12 @@ export function SettingsForm() {
|
||||
}, [data]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const { mutate, error, isPending } = useFormMutation(form, {
|
||||
mutationFn: (values: SettingsFormValues) => {
|
||||
const payload = mapFormToPayload(values)
|
||||
const promise = api.settings.update(payload)
|
||||
toast.promise(promise, {
|
||||
loading: "Saving settings...",
|
||||
success: "Settings saved successfully",
|
||||
error: "Failed to save settings",
|
||||
})
|
||||
return promise
|
||||
mutationFn: (values: SettingsFormValues) => api.settings.update(mapFormToPayload(values)),
|
||||
onSuccess: () => {
|
||||
toast.success("Settings saved successfully")
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to save settings")
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
RhfTextField,
|
||||
RhfTextareaField,
|
||||
RhfAsyncSelectField,
|
||||
RhfCheckboxField,
|
||||
RhfImageField,
|
||||
} from "@/shared/components/form"
|
||||
import { ShopTypeInlineForm } from "./inline-forms/shop-type-inline-form"
|
||||
@ -24,7 +25,7 @@ import { useResourceForm } from "@/shared/hooks/use-resource-form"
|
||||
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
|
||||
import { toRelation, toId } from "@/shared/lib/utils"
|
||||
import { formatUppercase } from "@/shared/utils/formatters"
|
||||
import { VEHICLE_ROUTES } from "@garage/api"
|
||||
import { VEHICLE_ROUTES, INSURANCE_TYPE_ROUTES } from "@garage/api"
|
||||
|
||||
import { vehicleFormSchema, type VehicleFormValues } from "./vehicle.schema"
|
||||
import { CustomerForm } from "../customers/customer-form"
|
||||
@ -57,6 +58,8 @@ const DEFAULT_VALUES: VehicleFormValues = {
|
||||
drivetrain: "",
|
||||
mileage: "",
|
||||
note: "",
|
||||
has_insurance: false,
|
||||
insurance_type: null,
|
||||
image: null,
|
||||
}
|
||||
|
||||
@ -92,6 +95,8 @@ function mapToFormValues(data: unknown): VehicleFormValues {
|
||||
drivetrain: toFormString(d.drivetrain),
|
||||
mileage: toFormString(d.mileage),
|
||||
note: toFormString(d.note),
|
||||
has_insurance: Boolean(d.has_insurance),
|
||||
insurance_type: toRelation(d.insurance_type_id, d.insurance_type?.title),
|
||||
image: null,
|
||||
}
|
||||
}
|
||||
@ -115,6 +120,8 @@ function mapToPayload(values: VehicleFormValues) {
|
||||
drivetrain: values.drivetrain || undefined,
|
||||
mileage: values.mileage || undefined,
|
||||
note: values.note || undefined,
|
||||
has_insurance: values.has_insurance ?? false,
|
||||
insurance_type_id: values.has_insurance ? toId(values.insurance_type) : undefined,
|
||||
image: values.image instanceof File ? values.image : undefined,
|
||||
}
|
||||
}
|
||||
@ -137,6 +144,8 @@ export function VehicleForm({ resourceId, initialData, onSuccess }: VehicleFormP
|
||||
const existingImageUrl: string | null =
|
||||
(resolvedData as any)?.data?.image_url ?? (resolvedData as any)?.image_url ?? null
|
||||
|
||||
const hasInsurance = form.watch("has_insurance")
|
||||
|
||||
const { mutate, error, isPending } = useFormMutation(form, {
|
||||
mutationFn: (values: VehicleFormValues) => {
|
||||
const payload = mapToPayload(values)
|
||||
@ -254,6 +263,24 @@ export function VehicleForm({ resourceId, initialData, onSuccess }: VehicleFormP
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Insurance */}
|
||||
<div className="border-t pt-4">
|
||||
<RhfCheckboxField name="has_insurance" label="Has Insurance?" />
|
||||
{hasInsurance && (
|
||||
<div className="mt-4">
|
||||
<RhfAsyncSelectField
|
||||
name="insurance_type"
|
||||
label="Insurance Type"
|
||||
placeholder="Select insurance type (e.g. Comprehensive)"
|
||||
queryKey={[INSURANCE_TYPE_ROUTES.INDEX]}
|
||||
listFn={() => api.insuranceTypes.list()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* License & identifiers */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField
|
||||
|
||||
@ -31,6 +31,10 @@ export const vehicleFormSchema = z.object({
|
||||
// ── Notes ──
|
||||
note: z.string().optional(),
|
||||
|
||||
// ── Insurance ──
|
||||
has_insurance: z.boolean().optional(),
|
||||
insurance_type: relationFieldSchema.optional(),
|
||||
|
||||
// ── Image ──
|
||||
image: z.any().optional(),
|
||||
})
|
||||
|
||||
74
apps/dashboard/modules/vendors/vendor-form.tsx
vendored
74
apps/dashboard/modules/vendors/vendor-form.tsx
vendored
@ -8,11 +8,13 @@ import { FieldGroup } from "@/shared/components/ui/field"
|
||||
import {
|
||||
Rhform,
|
||||
RhfTextField,
|
||||
RhfAsyncSelectField,
|
||||
} from "@/shared/components/form"
|
||||
import { toast } from "sonner"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useResourceForm } from "@/shared/hooks/use-resource-form"
|
||||
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
|
||||
import { toId, toRelation } from "@/shared/lib/utils"
|
||||
|
||||
import {
|
||||
vendorFormSchema,
|
||||
@ -31,24 +33,44 @@ export type VendorFormProps = {
|
||||
// ── Default values ──
|
||||
|
||||
const DEFAULT_VALUES: VendorFormValues = {
|
||||
salutation:"Mr.",
|
||||
salutation: "Mr.",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
company_name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
alternate_phone: "",
|
||||
address_line_1: "",
|
||||
address_line_2: "",
|
||||
city: "",
|
||||
zip_code: "",
|
||||
country: null,
|
||||
state: null,
|
||||
} as any
|
||||
|
||||
// ── Mapping helpers ──
|
||||
|
||||
const mapLookupOption = (item: any) => ({ value: String(item.id), label: item.name })
|
||||
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||
|
||||
function mapToFormValues(data: unknown): VendorFormValues {
|
||||
const d = (data as any)?.data ?? data ?? {}
|
||||
const address = Array.isArray(d.addresses) ? d.addresses[0] ?? {} : {}
|
||||
|
||||
return {
|
||||
first_name: d.first_name || "",
|
||||
last_name: d.last_name || "",
|
||||
company_name: d.company_name || "",
|
||||
email: d.email || "",
|
||||
}
|
||||
phone: d.phone || "",
|
||||
alternate_phone: d.alternate_phone || "",
|
||||
address_line_1: address.address_line_1 || "",
|
||||
address_line_2: address.address_line_2 || "",
|
||||
city: address.city || "",
|
||||
zip_code: address.zip_code || "",
|
||||
country: toRelation(address.country_id, address.country?.name),
|
||||
state: toRelation(address.state_id, address.state?.name),
|
||||
} as VendorFormValues
|
||||
}
|
||||
|
||||
function mapFormToPayload(values: VendorFormValues) {
|
||||
@ -57,6 +79,14 @@ function mapFormToPayload(values: VendorFormValues) {
|
||||
last_name: values.last_name || undefined,
|
||||
company_name: values.company_name || undefined,
|
||||
email: values.email || undefined,
|
||||
phone: values.phone || undefined,
|
||||
alternate_phone: values.alternate_phone || undefined,
|
||||
address_line_1: values.address_line_1 || undefined,
|
||||
address_line_2: values.address_line_2 || undefined,
|
||||
city: values.city || undefined,
|
||||
zip_code: values.zip_code || undefined,
|
||||
country_id: toId(values.country),
|
||||
state_id: toId(values.state),
|
||||
}
|
||||
}
|
||||
|
||||
@ -113,6 +143,46 @@ export function VendorForm({ resourceId, initialData, onSuccess }: VendorFormPro
|
||||
<RhfTextField name="company_name" label="Company Name" placeholder="Acme Supplies" required />
|
||||
<RhfTextField name="email" label="Email" placeholder="vendor@example.com" type="email" required />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField name="phone" label="Phone" placeholder="+971501234567" type="tel" />
|
||||
<RhfTextField name="alternate_phone" label="Alternate Phone" placeholder="+971509876543" type="tel" />
|
||||
</div>
|
||||
|
||||
{/* ── Address ── */}
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="mb-3 text-sm font-semibold">Address</h3>
|
||||
<RhfTextField name="address_line_1" label="Address Line 1" placeholder="Street 10" />
|
||||
<div className="mt-4">
|
||||
<RhfTextField name="address_line_2" label="Address Line 2" placeholder="Near Central Plaza" />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
name="country"
|
||||
label="Country"
|
||||
placeholder="Select country"
|
||||
queryKey={["countries"]}
|
||||
listFn={() => api.geo.countries()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
name="state"
|
||||
label="State"
|
||||
placeholder="Select state"
|
||||
queryKey={["states"]}
|
||||
listFn={() => api.geo.states()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField name="city" label="City" placeholder="Dubai" />
|
||||
<RhfTextField name="zip_code" label="Zip Code" placeholder="00000" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" variant="default" disabled={isPending}>
|
||||
{isEditing ? <Save /> : <Plus />}
|
||||
{isPending
|
||||
|
||||
13
apps/dashboard/modules/vendors/vendor.schema.ts
vendored
13
apps/dashboard/modules/vendors/vendor.schema.ts
vendored
@ -11,6 +11,11 @@ const optionalNumberSchema = z.preprocess(
|
||||
z.coerce.number().int().optional(),
|
||||
)
|
||||
|
||||
const relationFieldSchema = z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.nullable()
|
||||
.optional()
|
||||
|
||||
const vendorFormSchema = z.object({
|
||||
first_name: z.string().trim().min(1, "First name is required").max(50, "First name must be at most 50 characters"),
|
||||
last_name: z.string().trim().min(1, "Last name is required").max(50, "Last name must be at most 50 characters"),
|
||||
@ -22,6 +27,14 @@ const vendorFormSchema = z.object({
|
||||
opening_balance: optionalNumberSchema,
|
||||
credit_limit: optionalNumberSchema,
|
||||
website: optionalStringMaxSchema(255),
|
||||
// ── Address (optional). Persisted by the backend store endpoint when
|
||||
// address_line_1 + country + state are provided. ──
|
||||
address_line_1: optionalStringMaxSchema(255),
|
||||
address_line_2: optionalStringMaxSchema(255),
|
||||
city: optionalStringMaxSchema(100),
|
||||
zip_code: optionalStringMaxSchema(20),
|
||||
country: relationFieldSchema,
|
||||
state: relationFieldSchema,
|
||||
})
|
||||
|
||||
type VendorFormValues = z.infer<typeof vendorFormSchema>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user