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

+ 3 - 0
prisma/migrations/20260219050044_predicted_maintenance/migration.sql

@@ -0,0 +1,3 @@
+-- AlterTable
+ALTER TABLE "vehicles" ADD COLUMN     "maintenanceDismissed" BOOLEAN NOT NULL DEFAULT false,
+ADD COLUMN     "maintenanceDismissedAt" TIMESTAMP(3);

+ 5 - 3
prisma/schema.prisma

@@ -122,9 +122,11 @@ model Vehicle {
   purchaseDate  DateTime?
   purchasePrice Float?
   imageUrl      String?
-  isArchived    Boolean   @default(false)
-  archiveReason String?
-  createdAt     DateTime  @default(now())
+  isArchived             Boolean   @default(false)
+  archiveReason          String?
+  maintenanceDismissed   Boolean   @default(false)
+  maintenanceDismissedAt DateTime?
+  createdAt              DateTime  @default(now())
   updatedAt     DateTime  @updatedAt
 
   userId String

+ 39 - 4
src/app/(authenticated)/dashboard-client.tsx

@@ -1,10 +1,11 @@
 "use client";
 
-import { useState } from "react";
+import { useState, useTransition } from "react";
 import { useRouter } from "next/navigation";
 import { useFormatDate } from "@/lib/use-format-date";
 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
 import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
 import {
   Table,
   TableBody,
@@ -23,8 +24,10 @@ import {
   Gauge,
   Loader2,
   Users,
+  X,
 } from "lucide-react";
 import { formatCurrency } from "@/lib/format";
+import { dismissMaintenance } from "@/features/vehicles/Actions/dismissMaintenance";
 
 interface ServiceItem {
   id: string;
@@ -80,6 +83,7 @@ interface VehicleDueForService {
   mileageSinceLastService: number;
   serviceInterval: number;
   status: "overdue" | "approaching";
+  confidencePercent: number;
 }
 
 export function DashboardClient({
@@ -99,6 +103,16 @@ export function DashboardClient({
   const router = useRouter();
   const { formatDate } = useFormatDate();
   const [navigatingId, setNavigatingId] = useState<string | null>(null);
+  const [dismissingId, setDismissingId] = useState<string | null>(null);
+  const [isPending, startTransition] = useTransition();
+
+  const handleDismiss = (vehicleId: string) => {
+    setDismissingId(vehicleId);
+    startTransition(async () => {
+      await dismissMaintenance(vehicleId);
+      setDismissingId(null);
+    });
+  };
 
   return (
     <div className="space-y-6">
@@ -151,11 +165,14 @@ export function DashboardClient({
       {/* Vehicles Due for Service */}
       {vehiclesDueForService.length > 0 && (
         <Card className="border-0 shadow-sm">
-          <CardHeader className="pb-3">
+          <CardHeader className="pb-1">
             <CardTitle className="flex items-center gap-2 text-base">
               <Gauge className="h-4 w-4" />
-              Vehicles Due for Service
+              Predicted Maintenance Due
             </CardTitle>
+            <p className="text-xs text-muted-foreground">
+              Based on service history and estimated daily mileage
+            </p>
           </CardHeader>
           <CardContent className="p-0">
             <div className="divide-y">
@@ -184,10 +201,12 @@ export function DashboardClient({
                         Est. {v.predictedMileage.toLocaleString()} {distUnit}
                         {" · "}
                         {v.mileageSinceLastService.toLocaleString()} {distUnit} since last service
+                        {" · "}
+                        {v.confidencePercent}% certainty
                       </p>
                     </div>
                   </div>
-                  <div className="shrink-0 ml-3">
+                  <div className="shrink-0 ml-3 flex items-center gap-1.5">
                     {v.status === "overdue" ? (
                       <Badge variant="destructive" className="text-[10px]">
                         Overdue
@@ -197,6 +216,22 @@ export function DashboardClient({
                         Approaching
                       </Badge>
                     )}
+                    <Button
+                      variant="ghost"
+                      size="icon"
+                      className="h-6 w-6 text-muted-foreground hover:text-foreground"
+                      disabled={dismissingId === v.vehicleId}
+                      onClick={(e) => {
+                        e.stopPropagation();
+                        handleDismiss(v.vehicleId);
+                      }}
+                    >
+                      {dismissingId === v.vehicleId ? (
+                        <Loader2 className="h-3.5 w-3.5 animate-spin" />
+                      ) : (
+                        <X className="h-3.5 w-3.5" />
+                      )}
+                    </Button>
                   </div>
                 </div>
               ))}

+ 22 - 15
src/app/(authenticated)/vehicles/[id]/page.tsx

@@ -67,30 +67,37 @@ export default async function VehicleDetailPage({
     lastServiceMileage: number;
     serviceInterval: number;
     mileageSinceLastService: number;
-    status: "overdue" | "approaching" | "ok";
+    status: "overdue" | "approaching" | "ok" | null;
+    maintenanceDismissed: boolean;
+    confidencePercent: number;
   } | null = null;
 
-  if (maintenanceEnabled) {
-    const predResult = await getVehiclePredictedMileage(id);
-    if (predResult.success && predResult.data) {
-      const p = predResult.data;
-      const mileageSinceLastService = p.predictedMileage - p.lastServiceMileage;
+  const predResult = await getVehiclePredictedMileage(id);
+  if (predResult.success && predResult.data) {
+    const p = predResult.data;
+    const mileageSinceLastService = p.predictedMileage - p.lastServiceMileage;
+
+    let status: "overdue" | "approaching" | "ok" | null = null;
+    if (maintenanceEnabled) {
       const approachingThreshold = parseInt(maintenanceSettings[SETTING_KEYS.MAINTENANCE_APPROACHING_THRESHOLD] || "1000", 10);
-      let status: "overdue" | "approaching" | "ok" = "ok";
+      status = "ok";
       if (mileageSinceLastService >= serviceInterval) {
         status = "overdue";
       } else if (mileageSinceLastService >= serviceInterval - approachingThreshold) {
         status = "approaching";
       }
-      predictionData = {
-        predictedMileage: p.predictedMileage,
-        avgPerDay: p.avgPerDay,
-        lastServiceMileage: p.lastServiceMileage,
-        serviceInterval,
-        mileageSinceLastService,
-        status,
-      };
     }
+
+    predictionData = {
+      predictedMileage: p.predictedMileage,
+      avgPerDay: p.avgPerDay,
+      lastServiceMileage: p.lastServiceMileage,
+      serviceInterval,
+      mileageSinceLastService,
+      status,
+      maintenanceDismissed: result.data.maintenanceDismissed,
+      confidencePercent: p.confidencePercent,
+    };
   }
 
   return (

+ 92 - 12
src/app/(authenticated)/vehicles/[id]/vehicle-detail-client.tsx

@@ -1,6 +1,6 @@
 'use client'
 
-import { useState } from 'react'
+import { useState, useTransition } from 'react'
 import { useRouter } from 'next/navigation'
 import Link from 'next/link'
 import { useFormatDate } from '@/lib/use-format-date'
@@ -26,6 +26,7 @@ import { deleteNote, toggleNotePin } from '@/features/vehicles/Actions/noteActio
 import { toggleReminder, deleteReminder } from '@/features/vehicles/Actions/reminderActions'
 import { unarchiveVehicle } from '@/features/vehicles/Actions/unarchiveVehicle'
 import { deleteVehicle } from '@/features/vehicles/Actions/deleteVehicle'
+import { dismissMaintenance, undismissMaintenance } from '@/features/vehicles/Actions/dismissMaintenance'
 import { ArchiveVehicleDialog } from '@/features/vehicles/Components/ArchiveVehicleDialog'
 import { formatCurrency } from '@/lib/format'
 import {
@@ -38,7 +39,9 @@ import {
   CheckCircle2,
   Clock,
   DollarSign,
+  EyeOff,
   Gauge,
+  Loader2,
   MoreVertical,
   Pencil,
   Pin,
@@ -47,7 +50,9 @@ import {
   StickyNote,
   Trash2,
   TrendingUp,
+  Undo2,
   Wrench,
+  X,
 } from 'lucide-react'
 import {
   DropdownMenu,
@@ -171,7 +176,9 @@ export function VehicleDetailClient({
     lastServiceMileage: number
     serviceInterval: number
     mileageSinceLastService: number
-    status: 'overdue' | 'approaching' | 'ok'
+    status: 'overdue' | 'approaching' | 'ok' | null
+    maintenanceDismissed: boolean
+    confidencePercent: number
   } | null
 }) {
   const distUnit = unitSystem === 'metric' ? 'km' : 'mi'
@@ -188,6 +195,19 @@ export function VehicleDetailClient({
   const [showImage, setShowImage] = useState(false)
   const [showArchiveDialog, setShowArchiveDialog] = useState(false)
   const [showDeleteDialog, setShowDeleteDialog] = useState(false)
+  const [isDismissPending, startDismissTransition] = useTransition()
+
+  const handleDismissMaintenance = () => {
+    startDismissTransition(async () => {
+      await dismissMaintenance(vehicle.id)
+    })
+  }
+
+  const handleUndismissMaintenance = () => {
+    startDismissTransition(async () => {
+      await undismissMaintenance(vehicle.id)
+    })
+  }
 
   const now = new Date()
   const sevenDaysFromNow = new Date(now)
@@ -421,19 +441,79 @@ export function VehicleDetailClient({
           {predictionData && (
             <div className="flex items-center gap-1.5 text-muted-foreground">
               <TrendingUp className="h-3.5 w-3.5" />
+              <span className="text-xs">
+                {unitSystem === 'metric' ? 'Predicted Km' : 'Predicted Mileage'}
+              </span>
               <span className="font-semibold text-foreground">
                 ~{predictionData.predictedMileage.toLocaleString()}
               </span>
-              <span className="text-xs">{distUnit} est.</span>
-              {predictionData.status === 'overdue' && (
-                <Badge variant="destructive" className="text-[10px] ml-1">
-                  Service Overdue
-                </Badge>
-              )}
-              {predictionData.status === 'approaching' && (
-                <Badge className="bg-amber-500/15 text-amber-600 border-amber-500/20 text-[10px] ml-1">
-                  Service Soon
-                </Badge>
+              <span className="text-[10px] text-muted-foreground/70">({predictionData.confidencePercent}%)</span>
+              {predictionData.status && predictionData.maintenanceDismissed ? (
+                <>
+                  <Badge variant="outline" className="text-[10px] ml-1 text-muted-foreground">
+                    <EyeOff className="mr-0.5 h-3 w-3" />
+                    Dismissed
+                  </Badge>
+                  <Button
+                    variant="ghost"
+                    size="sm"
+                    className="h-5 px-1.5 text-[10px] text-muted-foreground hover:text-foreground"
+                    disabled={isDismissPending}
+                    onClick={handleUndismissMaintenance}
+                  >
+                    {isDismissPending ? (
+                      <Loader2 className="h-3 w-3 animate-spin" />
+                    ) : (
+                      <>
+                        <Undo2 className="mr-0.5 h-3 w-3" />
+                        Undo
+                      </>
+                    )}
+                  </Button>
+                </>
+              ) : (
+                <>
+                  {predictionData.status === 'overdue' && (
+                    <>
+                      <Badge variant="destructive" className="text-[10px] ml-1">
+                        Service Overdue
+                      </Badge>
+                      <Button
+                        variant="ghost"
+                        size="icon"
+                        className="h-5 w-5 text-muted-foreground hover:text-foreground ml-0.5"
+                        disabled={isDismissPending}
+                        onClick={handleDismissMaintenance}
+                      >
+                        {isDismissPending ? (
+                          <Loader2 className="h-3 w-3 animate-spin" />
+                        ) : (
+                          <X className="h-3 w-3" />
+                        )}
+                      </Button>
+                    </>
+                  )}
+                  {predictionData.status === 'approaching' && (
+                    <>
+                      <Badge className="bg-amber-500/15 text-amber-600 border-amber-500/20 text-[10px] ml-1">
+                        Service Soon
+                      </Badge>
+                      <Button
+                        variant="ghost"
+                        size="icon"
+                        className="h-5 w-5 text-muted-foreground hover:text-foreground ml-0.5"
+                        disabled={isDismissPending}
+                        onClick={handleDismissMaintenance}
+                      >
+                        {isDismissPending ? (
+                          <Loader2 className="h-3 w-3 animate-spin" />
+                        ) : (
+                          <X className="h-3 w-3" />
+                        )}
+                      </Button>
+                    </>
+                  )}
+                </>
               )}
             </div>
           )}

+ 48 - 0
src/features/vehicles/Actions/dismissMaintenance.ts

@@ -0,0 +1,48 @@
+"use server";
+
+import { db } from "@/lib/db";
+import { withAuth } from "@/lib/with-auth";
+import { revalidatePath } from "next/cache";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+
+export async function dismissMaintenance(vehicleId: string) {
+  return withAuth(async ({ organizationId }) => {
+    const vehicle = await db.vehicle.findFirst({
+      where: { id: vehicleId, organizationId },
+    });
+    if (!vehicle) throw new Error("Vehicle not found");
+
+    await db.vehicle.update({
+      where: { id: vehicleId },
+      data: {
+        maintenanceDismissed: true,
+        maintenanceDismissedAt: new Date(),
+      },
+    });
+
+    revalidatePath("/");
+    revalidatePath(`/vehicles/${vehicleId}`);
+    return { success: true };
+  }, { requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.VEHICLES }] });
+}
+
+export async function undismissMaintenance(vehicleId: string) {
+  return withAuth(async ({ organizationId }) => {
+    const vehicle = await db.vehicle.findFirst({
+      where: { id: vehicleId, organizationId },
+    });
+    if (!vehicle) throw new Error("Vehicle not found");
+
+    await db.vehicle.update({
+      where: { id: vehicleId },
+      data: {
+        maintenanceDismissed: false,
+        maintenanceDismissedAt: null,
+      },
+    });
+
+    revalidatePath("/");
+    revalidatePath(`/vehicles/${vehicleId}`);
+    return { success: true };
+  }, { requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.VEHICLES }] });
+}

+ 11 - 1
src/features/vehicles/Actions/predictedMaintenanceActions.ts

@@ -11,6 +11,7 @@ export type PredictedMileage = {
   lastServiceDate: Date;
   lastServiceMileage: number;
   confidence: number;
+  confidencePercent: number;
 };
 
 export type VehicleDueForService = {
@@ -24,8 +25,15 @@ export type VehicleDueForService = {
   mileageSinceLastService: number;
   serviceInterval: number;
   status: "overdue" | "approaching";
+  confidencePercent: number;
 };
 
+function calculateConfidencePercent(dataPoints: number, totalDays: number): number {
+  const pointScore = Math.min(50, 15 * Math.log2(dataPoints));
+  const timeScore = Math.min(30, (totalDays / 365) * 30);
+  return Math.min(95, Math.round(15 + pointScore + timeScore));
+}
+
 export async function getVehiclePredictedMileage(vehicleId: string) {
   return withAuth(async ({ organizationId }) => {
     const records = await db.serviceRecord.findMany({
@@ -73,6 +81,7 @@ export async function getVehiclePredictedMileage(vehicleId: string) {
       lastServiceDate: latest.date,
       lastServiceMileage: latest.mileage,
       confidence: dataPoints.length,
+      confidencePercent: calculateConfidencePercent(dataPoints.length, totalDays),
     } satisfies PredictedMileage;
   }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.VEHICLES }] });
 }
@@ -113,7 +122,7 @@ export async function getVehiclesDueForService() {
 
     // Get all non-archived vehicles with their service records that have mileage
     const vehicles = await db.vehicle.findMany({
-      where: { organizationId, isArchived: false },
+      where: { organizationId, isArchived: false, maintenanceDismissed: false },
       select: {
         id: true,
         make: true,
@@ -182,6 +191,7 @@ export async function getVehiclesDueForService() {
           mileageSinceLastService,
           serviceInterval,
           status,
+          confidencePercent: calculateConfidencePercent(records.length, totalDays),
         });
       }
     }

+ 10 - 5
src/features/vehicles/Actions/serviceActions.ts

@@ -321,13 +321,18 @@ export async function createServiceRecord(input: unknown) {
       revalidatePath("/inventory");
     }
 
-    // Update vehicle mileage if service mileage is higher
+    // Update vehicle mileage if service mileage is higher, and reset maintenance dismissed
+    const vehicleUpdate: { mileage?: number; maintenanceDismissed: boolean; maintenanceDismissedAt: null } = {
+      maintenanceDismissed: false,
+      maintenanceDismissedAt: null,
+    };
     if (data.mileage && data.mileage > vehicle.mileage) {
-      await db.vehicle.update({
-        where: { id: vehicle.id },
-        data: { mileage: data.mileage },
-      });
+      vehicleUpdate.mileage = data.mileage;
     }
+    await db.vehicle.update({
+      where: { id: vehicle.id },
+      data: vehicleUpdate,
+    });
 
     revalidatePath("/");
     revalidatePath(`/vehicles/${data.vehicleId}`);