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"
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { ResourcePage } from "@/shared/data-view/resource-page"
|
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||||
import { ColumnHeader } from "@/shared/data-view/table-view"
|
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||||
import FormDialog from "@/shared/components/form-dialog"
|
import FormDialog from "@/shared/components/form-dialog"
|
||||||
import { AppointmentForm } from "@/modules/appointments/appointment-form"
|
import { AppointmentForm } from "@/modules/appointments/appointment-form"
|
||||||
|
import { AppointmentsCalendarView } from "@/modules/appointments/appointments-calendar-view"
|
||||||
import { APPOINTMENT_ROUTES, AppointmentStatus } from "@garage/api"
|
import { APPOINTMENT_ROUTES, AppointmentStatus } from "@garage/api"
|
||||||
import type { AppointmentsClient } 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 { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { RelationLink } from "@/shared/components/relation-link"
|
import { RelationLink } from "@/shared/components/relation-link"
|
||||||
import { formatEnum } from "@/shared/utils/formatters"
|
import { formatEnum } from "@/shared/utils/formatters"
|
||||||
|
|
||||||
@ -22,8 +25,44 @@ const STATUS_COLORS: Record<string, string> = {
|
|||||||
|
|
||||||
export default function AppointmentsPage() {
|
export default function AppointmentsPage() {
|
||||||
const router = useRouter()
|
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 (
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{viewToggle}
|
||||||
<ResourcePage<AppointmentsClient>
|
<ResourcePage<AppointmentsClient>
|
||||||
pageTitle="Appointments"
|
pageTitle="Appointments"
|
||||||
routeKey={APPOINTMENT_ROUTES.INDEX}
|
routeKey={APPOINTMENT_ROUTES.INDEX}
|
||||||
@ -104,5 +143,6 @@ export default function AppointmentsPage() {
|
|||||||
actionsColumn(),
|
actionsColumn(),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import { useCallback, useEffect, useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useParams } from "next/navigation"
|
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 { toast } from "sonner"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
@ -44,6 +44,9 @@ export default function InspectionTemplateEditorPage() {
|
|||||||
const api = useAuthApi()
|
const api = useAuthApi()
|
||||||
const [template, setTemplate] = useState<InspectionTemplate | null>(null)
|
const [template, setTemplate] = useState<InspectionTemplate | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [description, setDescription] = useState("")
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
const [editing, setEditing] = useState<{
|
const [editing, setEditing] = useState<{
|
||||||
section: InspectionTemplateSection
|
section: InspectionTemplateSection
|
||||||
@ -55,6 +58,8 @@ export default function InspectionTemplateEditorPage() {
|
|||||||
try {
|
try {
|
||||||
const res = await api.inspectionTemplates.show(id)
|
const res = await api.inspectionTemplates.show(id)
|
||||||
setTemplate(res.data)
|
setTemplate(res.data)
|
||||||
|
setName(res.data.name ?? "")
|
||||||
|
setDescription(res.data.description ?? "")
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(e?.message ?? "Failed to load template")
|
toast.error(e?.message ?? "Failed to load template")
|
||||||
} finally {
|
} 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 () => {
|
const addSection = async () => {
|
||||||
if (!template) return
|
if (!template) return
|
||||||
const name = await prompt({
|
const name = await prompt({
|
||||||
@ -153,8 +172,8 @@ export default function InspectionTemplateEditorPage() {
|
|||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-muted-foreground">Name</label>
|
<label className="text-xs text-muted-foreground">Name</label>
|
||||||
<Input
|
<Input
|
||||||
defaultValue={template.name}
|
value={name}
|
||||||
onBlur={(e) => e.currentTarget.value !== template.name && updateMeta({ name: e.currentTarget.value })}
|
onChange={(e) => setName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 mt-5">
|
<div className="flex items-center gap-3 mt-5">
|
||||||
@ -168,9 +187,9 @@ export default function InspectionTemplateEditorPage() {
|
|||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-muted-foreground">Description</label>
|
<label className="text-xs text-muted-foreground">Description</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
defaultValue={template.description ?? ""}
|
value={description}
|
||||||
rows={2}
|
rows={2}
|
||||||
onBlur={(e) => e.currentTarget.value !== (template.description ?? "") && updateMeta({ description: e.currentTarget.value })}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -259,6 +278,13 @@ export default function InspectionTemplateEditorPage() {
|
|||||||
onSaved={load}
|
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>
|
</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) => {
|
const handleSubmit = async (values: CheckInFormValues) => {
|
||||||
|
if (!values.department) {
|
||||||
|
form.setError("department", { message: "Department is required" })
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await api.jobCards.checkIn(jobCardId, {
|
await api.jobCards.checkIn(jobCardId, {
|
||||||
check_in_date: values.check_in_date || undefined,
|
check_in_date: values.check_in_date || undefined,
|
||||||
@ -131,6 +135,7 @@ export function JobCardCheckInDialog({
|
|||||||
name="department"
|
name="department"
|
||||||
label="Department"
|
label="Department"
|
||||||
placeholder="Select department"
|
placeholder="Select department"
|
||||||
|
required
|
||||||
queryKey={[DEPARTMENT_ROUTES.INDEX]}
|
queryKey={[DEPARTMENT_ROUTES.INDEX]}
|
||||||
listFn={() => api.departments.list()}
|
listFn={() => api.departments.list()}
|
||||||
mapOption={(op:any)=> ({value: op.id, label: op.name})}
|
mapOption={(op:any)=> ({value: op.id, label: op.name})}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useQueryClient } from "@tanstack/react-query"
|
||||||
import { CrudResource } from "@/shared/data-view/resource-page"
|
import { CrudResource } from "@/shared/data-view/resource-page"
|
||||||
import { ColumnHeader } from "@/shared/data-view/table-view"
|
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||||
import FormDialog from "@/shared/components/form-dialog"
|
import FormDialog from "@/shared/components/form-dialog"
|
||||||
@ -22,6 +23,7 @@ import { Money } from "@/shared/components/money"
|
|||||||
|
|
||||||
export default function JobCardPaymentsReceived() {
|
export default function JobCardPaymentsReceived() {
|
||||||
const jobCard = useJobCard()
|
const jobCard = useJobCard()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const [hasOpened, setHasOpened] = useState(false)
|
const [hasOpened, setHasOpened] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -60,7 +62,12 @@ export default function JobCardPaymentsReceived() {
|
|||||||
invoiceCustomer={jobCard?.customer as any}
|
invoiceCustomer={jobCard?.customer as any}
|
||||||
lockJobCard
|
lockJobCard
|
||||||
lockCustomer
|
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>
|
</FormDialog>
|
||||||
|
|||||||
@ -212,14 +212,14 @@ export function PartForm({ resourceId, initialData, onSuccess }: PartFormProps)
|
|||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<RhfTextField
|
<RhfTextField
|
||||||
name="selling_price"
|
name="selling_price"
|
||||||
label="Selling Price"
|
label="Selling Price (AED)"
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
type="number"
|
type="number"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RhfTextField
|
<RhfTextField
|
||||||
name="purchase_price"
|
name="purchase_price"
|
||||||
label="Purchase Price"
|
label="Purchase Price (AED)"
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
type="number"
|
type="number"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -213,21 +213,24 @@ export function PaymentMadeForm({ resourceId, initialData, onSuccess, billId, ex
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const vendorError = error.validationErrors.vendor_id?.[0]
|
// Surface the actual backend validation messages on each field
|
||||||
const employeeError = error.validationErrors.employee_id?.[0]
|
// instead of blanket-replacing them with the "not both" message —
|
||||||
const billError = error.validationErrors.bill_id?.[0]
|
// 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) {
|
if (billError) {
|
||||||
form.setError("bill", { message: billError })
|
form.setError("bill", { message: billError })
|
||||||
}
|
}
|
||||||
|
if (vendorError) {
|
||||||
if (!vendorError && !employeeError) {
|
form.setError("vendor", { message: vendorError })
|
||||||
return
|
}
|
||||||
|
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: () => {
|
onSuccess: () => {
|
||||||
form.reset()
|
form.reset()
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"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 { Button } from "@/shared/components/ui/button"
|
||||||
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
|
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
|
||||||
@ -49,6 +50,8 @@ const DEFAULT_VALUES: ServiceGroupFormValues = {
|
|||||||
mark_as_recommended: false,
|
mark_as_recommended: false,
|
||||||
set_packaged_pricing: false,
|
set_packaged_pricing: false,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
|
parts: [],
|
||||||
|
services: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mapping helpers ──
|
// ── Mapping helpers ──
|
||||||
@ -60,6 +63,20 @@ const mapLookupOption = (item: any) => ({
|
|||||||
|
|
||||||
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
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 {
|
function mapToFormValues(data: unknown): ServiceGroupFormValues {
|
||||||
const d = (data as any)?.data ?? data ?? {}
|
const d = (data as any)?.data ?? data ?? {}
|
||||||
|
|
||||||
@ -77,6 +94,8 @@ function mapToFormValues(data: unknown): ServiceGroupFormValues {
|
|||||||
mark_as_recommended: d.mark_as_recommended ?? false,
|
mark_as_recommended: d.mark_as_recommended ?? false,
|
||||||
set_packaged_pricing: d.set_packaged_pricing ?? false,
|
set_packaged_pricing: d.set_packaged_pricing ?? false,
|
||||||
is_active: d.is_active ?? true,
|
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,
|
defaultValues: DEFAULT_VALUES,
|
||||||
resourceId,
|
resourceId,
|
||||||
initialData,
|
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,
|
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, {
|
const { mutate, error, isPending } = useFormMutation(form, {
|
||||||
mutationFn: (values: ServiceGroupFormValues) => {
|
mutationFn: (values: ServiceGroupFormValues) => {
|
||||||
const promise = isEditing && resourceId
|
const run = async () => {
|
||||||
? api.serviceGroups.update(resourceId, mapUpdatePayload(values))
|
const groupRes: any =
|
||||||
: api.serviceGroups.create(mapCreatePayload(values))
|
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, {
|
toast.promise(promise, {
|
||||||
loading: isEditing ? "Updating service group..." : "Creating service group...",
|
loading: isEditing ? "Updating package..." : "Creating package...",
|
||||||
success: isEditing ? "Service group updated" : "Service group created",
|
success: isEditing ? "Package updated" : "Package created",
|
||||||
error: isEditing ? "Failed to update service group" : "Failed to create service group",
|
error: isEditing ? "Failed to update package" : "Failed to create package",
|
||||||
})
|
})
|
||||||
return promise
|
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">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<RhfTextField
|
<RhfTextField
|
||||||
name="selling_price"
|
name="selling_price"
|
||||||
label="Selling Price"
|
label="Selling Price (AED)"
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
type="number"
|
type="number"
|
||||||
/>
|
/>
|
||||||
@ -239,6 +327,100 @@ export function ServiceGroupForm({ resourceId, initialData, onSuccess }: Service
|
|||||||
rows={3}
|
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">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<RhfCheckboxField
|
<RhfCheckboxField
|
||||||
name="show_as_lump_sum"
|
name="show_as_lump_sum"
|
||||||
|
|||||||
@ -4,6 +4,20 @@ export const relationFieldSchema = z
|
|||||||
.object({ value: z.string(), label: z.string() })
|
.object({ value: z.string(), label: z.string() })
|
||||||
.nullable()
|
.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({
|
export const serviceGroupFormSchema = z.object({
|
||||||
shop_type: relationFieldSchema,
|
shop_type: relationFieldSchema,
|
||||||
inventory_category: relationFieldSchema,
|
inventory_category: relationFieldSchema,
|
||||||
@ -18,6 +32,9 @@ export const serviceGroupFormSchema = z.object({
|
|||||||
mark_as_recommended: z.boolean().optional(),
|
mark_as_recommended: z.boolean().optional(),
|
||||||
set_packaged_pricing: z.boolean().optional(),
|
set_packaged_pricing: z.boolean().optional(),
|
||||||
is_active: 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>
|
export type ServiceGroupFormValues = z.infer<typeof serviceGroupFormSchema>
|
||||||
|
|||||||
@ -151,15 +151,12 @@ export function SettingsForm() {
|
|||||||
}, [data]) // eslint-disable-line react-hooks/exhaustive-deps
|
}, [data]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const { mutate, error, isPending } = useFormMutation(form, {
|
const { mutate, error, isPending } = useFormMutation(form, {
|
||||||
mutationFn: (values: SettingsFormValues) => {
|
mutationFn: (values: SettingsFormValues) => api.settings.update(mapFormToPayload(values)),
|
||||||
const payload = mapFormToPayload(values)
|
onSuccess: () => {
|
||||||
const promise = api.settings.update(payload)
|
toast.success("Settings saved successfully")
|
||||||
toast.promise(promise, {
|
},
|
||||||
loading: "Saving settings...",
|
onError: () => {
|
||||||
success: "Settings saved successfully",
|
toast.error("Failed to save settings")
|
||||||
error: "Failed to save settings",
|
|
||||||
})
|
|
||||||
return promise
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
RhfTextField,
|
RhfTextField,
|
||||||
RhfTextareaField,
|
RhfTextareaField,
|
||||||
RhfAsyncSelectField,
|
RhfAsyncSelectField,
|
||||||
|
RhfCheckboxField,
|
||||||
RhfImageField,
|
RhfImageField,
|
||||||
} from "@/shared/components/form"
|
} from "@/shared/components/form"
|
||||||
import { ShopTypeInlineForm } from "./inline-forms/shop-type-inline-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 { useFormMutation } from "@/shared/hooks/use-form-mutation"
|
||||||
import { toRelation, toId } from "@/shared/lib/utils"
|
import { toRelation, toId } from "@/shared/lib/utils"
|
||||||
import { formatUppercase } from "@/shared/utils/formatters"
|
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 { vehicleFormSchema, type VehicleFormValues } from "./vehicle.schema"
|
||||||
import { CustomerForm } from "../customers/customer-form"
|
import { CustomerForm } from "../customers/customer-form"
|
||||||
@ -57,6 +58,8 @@ const DEFAULT_VALUES: VehicleFormValues = {
|
|||||||
drivetrain: "",
|
drivetrain: "",
|
||||||
mileage: "",
|
mileage: "",
|
||||||
note: "",
|
note: "",
|
||||||
|
has_insurance: false,
|
||||||
|
insurance_type: null,
|
||||||
image: null,
|
image: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -92,6 +95,8 @@ function mapToFormValues(data: unknown): VehicleFormValues {
|
|||||||
drivetrain: toFormString(d.drivetrain),
|
drivetrain: toFormString(d.drivetrain),
|
||||||
mileage: toFormString(d.mileage),
|
mileage: toFormString(d.mileage),
|
||||||
note: toFormString(d.note),
|
note: toFormString(d.note),
|
||||||
|
has_insurance: Boolean(d.has_insurance),
|
||||||
|
insurance_type: toRelation(d.insurance_type_id, d.insurance_type?.title),
|
||||||
image: null,
|
image: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -115,6 +120,8 @@ function mapToPayload(values: VehicleFormValues) {
|
|||||||
drivetrain: values.drivetrain || undefined,
|
drivetrain: values.drivetrain || undefined,
|
||||||
mileage: values.mileage || undefined,
|
mileage: values.mileage || undefined,
|
||||||
note: values.note || 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,
|
image: values.image instanceof File ? values.image : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -137,6 +144,8 @@ export function VehicleForm({ resourceId, initialData, onSuccess }: VehicleFormP
|
|||||||
const existingImageUrl: string | null =
|
const existingImageUrl: string | null =
|
||||||
(resolvedData as any)?.data?.image_url ?? (resolvedData as any)?.image_url ?? 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, {
|
const { mutate, error, isPending } = useFormMutation(form, {
|
||||||
mutationFn: (values: VehicleFormValues) => {
|
mutationFn: (values: VehicleFormValues) => {
|
||||||
const payload = mapToPayload(values)
|
const payload = mapToPayload(values)
|
||||||
@ -254,6 +263,24 @@ export function VehicleForm({ resourceId, initialData, onSuccess }: VehicleFormP
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* License & identifiers */}
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<RhfTextField
|
<RhfTextField
|
||||||
|
|||||||
@ -31,6 +31,10 @@ export const vehicleFormSchema = z.object({
|
|||||||
// ── Notes ──
|
// ── Notes ──
|
||||||
note: z.string().optional(),
|
note: z.string().optional(),
|
||||||
|
|
||||||
|
// ── Insurance ──
|
||||||
|
has_insurance: z.boolean().optional(),
|
||||||
|
insurance_type: relationFieldSchema.optional(),
|
||||||
|
|
||||||
// ── Image ──
|
// ── Image ──
|
||||||
image: z.any().optional(),
|
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 {
|
import {
|
||||||
Rhform,
|
Rhform,
|
||||||
RhfTextField,
|
RhfTextField,
|
||||||
|
RhfAsyncSelectField,
|
||||||
} from "@/shared/components/form"
|
} from "@/shared/components/form"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { useAuthApi } from "@/shared/useApi"
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
import { useResourceForm } from "@/shared/hooks/use-resource-form"
|
import { useResourceForm } from "@/shared/hooks/use-resource-form"
|
||||||
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
|
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
|
||||||
|
import { toId, toRelation } from "@/shared/lib/utils"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
vendorFormSchema,
|
vendorFormSchema,
|
||||||
@ -31,24 +33,44 @@ export type VendorFormProps = {
|
|||||||
// ── Default values ──
|
// ── Default values ──
|
||||||
|
|
||||||
const DEFAULT_VALUES: VendorFormValues = {
|
const DEFAULT_VALUES: VendorFormValues = {
|
||||||
salutation:"Mr.",
|
salutation: "Mr.",
|
||||||
first_name: "",
|
first_name: "",
|
||||||
last_name: "",
|
last_name: "",
|
||||||
company_name: "",
|
company_name: "",
|
||||||
email: "",
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
alternate_phone: "",
|
||||||
|
address_line_1: "",
|
||||||
|
address_line_2: "",
|
||||||
|
city: "",
|
||||||
|
zip_code: "",
|
||||||
|
country: null,
|
||||||
|
state: null,
|
||||||
} as any
|
} as any
|
||||||
|
|
||||||
// ── Mapping helpers ──
|
// ── 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 {
|
function mapToFormValues(data: unknown): VendorFormValues {
|
||||||
const d = (data as any)?.data ?? data ?? {}
|
const d = (data as any)?.data ?? data ?? {}
|
||||||
|
const address = Array.isArray(d.addresses) ? d.addresses[0] ?? {} : {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
first_name: d.first_name || "",
|
first_name: d.first_name || "",
|
||||||
last_name: d.last_name || "",
|
last_name: d.last_name || "",
|
||||||
company_name: d.company_name || "",
|
company_name: d.company_name || "",
|
||||||
email: d.email || "",
|
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) {
|
function mapFormToPayload(values: VendorFormValues) {
|
||||||
@ -57,6 +79,14 @@ function mapFormToPayload(values: VendorFormValues) {
|
|||||||
last_name: values.last_name || undefined,
|
last_name: values.last_name || undefined,
|
||||||
company_name: values.company_name || undefined,
|
company_name: values.company_name || undefined,
|
||||||
email: values.email || 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="company_name" label="Company Name" placeholder="Acme Supplies" required />
|
||||||
<RhfTextField name="email" label="Email" placeholder="vendor@example.com" type="email" 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}>
|
<Button type="submit" variant="default" disabled={isPending}>
|
||||||
{isEditing ? <Save /> : <Plus />}
|
{isEditing ? <Save /> : <Plus />}
|
||||||
{isPending
|
{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(),
|
z.coerce.number().int().optional(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const relationFieldSchema = z
|
||||||
|
.object({ value: z.string(), label: z.string() })
|
||||||
|
.nullable()
|
||||||
|
.optional()
|
||||||
|
|
||||||
const vendorFormSchema = z.object({
|
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"),
|
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"),
|
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,
|
opening_balance: optionalNumberSchema,
|
||||||
credit_limit: optionalNumberSchema,
|
credit_limit: optionalNumberSchema,
|
||||||
website: optionalStringMaxSchema(255),
|
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>
|
type VendorFormValues = z.infer<typeof vendorFormSchema>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user