Bernt Christian Egeland 7 месяцев назад
Родитель
Сommit
e7be143b32

+ 9 - 0
prisma/migrations/20260220161344_dvi_inspection_images/migration.sql

@@ -0,0 +1,9 @@
+/*
+  Warnings:
+
+  - You are about to drop the column `imageUrl` on the `inspection_items` table. All the data in the column will be lost.
+
+*/
+-- AlterTable
+ALTER TABLE "inspection_items" DROP COLUMN "imageUrl",
+ADD COLUMN     "imageUrls" TEXT[] DEFAULT ARRAY[]::TEXT[];

+ 4 - 4
prisma/schema.prisma

@@ -732,13 +732,13 @@ model Inspection {
 }
 
 model InspectionItem {
-  id        String  @id @default(cuid())
+  id        String   @id @default(cuid())
   name      String
   section   String
-  sortOrder Int     @default(0)
-  condition String  @default("not_inspected")
+  sortOrder Int      @default(0)
+  condition String   @default("not_inspected")
   notes     String?
-  imageUrl  String?
+  imageUrls String[] @default([])
 
   inspectionId String
   inspection   Inspection @relation(fields: [inspectionId], references: [id], onDelete: Cascade)

+ 120 - 0
src/app/(authenticated)/dashboard-client.tsx

@@ -18,6 +18,8 @@ import { statusColors } from "@/lib/table-utils";
 import {
   AlertTriangle,
   Bell,
+  Check,
+  ClipboardCheck,
   ClipboardList,
   Clock,
   DollarSign,
@@ -86,18 +88,37 @@ interface VehicleDueForService {
   confidencePercent: number;
 }
 
+interface DashboardInspection {
+  id: string;
+  status: string;
+  createdAt: Date;
+  vehicle: {
+    id: string;
+    make: string;
+    model: string;
+    year: number;
+    licensePlate: string | null;
+  };
+  template: { id: string; name: string };
+  items: { id: string; condition: string }[];
+}
+
 export function DashboardClient({
   stats,
   currencyCode = "USD",
   upcomingReminders = [],
   vehiclesDueForService = [],
   unitSystem = "imperial",
+  inProgressInspections = [],
+  completedInspections = [],
 }: {
   stats: DashboardStats;
   currencyCode?: string;
   upcomingReminders?: ReminderItem[];
   vehiclesDueForService?: VehicleDueForService[];
   unitSystem?: "metric" | "imperial";
+  inProgressInspections?: DashboardInspection[];
+  completedInspections?: DashboardInspection[];
 }) {
   const distUnit = unitSystem === "metric" ? "km" : "mi";
   const router = useRouter();
@@ -309,6 +330,105 @@ export function DashboardClient({
         </Card>
       )}
 
+      {/* Inspections */}
+      {(inProgressInspections.length > 0 || completedInspections.length > 0) && (
+        <Card className="border-0 shadow-sm">
+          <CardHeader className="pb-3">
+            <CardTitle className="flex items-center gap-2 text-base">
+              <ClipboardCheck className="h-4 w-4" />
+              Inspections
+            </CardTitle>
+          </CardHeader>
+          <CardContent className="p-0">
+            <div className="divide-y">
+              {inProgressInspections.map((insp) => {
+                const total = insp.items.length;
+                const pass = insp.items.filter((i) => i.condition === "pass").length;
+                const fail = insp.items.filter((i) => i.condition === "fail").length;
+                const attention = insp.items.filter((i) => i.condition === "attention").length;
+                const inspected = pass + fail + attention;
+
+                return (
+                  <div
+                    key={insp.id}
+                    className="flex items-center justify-between px-5 py-3 cursor-pointer hover:bg-muted/50 transition-colors"
+                    onClick={() => router.push(`/inspections/${insp.id}`)}
+                  >
+                    <div className="flex items-center gap-3 min-w-0">
+                      <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-blue-500/10">
+                        <ClipboardCheck className="h-4 w-4 text-blue-500" />
+                      </div>
+                      <div className="min-w-0">
+                        <p className="font-medium text-sm truncate">
+                          {insp.vehicle.year} {insp.vehicle.make} {insp.vehicle.model}
+                        </p>
+                        <p className="text-xs text-muted-foreground truncate">
+                          {insp.vehicle.licensePlate && `${insp.vehicle.licensePlate} · `}
+                          {insp.template.name} · {inspected}/{total} items
+                        </p>
+                      </div>
+                    </div>
+                    <div className="shrink-0 ml-3 flex items-center gap-2">
+                      {total > 0 && (
+                        <div className="hidden sm:flex h-2 w-20 overflow-hidden rounded-full bg-gray-200">
+                          <div className="bg-emerald-500" style={{ width: `${(pass / total) * 100}%` }} />
+                          <div className="bg-red-500" style={{ width: `${(fail / total) * 100}%` }} />
+                          <div className="bg-amber-500" style={{ width: `${(attention / total) * 100}%` }} />
+                        </div>
+                      )}
+                      <Badge className="bg-blue-500/15 text-blue-600 border-blue-500/20 text-[10px]">
+                        In Progress
+                      </Badge>
+                    </div>
+                  </div>
+                );
+              })}
+              {completedInspections.map((insp) => {
+                const total = insp.items.length;
+                const pass = insp.items.filter((i) => i.condition === "pass").length;
+                const fail = insp.items.filter((i) => i.condition === "fail").length;
+                const attention = insp.items.filter((i) => i.condition === "attention").length;
+
+                return (
+                  <div
+                    key={insp.id}
+                    className="flex items-center justify-between px-5 py-3 cursor-pointer hover:bg-muted/50 transition-colors"
+                    onClick={() => router.push(`/inspections/${insp.id}`)}
+                  >
+                    <div className="flex items-center gap-3 min-w-0">
+                      <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-emerald-500/10">
+                        <Check className="h-4 w-4 text-emerald-500" />
+                      </div>
+                      <div className="min-w-0">
+                        <p className="font-medium text-sm truncate">
+                          {insp.vehicle.year} {insp.vehicle.make} {insp.vehicle.model}
+                        </p>
+                        <p className="text-xs text-muted-foreground truncate">
+                          {insp.vehicle.licensePlate && `${insp.vehicle.licensePlate} · `}
+                          {insp.template.name} · {formatDate(new Date(insp.createdAt))}
+                        </p>
+                      </div>
+                    </div>
+                    <div className="shrink-0 ml-3 flex items-center gap-2">
+                      {total > 0 && (
+                        <div className="hidden sm:flex h-2 w-20 overflow-hidden rounded-full bg-gray-200">
+                          <div className="bg-emerald-500" style={{ width: `${(pass / total) * 100}%` }} />
+                          <div className="bg-red-500" style={{ width: `${(fail / total) * 100}%` }} />
+                          <div className="bg-amber-500" style={{ width: `${(attention / total) * 100}%` }} />
+                        </div>
+                      )}
+                      <Badge className="bg-emerald-500/15 text-emerald-600 border-emerald-500/20 text-[10px]">
+                        Completed
+                      </Badge>
+                    </div>
+                  </div>
+                );
+              })}
+            </div>
+          </CardContent>
+        </Card>
+      )}
+
       {/* Recent Completed table */}
       <Card className="border-0 shadow-sm">
         <CardHeader className="pb-3">

+ 6 - 1
src/app/(authenticated)/page.tsx

@@ -2,15 +2,18 @@ import { getDashboardStats, getUpcomingReminders } from "@/features/vehicles/Act
 import { getSettings } from "@/features/settings/Actions/settingsActions";
 import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
 import { getVehiclesDueForService } from "@/features/vehicles/Actions/predictedMaintenanceActions";
+import { getInspectionsPaginated } from "@/features/inspections/Actions/inspectionActions";
 import { DashboardClient } from "./dashboard-client";
 import { PageHeader } from "@/components/page-header";
 
 export default async function DashboardPage() {
-  const [result, settingsResult, remindersResult, maintenanceResult] = await Promise.all([
+  const [result, settingsResult, remindersResult, maintenanceResult, inProgressResult, completedResult] = await Promise.all([
     getDashboardStats(),
     getSettings([SETTING_KEYS.CURRENCY_CODE, SETTING_KEYS.UNIT_SYSTEM]),
     getUpcomingReminders(),
     getVehiclesDueForService(),
+    getInspectionsPaginated({ status: "in_progress", pageSize: 5 }),
+    getInspectionsPaginated({ status: "completed", pageSize: 5 }),
   ]);
 
   if (!result.success || !result.data) {
@@ -40,6 +43,8 @@ export default async function DashboardPage() {
           upcomingReminders={remindersResult.success && remindersResult.data ? remindersResult.data : []}
           vehiclesDueForService={maintenanceResult.success && maintenanceResult.data ? maintenanceResult.data : []}
           unitSystem={unitSystem}
+          inProgressInspections={inProgressResult.success && inProgressResult.data ? inProgressResult.data.records : []}
+          completedInspections={completedResult.success && completedResult.data ? completedResult.data.records : []}
         />
       </div>
     </>

+ 28 - 21
src/app/(public)/share/inspection/[orgId]/[token]/inspection-view.tsx

@@ -10,7 +10,7 @@ interface InspectionItem {
   sortOrder: number;
   condition: string;
   notes: string | null;
-  imageUrl: string | null;
+  imageUrls: string[];
 }
 
 interface InspectionRecord {
@@ -81,17 +81,20 @@ export function InspectionView({
   const tz = timezone || "America/New_York";
   const formatDate = (d: Date) => fmtDate(new Date(d), fmt, tz);
 
+  // Only show items that have been inspected (not "not_inspected")
+  const inspectedItems = inspection.items.filter((i) => i.condition !== "not_inspected");
+
   // Group items by section
   const sections: Record<string, InspectionItem[]> = {};
-  for (const item of inspection.items) {
+  for (const item of inspectedItems) {
     if (!sections[item.section]) sections[item.section] = [];
     sections[item.section].push(item);
   }
 
-  const totalItems = inspection.items.length;
-  const passCount = inspection.items.filter((i) => i.condition === "pass").length;
-  const failCount = inspection.items.filter((i) => i.condition === "fail").length;
-  const attentionCount = inspection.items.filter((i) => i.condition === "attention").length;
+  const totalItems = inspectedItems.length;
+  const passCount = inspectedItems.filter((i) => i.condition === "pass").length;
+  const failCount = inspectedItems.filter((i) => i.condition === "fail").length;
+  const attentionCount = inspectedItems.filter((i) => i.condition === "attention").length;
 
   return (
     <div className="mx-auto max-w-3xl p-4 sm:p-8">
@@ -207,21 +210,25 @@ export function InspectionView({
                     {item.notes && (
                       <p className="mt-1 text-sm text-gray-500">{item.notes}</p>
                     )}
-                    {item.imageUrl && (
-                      <div className="mt-2">
-                        {/\.(mp4|webm|mov)$/i.test(item.imageUrl) ? (
-                          <video
-                            src={item.imageUrl}
-                            controls
-                            className="h-48 max-w-sm rounded-lg border"
-                          />
-                        ) : (
-                          <img
-                            src={item.imageUrl}
-                            alt={item.name}
-                            className="h-32 rounded-lg object-cover border"
-                          />
-                        )}
+                    {item.imageUrls && item.imageUrls.length > 0 && (
+                      <div className="mt-2 flex flex-wrap gap-2">
+                        {item.imageUrls.map((url, idx) => (
+                          /\.(mp4|webm|mov)$/i.test(url) ? (
+                            <video
+                              key={idx}
+                              src={url}
+                              controls
+                              className="h-48 max-w-sm rounded-lg border"
+                            />
+                          ) : (
+                            <img
+                              key={idx}
+                              src={url}
+                              alt={`${item.name} ${idx + 1}`}
+                              className="h-32 rounded-lg object-cover border"
+                            />
+                          )
+                        ))}
                       </div>
                     )}
                   </div>

+ 1 - 1
src/features/inspections/Actions/inspectionActions.ts

@@ -174,7 +174,7 @@ export async function updateInspectionItem(itemId: string, input: unknown) {
       data: {
         condition: data.condition,
         notes: data.notes,
-        imageUrl: data.imageUrl,
+        imageUrls: data.imageUrls,
       },
     });
 

+ 8 - 6
src/features/inspections/Actions/templateActions.ts

@@ -145,14 +145,16 @@ export async function deleteTemplate(id: string) {
     });
     if (!template) throw new Error("Template not found");
 
-    await db.$transaction(async (tx) => {
-      // Delete inspections that reference this template (and their items via cascade)
-      await tx.inspection.deleteMany({ where: { templateId: id } });
-      await tx.inspectionTemplate.delete({ where: { id } });
-    });
+    const inspectionCount = await db.inspection.count({ where: { templateId: id } });
+    if (inspectionCount > 0) {
+      throw new Error(
+        `This template has ${inspectionCount} inspection${inspectionCount === 1 ? "" : "s"}. Delete the inspections first before removing the template.`
+      );
+    }
+
+    await db.inspectionTemplate.delete({ where: { id } });
 
     revalidatePath("/settings/templates");
-    revalidatePath("/inspections");
   }, { requiredPermissions: [{ action: PermissionAction.DELETE, subject: PermissionSubject.INSPECTIONS }] });
 }
 

+ 76 - 34
src/features/inspections/Components/InspectionPageClient.tsx

@@ -32,7 +32,7 @@ interface InspectionItem {
   sortOrder: number;
   condition: string;
   notes: string | null;
-  imageUrl: string | null;
+  imageUrls: string[];
 }
 
 interface InspectionData {
@@ -123,7 +123,7 @@ function InspectionItemRow({
 }) {
   const [condition, setCondition] = useState(item.condition as Condition);
   const [notes, setNotes] = useState(item.notes || "");
-  const [imageUrl, setImageUrl] = useState(item.imageUrl || "");
+  const [imageUrls, setImageUrls] = useState<string[]>(item.imageUrls || []);
   const [showNotes, setShowNotes] = useState(!!item.notes);
   const [isSaving, startSaving] = useTransition();
   const [isUploading, setIsUploading] = useState(false);
@@ -136,7 +136,7 @@ function InspectionItemRow({
       const result = await updateInspectionItem(item.id, {
         condition: newCondition,
         notes: notes || undefined,
-        imageUrl: imageUrl || undefined,
+        imageUrls,
       });
       if (!result.success) {
         setCondition(item.condition as Condition);
@@ -153,7 +153,7 @@ function InspectionItemRow({
       const result = await updateInspectionItem(item.id, {
         condition,
         notes: notes || undefined,
-        imageUrl: imageUrl || undefined,
+        imageUrls,
       });
       if (result.success) {
         toast.success("Notes saved");
@@ -164,37 +164,65 @@ function InspectionItemRow({
   };
 
   const handleMediaUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
-    const file = e.target.files?.[0];
-    if (!file || isCompleted) return;
+    const files = e.target.files;
+    if (!files || files.length === 0 || isCompleted) return;
 
     setIsUploading(true);
-    const formData = new FormData();
-    formData.append("file", file);
+    const newUrls: string[] = [];
 
     try {
-      const res = await fetch("/api/upload/service-files", {
-        method: "POST",
-        body: formData,
-      });
-      const data = await res.json();
-      if (data.url) {
-        setImageUrl(data.url);
+      for (const file of Array.from(files)) {
+        const formData = new FormData();
+        formData.append("file", file);
+
+        const res = await fetch("/api/upload/service-files", {
+          method: "POST",
+          body: formData,
+        });
+        const data = await res.json();
+        if (data.url) {
+          newUrls.push(data.url);
+        } else {
+          toast.error(data.error || `Upload failed for ${file.name}`);
+        }
+      }
+
+      if (newUrls.length > 0) {
+        const updated = [...imageUrls, ...newUrls];
+        setImageUrls(updated);
         await updateInspectionItem(item.id, {
           condition,
           notes: notes || undefined,
-          imageUrl: data.url,
+          imageUrls: updated,
         });
-        toast.success("File uploaded");
-      } else {
-        toast.error(data.error || "Upload failed");
+        toast.success(`${newUrls.length} file${newUrls.length > 1 ? "s" : ""} uploaded`);
       }
     } catch {
       toast.error("Upload failed");
     } finally {
       setIsUploading(false);
+      if (fileInputRef.current) fileInputRef.current.value = "";
     }
   };
 
+  const handleRemoveFile = (index: number) => {
+    if (isCompleted) return;
+    const updated = imageUrls.filter((_, i) => i !== index);
+    setImageUrls(updated);
+    startSaving(async () => {
+      const result = await updateInspectionItem(item.id, {
+        condition,
+        notes: notes || undefined,
+        imageUrls: updated,
+      });
+      if (result.success) {
+        toast.success("File removed");
+      } else {
+        toast.error("Failed to remove file");
+      }
+    });
+  };
+
   const isVideo = (url: string) => /\.(mp4|webm|mov)$/i.test(url);
 
   const conditionBadgeColor = conditionConfig[condition]?.color || "text-gray-400";
@@ -241,6 +269,7 @@ function InspectionItemRow({
             ref={fileInputRef}
             type="file"
             accept="image/*,video/mp4,video/webm,video/quicktime"
+            multiple
             className="hidden"
             onChange={handleMediaUpload}
           />
@@ -258,21 +287,34 @@ function InspectionItemRow({
         />
       )}
 
-      {imageUrl && (
-        <div className="mt-2">
-          {isVideo(imageUrl) ? (
-            <video
-              src={imageUrl}
-              controls
-              className="h-32 max-w-xs rounded-lg border"
-            />
-          ) : (
-            <img
-              src={imageUrl}
-              alt={item.name}
-              className="h-20 w-20 rounded-lg object-cover border"
-            />
-          )}
+      {imageUrls.length > 0 && (
+        <div className="mt-2 flex flex-wrap gap-2">
+          {imageUrls.map((url, idx) => (
+            <div key={idx} className="relative group">
+              {isVideo(url) ? (
+                <video
+                  src={url}
+                  controls
+                  className="h-32 max-w-xs rounded-lg border"
+                />
+              ) : (
+                <img
+                  src={url}
+                  alt={`${item.name} ${idx + 1}`}
+                  className="h-20 w-20 rounded-lg object-cover border"
+                />
+              )}
+              {!isCompleted && (
+                <button
+                  type="button"
+                  onClick={() => handleRemoveFile(idx)}
+                  className="absolute -right-1.5 -top-1.5 hidden group-hover:flex h-5 w-5 items-center justify-center rounded-full bg-destructive text-destructive-foreground text-xs"
+                >
+                  <X className="h-3 w-3" />
+                </button>
+              )}
+            </div>
+          ))}
         </div>
       )}
     </div>

+ 1 - 1
src/features/inspections/Schema/inspectionSchema.ts

@@ -9,7 +9,7 @@ export const createInspectionSchema = z.object({
 export const updateInspectionItemSchema = z.object({
   condition: z.enum(["pass", "fail", "attention", "not_inspected"]).default("not_inspected"),
   notes: z.string().optional(),
-  imageUrl: z.string().optional(),
+  imageUrls: z.array(z.string()).optional(),
 });
 
 export type CreateInspectionInput = z.infer<typeof createInspectionSchema>;