BoardJobCard.tsx 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. "use client";
  2. import { interactiveRow } from "@/lib/interactive-row";
  3. import { useDraggable } from "@dnd-kit/core";
  4. import { CSS } from "@dnd-kit/utilities";
  5. import { Clock, GripVertical, Wrench, ClipboardCheck } from "lucide-react";
  6. import type { WorkBoardJob } from "../Actions/boardActions";
  7. import { getJobDateRange, getDurationMinutes } from "../utils/datetime";
  8. import { formatDuration } from "./DurationSlider";
  9. export function BoardJobCard({
  10. job,
  11. onClick,
  12. }: {
  13. job: WorkBoardJob;
  14. onClick?: () => void;
  15. }) {
  16. const { attributes, listeners, setNodeRef, transform, isDragging } =
  17. useDraggable({
  18. id: job.id,
  19. data: { job },
  20. });
  21. const style = transform
  22. ? {
  23. transform: CSS.Translate.toString(transform),
  24. zIndex: isDragging ? 50 : undefined,
  25. opacity: isDragging ? 0.5 : undefined,
  26. }
  27. : undefined;
  28. const isServiceRecord = job.type === "serviceRecord";
  29. const { start, end } = getJobDateRange(job);
  30. const durationMins = start && end ? getDurationMinutes(start, end) : null;
  31. return (
  32. <div
  33. ref={setNodeRef}
  34. style={style}
  35. className="group flex cursor-pointer items-start gap-1 rounded-md border bg-card p-1.5 text-xs shadow-sm transition-shadow hover:shadow-md"
  36. {...(onClick ? interactiveRow(onClick) : {})}
  37. >
  38. <button
  39. {...listeners}
  40. {...attributes}
  41. className="mt-0.5 shrink-0 cursor-grab touch-none text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 active:cursor-grabbing"
  42. onClick={(e) => e.stopPropagation()}
  43. >
  44. <GripVertical className="h-3 w-3" />
  45. </button>
  46. <div className="min-w-0 flex-1">
  47. <div className="flex items-center gap-1">
  48. {isServiceRecord ? (
  49. <Wrench className="h-3 w-3 shrink-0 text-blue-500" />
  50. ) : (
  51. <ClipboardCheck className="h-3 w-3 shrink-0 text-green-500" />
  52. )}
  53. <span className="truncate font-medium">{job.title}</span>
  54. </div>
  55. {job.vehicle && (
  56. <p className="truncate text-muted-foreground">
  57. {job.vehicle.year} {job.vehicle.make} {job.vehicle.model}
  58. {job.vehicle.licensePlate ? ` · ${job.vehicle.licensePlate}` : ""}
  59. </p>
  60. )}
  61. <div className="flex items-center gap-1.5">
  62. {job.status && (
  63. <span className="inline-block rounded bg-muted px-1 py-0.5 text-[10px] capitalize">
  64. {job.status.replace(/_/g, " ")}
  65. </span>
  66. )}
  67. {durationMins != null && durationMins > 0 && (
  68. <span className="inline-flex items-center gap-0.5 rounded bg-blue-500/10 px-1 py-0.5 text-[10px] text-blue-600 dark:text-blue-400">
  69. <Clock className="h-2.5 w-2.5" />
  70. {formatDuration(durationMins)}
  71. </span>
  72. )}
  73. </div>
  74. </div>
  75. </div>
  76. );
  77. }
  78. export function UnassignedJobCard({
  79. job,
  80. type,
  81. }: {
  82. job:
  83. | {
  84. id: string;
  85. title: string;
  86. status: string;
  87. vehicle: { id: string; make: string; model: string; year: number; licensePlate: string | null } | null;
  88. }
  89. | {
  90. id: string;
  91. status: string;
  92. vehicle: { id: string; make: string; model: string; year: number; licensePlate: string | null };
  93. template: { name: string };
  94. };
  95. type: "serviceRecord" | "inspection";
  96. }) {
  97. const dragId =
  98. type === "serviceRecord" ? `unassigned-sr-${job.id}` : `unassigned-insp-${job.id}`;
  99. const { attributes, listeners, setNodeRef, transform, isDragging } =
  100. useDraggable({
  101. id: dragId,
  102. data: { job, type },
  103. });
  104. const style = transform
  105. ? {
  106. transform: CSS.Translate.toString(transform),
  107. zIndex: isDragging ? 50 : undefined,
  108. opacity: isDragging ? 0.5 : undefined,
  109. }
  110. : undefined;
  111. const isServiceRecord = type === "serviceRecord";
  112. const title = isServiceRecord
  113. ? (job as { title: string }).title
  114. : (job as { template: { name: string } }).template.name;
  115. return (
  116. <div
  117. ref={setNodeRef}
  118. style={style}
  119. {...listeners}
  120. {...attributes}
  121. className="flex cursor-grab items-start gap-1 rounded-md border bg-card p-1.5 text-xs shadow-sm touch-none active:cursor-grabbing"
  122. >
  123. <div className="min-w-0 flex-1">
  124. <div className="flex items-center gap-1">
  125. {isServiceRecord ? (
  126. <Wrench className="h-3 w-3 shrink-0 text-blue-500" />
  127. ) : (
  128. <ClipboardCheck className="h-3 w-3 shrink-0 text-green-500" />
  129. )}
  130. <span className="truncate font-medium">{title}</span>
  131. </div>
  132. {job.vehicle && (
  133. <p className="truncate text-muted-foreground">
  134. {job.vehicle.year} {job.vehicle.make} {job.vehicle.model}
  135. {job.vehicle.licensePlate ? ` · ${job.vehicle.licensePlate}` : ""}
  136. </p>
  137. )}
  138. </div>
  139. </div>
  140. );
  141. }