WorkBoardClient.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. "use client";
  2. import { useState, useEffect, useCallback } from "react";
  3. import { useSearchParams, useRouter, usePathname } from "next/navigation";
  4. import { DndContext, DragOverlay, type DragEndEvent, type DragStartEvent, PointerSensor, TouchSensor, KeyboardSensor, useSensor, useSensors } from "@dnd-kit/core";
  5. import { toast } from "sonner";
  6. import { useWorkBoardStore, type Technician } from "../store/workboardStore";
  7. import { useWorkBoardWebSocket } from "../hooks/useWorkBoardWebSocket";
  8. import type { WorkBoardJob } from "../Actions/boardActions";
  9. import {
  10. getBoardJobs,
  11. getUnassignedJobs,
  12. assignTechnician,
  13. moveJob,
  14. unassignJob,
  15. } from "../Actions/boardActions";
  16. import { WorkBoardToolbar, type BoardView } from "./WorkBoardToolbar";
  17. import { DayTimeline } from "./DayTimeline";
  18. import type { WorkBoardSettings } from "../Actions/boardActions";
  19. import { TechnicianRow } from "./TechnicianRow";
  20. import { UnassignedJobsPanel } from "./UnassignedJobsPanel";
  21. import { TechnicianDialog } from "./TechnicianDialog";
  22. import { JobDetailPopover } from "./JobDetailPopover";
  23. import { BoardJobCard } from "./BoardJobCard";
  24. import { ScrollArea } from "@/components/ui/scroll-area";
  25. import { Users, Wrench, ClipboardCheck } from "lucide-react";
  26. import { useTranslations } from "next-intl";
  27. import { VehiclePickerDialog } from "@/components/vehicle-picker-dialog";
  28. import { getVehicles } from "@/features/vehicles/Actions/vehicleActions";
  29. import { getCustomersList } from "@/features/customers/Actions/customerActions";
  30. function toLocalDateString(d: Date): string {
  31. return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
  32. }
  33. function getWeekStartDate(date: Date, weekStartDay: number): string {
  34. const d = new Date(date);
  35. const diff = (d.getDay() - weekStartDay + 7) % 7;
  36. d.setDate(d.getDate() - diff);
  37. return toLocalDateString(d);
  38. }
  39. function getWeekDays(weekStart: string): string[] {
  40. const days: string[] = [];
  41. for (let i = 0; i < 7; i++) {
  42. const d = new Date(weekStart + "T12:00:00");
  43. d.setDate(d.getDate() + i);
  44. days.push(toLocalDateString(d));
  45. }
  46. return days;
  47. }
  48. const ALL_DAY_KEYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
  49. function UnassignedJobOverlayCard({ job, type }: { job: Record<string, unknown>; type: "serviceRecord" | "inspection" }) {
  50. const isServiceRecord = type === "serviceRecord";
  51. const vehicle = job.vehicle as { year: number; make: string; model: string; licensePlate: string | null } | undefined;
  52. const title = isServiceRecord ? (job.title as string) : ((job.template as { name: string })?.name ?? "");
  53. return (
  54. <div className="flex items-start gap-1 rounded-md border bg-card p-1.5 text-xs shadow-md">
  55. <div className="min-w-0 flex-1">
  56. <div className="flex items-center gap-1">
  57. {isServiceRecord ? <Wrench className="h-3 w-3 shrink-0 text-blue-500" /> : <ClipboardCheck className="h-3 w-3 shrink-0 text-green-500" />}
  58. <span className="truncate font-medium">{title}</span>
  59. </div>
  60. {vehicle && <p className="truncate text-muted-foreground">{vehicle.year} {vehicle.make} {vehicle.model}{vehicle.licensePlate ? ` · ${vehicle.licensePlate}` : ""}</p>}
  61. </div>
  62. </div>
  63. );
  64. }
  65. export function WorkBoardClient({
  66. initialTechnicians,
  67. initialAssignments,
  68. initialUnassigned,
  69. initialWeekStart,
  70. boardSettings,
  71. }: {
  72. initialTechnicians: Technician[];
  73. initialAssignments: WorkBoardJob[];
  74. initialUnassigned: { serviceRecords: unknown[]; inspections: unknown[] };
  75. initialWeekStart: string;
  76. boardSettings: WorkBoardSettings;
  77. }) {
  78. const store = useWorkBoardStore();
  79. const t = useTranslations("workBoard.board");
  80. const searchParams = useSearchParams();
  81. const router = useRouter();
  82. const pathname = usePathname();
  83. const urlView = searchParams.get("view") as BoardView | null;
  84. const urlDate = searchParams.get("date");
  85. const urlWeek = searchParams.get("week");
  86. useEffect(() => {
  87. store.setTechnicians(initialTechnicians);
  88. store.setJobs(initialAssignments);
  89. store.setUnassigned(initialUnassigned.serviceRecords as Parameters<typeof store.setUnassigned>[0], initialUnassigned.inspections as Parameters<typeof store.setUnassigned>[1]);
  90. store.setWeekStart(urlWeek || initialWeekStart);
  91. if (urlWeek && urlWeek !== initialWeekStart) {
  92. getBoardJobs(urlWeek).then((res) => { if (res.success && res.data) store.setJobs(res.data as WorkBoardJob[]); });
  93. }
  94. // eslint-disable-next-line react-hooks/exhaustive-deps
  95. }, []);
  96. useWorkBoardWebSocket();
  97. const [view, setViewState] = useState<BoardView>(urlView === "day" || urlView === "week" ? urlView : "week");
  98. const [selectedDate, setSelectedDateState] = useState(urlDate || toLocalDateString(new Date()));
  99. const [techDialogOpen, setTechDialogOpen] = useState(false);
  100. const [editingTech, setEditingTech] = useState<Technician | null>(null);
  101. const [selectedJob, setSelectedJob] = useState<WorkBoardJob | null>(null);
  102. const [popoverOpen, setPopoverOpen] = useState(false);
  103. const [activeDrag, setActiveDrag] = useState<{ id: string; job?: WorkBoardJob; unassignedJob?: { job: Record<string, unknown>; type: "serviceRecord" | "inspection" } } | null>(null);
  104. const [vehiclePickerOpen, setVehiclePickerOpen] = useState(false);
  105. const [vehiclePickerVehicles, setVehiclePickerVehicles] = useState<{ id: string; make: string; model: string; year: number; licensePlate: string | null; customer: { id: string; name: string; company: string | null } | null }[]>([]);
  106. const [vehiclePickerCustomers, setVehiclePickerCustomers] = useState<{ id: string; name: string; company: string | null }[]>([]);
  107. const [boardContext, setBoardContext] = useState<Record<string, string>>({});
  108. const weekStart = store.weekStart || initialWeekStart;
  109. const days = getWeekDays(weekStart);
  110. const sensors = useSensors(
  111. useSensor(PointerSensor, {
  112. activationConstraint: { distance: 5 },
  113. }),
  114. useSensor(TouchSensor, {
  115. activationConstraint: { delay: 200, tolerance: 5 },
  116. }),
  117. useSensor(KeyboardSensor)
  118. );
  119. const updateUrl = useCallback((newView: BoardView, newDate: string, newWeekStart: string) => {
  120. const params = new URLSearchParams(); params.set("view", newView); params.set("date", newDate); params.set("week", newWeekStart);
  121. router.replace(`${pathname}?${params.toString()}`, { scroll: false });
  122. }, [router, pathname]);
  123. const setView = useCallback((v: BoardView) => { setViewState(v); updateUrl(v, selectedDate, weekStart); }, [selectedDate, weekStart, updateUrl]);
  124. const setSelectedDate = useCallback((d: string) => { setSelectedDateState(d); updateUrl(view, d, weekStart); }, [view, weekStart, updateUrl]);
  125. const loadWeekData = useCallback(async (ws: string) => {
  126. store.setWeekStart(ws); updateUrl(view, selectedDate, ws);
  127. const [assignRes, unassignedRes] = await Promise.all([getBoardJobs(ws), getUnassignedJobs()]);
  128. if (assignRes.success && assignRes.data) store.setJobs(assignRes.data as WorkBoardJob[]);
  129. if (unassignedRes.success && unassignedRes.data) store.setUnassigned(unassignedRes.data.serviceRecords as Parameters<typeof store.setUnassigned>[0], unassignedRes.data.inspections as Parameters<typeof store.setUnassigned>[1]);
  130. }, [store, view, selectedDate, updateUrl]);
  131. const handlePrevWeek = () => { const d = new Date(weekStart + "T12:00:00"); d.setDate(d.getDate() - 7); loadWeekData(toLocalDateString(d)); };
  132. const handleNextWeek = () => { const d = new Date(weekStart + "T12:00:00"); d.setDate(d.getDate() + 7); loadWeekData(toLocalDateString(d)); };
  133. const handleToday = () => { setSelectedDate(toLocalDateString(new Date())); loadWeekData(getWeekStartDate(new Date(), boardSettings.weekStartDay)); };
  134. const handlePrevDay = () => { const d = new Date(selectedDate + "T12:00:00"); d.setDate(d.getDate() - 1); const nd = toLocalDateString(d); setSelectedDate(nd); const nw = getWeekStartDate(d, boardSettings.weekStartDay); if (nw !== weekStart) loadWeekData(nw); };
  135. const handleNextDay = () => { const d = new Date(selectedDate + "T12:00:00"); d.setDate(d.getDate() + 1); const nd = toLocalDateString(d); setSelectedDate(nd); const nw = getWeekStartDate(d, boardSettings.weekStartDay); if (nw !== weekStart) loadWeekData(nw); };
  136. const handleCreateWorkOrder = async (techId: string, startTime: string, endTime: string) => {
  137. setBoardContext({ boardTech: techId, boardDate: selectedDate, boardStart: startTime, boardEnd: endTime });
  138. const [vehiclesResult, customersResult] = await Promise.all([getVehicles(), getCustomersList()]);
  139. setVehiclePickerVehicles(vehiclesResult.success && vehiclesResult.data ? vehiclesResult.data.map((v) => ({ id: v.id, make: v.make, model: v.model, year: v.year, licensePlate: v.licensePlate, customer: v.customer })) : []);
  140. setVehiclePickerCustomers(customersResult.success && customersResult.data ? customersResult.data : []);
  141. setVehiclePickerOpen(true);
  142. };
  143. const handleDragStart = (event: DragStartEvent) => {
  144. const data = event.active.data.current;
  145. if (data?.job && data.job.type) setActiveDrag({ id: event.active.id as string, job: data.job });
  146. else if (data?.job && data?.type) setActiveDrag({ id: event.active.id as string, unassignedJob: { job: data.job, type: data.type as "serviceRecord" | "inspection" } });
  147. else setActiveDrag({ id: event.active.id as string });
  148. };
  149. const handleDragEnd = async (event: DragEndEvent) => {
  150. setActiveDrag(null);
  151. const { active, over } = event;
  152. if (!over) return;
  153. const overData = over.data.current as { technicianId: string; date: string } | undefined;
  154. if (!overData?.technicianId) return;
  155. const activeData = active.data.current;
  156. if (activeData?.job && activeData.job.type) {
  157. const job = activeData.job as WorkBoardJob;
  158. if (job.technicianId === overData.technicianId) return;
  159. store.optimisticMove(job.id, overData.technicianId);
  160. const res = await moveJob({ id: job.id, technicianId: overData.technicianId, sortOrder: 0, type: job.type });
  161. if (!res.success) { toast.error(t("failedMove"), { description: res.error }); store.optimisticMove(job.id, job.technicianId!); }
  162. else if (res.data) store.updateJob(res.data as WorkBoardJob);
  163. return;
  164. }
  165. if (activeData?.job && activeData?.type) {
  166. const job = activeData.job;
  167. const jobType = activeData.type as "serviceRecord" | "inspection";
  168. // Set default time on the drop target's date (1 hour block at work day start)
  169. const dropDate = overData.date || selectedDate;
  170. const startDateTime = new Date(dropDate + "T" + boardSettings.workDayStart + ":00");
  171. const endDateTime = new Date(startDateTime.getTime() + 60 * 60 * 1000);
  172. store.removeFromUnassigned(job.id, jobType);
  173. const res = await assignTechnician({ id: job.id, technicianId: overData.technicianId, type: jobType, startDateTime, endDateTime });
  174. if (!res.success) { toast.error(t("failedAssign"), { description: res.error }); store.addToUnassigned(job, jobType); }
  175. else if (res.data) store.addJob(res.data as WorkBoardJob);
  176. }
  177. };
  178. const handleRemoveJob = async (job: WorkBoardJob) => {
  179. setPopoverOpen(false); setSelectedJob(null);
  180. store.removeJob(job.id);
  181. const res = await unassignJob({ id: job.id, type: job.type });
  182. if (!res.success) { toast.error(t("failedRemove"), { description: res.error }); store.addJob(job); }
  183. else { const unRes = await getUnassignedJobs(); if (unRes.success && unRes.data) store.setUnassigned(unRes.data.serviceRecords as Parameters<typeof store.setUnassigned>[0], unRes.data.inspections as Parameters<typeof store.setUnassigned>[1]); }
  184. };
  185. const handleCardClick = (a: WorkBoardJob) => { setSelectedJob(a); setPopoverOpen(true); };
  186. if (store.technicians.length === 0) {
  187. return (
  188. <div className="flex flex-1 flex-col">
  189. <WorkBoardToolbar weekStart={weekStart} selectedDate={selectedDate} view={view} onPrevWeek={handlePrevWeek} onNextWeek={handleNextWeek} onPrevDay={handlePrevDay} onNextDay={handleNextDay} onToday={handleToday} onAddTech={() => setTechDialogOpen(true)} onViewChange={setView} />
  190. <div className="flex flex-1 flex-col items-center justify-center gap-4 py-20">
  191. <Users className="h-12 w-12 text-muted-foreground/40" />
  192. <div className="text-center"><h3 className="text-lg font-medium">{t("noTechnicians")}</h3><p className="text-sm text-muted-foreground">{t("noTechniciansDescription")}</p></div>
  193. </div>
  194. <TechnicianDialog open={techDialogOpen} onOpenChange={setTechDialogOpen} />
  195. </div>
  196. );
  197. }
  198. const dragOverlay = activeDrag?.job ? (
  199. <div className="w-48 opacity-90"><BoardJobCard job={activeDrag.job} /></div>
  200. ) : activeDrag?.unassignedJob ? (
  201. <div className="w-48 opacity-90"><UnassignedJobOverlayCard job={activeDrag.unassignedJob.job} type={activeDrag.unassignedJob.type} /></div>
  202. ) : null;
  203. const gridTemplateColumns = `140px ${days
  204. .map((d) =>
  205. d === toLocalDateString(new Date()) ? "minmax(0,2fr)" : "minmax(0,1fr)",
  206. )
  207. .join(" ")}`;
  208. return (
  209. <div className="flex flex-1 flex-col gap-4">
  210. <WorkBoardToolbar weekStart={weekStart} selectedDate={selectedDate} view={view} onPrevWeek={handlePrevWeek} onNextWeek={handleNextWeek} onPrevDay={handlePrevDay} onNextDay={handleNextDay} onToday={handleToday} onAddTech={() => { setEditingTech(null); setTechDialogOpen(true); }} onViewChange={setView} />
  211. <DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
  212. <div className="flex flex-1 gap-4 overflow-hidden">
  213. <ScrollArea className="flex-1">
  214. {view === "day" ? (
  215. <DayTimeline date={selectedDate} technicians={store.technicians} assignments={store.jobs} workDayStart={boardSettings.workDayStart} workDayEnd={boardSettings.workDayEnd} onCardClick={handleCardClick} onTechClick={(t) => { setEditingTech(t); setTechDialogOpen(true); }} onCreateWorkOrder={handleCreateWorkOrder} />
  216. ) : (
  217. <div className="min-w-[900px] grid gap-1" style={{ gridTemplateColumns }}>
  218. <div />
  219. {days.map((day) => {
  220. const isToday = day === toLocalDateString(new Date());
  221. const d = new Date(day + "T12:00:00");
  222. const dayKey = ALL_DAY_KEYS[d.getDay()];
  223. return <div key={day} className={`rounded-md px-2 py-1 text-center text-xs font-medium ${isToday ? "bg-primary text-primary-foreground" : "text-muted-foreground"}`}>{t(`days.${dayKey}`)} {d.getDate()}</div>;
  224. })}
  225. {store.technicians.map((tech) => (
  226. <TechnicianRow key={tech.id} technician={tech} days={days} assignments={store.jobs} onCardClick={handleCardClick} onTechClick={(t) => { setEditingTech(t); setTechDialogOpen(true); }} />
  227. ))}
  228. </div>
  229. )}
  230. </ScrollArea>
  231. <UnassignedJobsPanel />
  232. </div>
  233. <DragOverlay dropAnimation={null}>{dragOverlay}</DragOverlay>
  234. </DndContext>
  235. {selectedJob && (
  236. <JobDetailPopover job={selectedJob} open={popoverOpen} onOpenChange={(o) => { setPopoverOpen(o); if (!o) setSelectedJob(null); }} onRemove={() => handleRemoveJob(selectedJob)} />
  237. )}
  238. <TechnicianDialog open={techDialogOpen} onOpenChange={setTechDialogOpen} technician={editingTech} />
  239. <VehiclePickerDialog open={vehiclePickerOpen} onOpenChange={(open) => { setVehiclePickerOpen(open); if (!open) setBoardContext({}); }} vehicles={vehiclePickerVehicles} customers={vehiclePickerCustomers} redirectQuery={boardContext} />
  240. </div>
  241. );
  242. }