- 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>
133 lines
5.7 KiB
TypeScript
133 lines
5.7 KiB
TypeScript
"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>
|
||
)
|
||
}
|