Răsfoiți Sursa

predicted maintaince

Bernt Christian Egeland 7 luni în urmă
părinte
comite
33ef8f7863

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

@@ -20,6 +20,7 @@ import {
   ClipboardList,
   Clock,
   DollarSign,
+  Gauge,
   Loader2,
   Users,
 } from "lucide-react";
@@ -68,15 +69,33 @@ interface ReminderItem {
   };
 }
 
+interface VehicleDueForService {
+  vehicleId: string;
+  make: string;
+  model: string;
+  year: number;
+  licensePlate: string | null;
+  predictedMileage: number;
+  lastServiceMileage: number;
+  mileageSinceLastService: number;
+  serviceInterval: number;
+  status: "overdue" | "approaching";
+}
+
 export function DashboardClient({
   stats,
   currencyCode = "USD",
   upcomingReminders = [],
+  vehiclesDueForService = [],
+  unitSystem = "imperial",
 }: {
   stats: DashboardStats;
   currencyCode?: string;
   upcomingReminders?: ReminderItem[];
+  vehiclesDueForService?: VehicleDueForService[];
+  unitSystem?: "metric" | "imperial";
 }) {
+  const distUnit = unitSystem === "metric" ? "km" : "mi";
   const router = useRouter();
   const { formatDate } = useFormatDate();
   const [navigatingId, setNavigatingId] = useState<string | null>(null);
@@ -129,6 +148,63 @@ export function DashboardClient({
         )}
       </div>
 
+      {/* Vehicles Due for Service */}
+      {vehiclesDueForService.length > 0 && (
+        <Card className="border-0 shadow-sm">
+          <CardHeader className="pb-3">
+            <CardTitle className="flex items-center gap-2 text-base">
+              <Gauge className="h-4 w-4" />
+              Vehicles Due for Service
+            </CardTitle>
+          </CardHeader>
+          <CardContent className="p-0">
+            <div className="divide-y">
+              {vehiclesDueForService.map((v) => (
+                <div
+                  key={v.vehicleId}
+                  className="flex items-center justify-between px-5 py-3 cursor-pointer hover:bg-muted/50 transition-colors"
+                  onClick={() => router.push(`/vehicles/${v.vehicleId}`)}
+                >
+                  <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 ${
+                      v.status === "overdue" ? "bg-red-500/10" : "bg-amber-500/10"
+                    }`}>
+                      {v.status === "overdue" ? (
+                        <AlertTriangle className="h-4 w-4 text-red-500" />
+                      ) : (
+                        <Clock className="h-4 w-4 text-amber-500" />
+                      )}
+                    </div>
+                    <div className="min-w-0">
+                      <p className="font-medium text-sm truncate">
+                        {v.year} {v.make} {v.model}
+                      </p>
+                      <p className="text-xs text-muted-foreground truncate">
+                        {v.licensePlate && `${v.licensePlate} · `}
+                        Est. {v.predictedMileage.toLocaleString()} {distUnit}
+                        {" · "}
+                        {v.mileageSinceLastService.toLocaleString()} {distUnit} since last service
+                      </p>
+                    </div>
+                  </div>
+                  <div className="shrink-0 ml-3">
+                    {v.status === "overdue" ? (
+                      <Badge variant="destructive" className="text-[10px]">
+                        Overdue
+                      </Badge>
+                    ) : (
+                      <Badge className="bg-amber-500/15 text-amber-600 border-amber-500/20 text-[10px]">
+                        Approaching
+                      </Badge>
+                    )}
+                  </div>
+                </div>
+              ))}
+            </div>
+          </CardContent>
+        </Card>
+      )}
+
       {/* Upcoming Reminders */}
       {upcomingReminders.length > 0 && (
         <Card className="border-0 shadow-sm">

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

@@ -1,14 +1,16 @@
 import { getDashboardStats, getUpcomingReminders } from "@/features/vehicles/Actions/dashboardActions";
 import { getSettings } from "@/features/settings/Actions/settingsActions";
 import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
+import { getVehiclesDueForService } from "@/features/vehicles/Actions/predictedMaintenanceActions";
 import { DashboardClient } from "./dashboard-client";
 import { PageHeader } from "@/components/page-header";
 
 export default async function DashboardPage() {
-  const [result, settingsResult, remindersResult] = await Promise.all([
+  const [result, settingsResult, remindersResult, maintenanceResult] = await Promise.all([
     getDashboardStats(),
     getSettings([SETTING_KEYS.CURRENCY_CODE, SETTING_KEYS.UNIT_SYSTEM]),
     getUpcomingReminders(),
+    getVehiclesDueForService(),
   ]);
 
   if (!result.success || !result.data) {
@@ -26,6 +28,7 @@ export default async function DashboardPage() {
 
   const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {};
   const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || "USD";
+  const unitSystem = (settings[SETTING_KEYS.UNIT_SYSTEM] || "imperial") as "metric" | "imperial";
 
   return (
     <>
@@ -35,6 +38,8 @@ export default async function DashboardPage() {
           stats={result.data}
           currencyCode={currencyCode}
           upcomingReminders={remindersResult.success && remindersResult.data ? remindersResult.data : []}
+          vehiclesDueForService={maintenanceResult.success && maintenanceResult.data ? maintenanceResult.data : []}
+          unitSystem={unitSystem}
         />
       </div>
     </>

+ 1 - 42
src/app/(authenticated)/settings/currency/currency-settings.tsx

@@ -18,7 +18,7 @@ import {
   SelectValue,
 } from "@/components/ui/select";
 import { Switch } from "@/components/ui/switch";
-import { Coins, Loader2, Ruler, Save } from "lucide-react";
+import { Coins, Loader2, Save } from "lucide-react";
 import { formatCurrency } from "@/lib/format";
 import { ReadOnlyBanner, SaveButton, ReadOnlyWrapper } from "../read-only-guard";
 
@@ -64,14 +64,12 @@ export function CurrencySettings({ settings }: { settings: Record<string, string
   const [currencyCode, setCurrencyCode] = useState(settings[SETTING_KEYS.CURRENCY_CODE] || "USD");
   const [taxEnabled, setTaxEnabled] = useState(settings[SETTING_KEYS.TAX_ENABLED] !== "false");
   const [defaultTaxRate, setDefaultTaxRate] = useState(settings[SETTING_KEYS.DEFAULT_TAX_RATE] || "0");
-  const [unitSystem, setUnitSystem] = useState(settings[SETTING_KEYS.UNIT_SYSTEM] || "imperial");
   const handleSave = async () => {
     setSaving(true);
     await setSettings({
       [SETTING_KEYS.CURRENCY_CODE]: currencyCode,
       [SETTING_KEYS.TAX_ENABLED]: String(taxEnabled),
       [SETTING_KEYS.DEFAULT_TAX_RATE]: taxEnabled ? defaultTaxRate : "0",
-      [SETTING_KEYS.UNIT_SYSTEM]: unitSystem,
     });
     setSaving(false);
     router.refresh();
@@ -143,45 +141,6 @@ export function CurrencySettings({ settings }: { settings: Record<string, string
             </div>
           )}
 
-          <Separator />
-
-          <div className="space-y-4">
-            <div className="flex flex-row items-center gap-3">
-              <Ruler className="h-5 w-5 text-muted-foreground" />
-              <h3 className="text-lg font-semibold">Units</h3>
-            </div>
-            <p className="text-sm text-muted-foreground">
-              Choose between metric (km, liters) and imperial (miles, gallons) units.
-            </p>
-
-            <div className="space-y-2">
-              <Label htmlFor="unitSystem">Unit System</Label>
-              <Select value={unitSystem} onValueChange={setUnitSystem}>
-                <SelectTrigger className="w-48">
-                  <SelectValue />
-                </SelectTrigger>
-                <SelectContent>
-                  <SelectItem value="metric">Metric (km, liters)</SelectItem>
-                  <SelectItem value="imperial">Imperial (miles, gallons)</SelectItem>
-                </SelectContent>
-              </Select>
-            </div>
-
-            <div className="rounded-lg border p-3 text-sm text-muted-foreground">
-              {unitSystem === "metric" ? (
-                <div className="space-y-1">
-                  <p>Distance: <span className="font-medium text-foreground">kilometers (km)</span></p>
-                  <p>Volume: <span className="font-medium text-foreground">liters (L)</span></p>
-                </div>
-              ) : (
-                <div className="space-y-1">
-                  <p>Distance: <span className="font-medium text-foreground">miles (mi)</span></p>
-                  <p>Volume: <span className="font-medium text-foreground">gallons (gal)</span></p>
-                </div>
-              )}
-            </div>
-          </div>
-
           </div>
           </ReadOnlyWrapper>
 

+ 128 - 0
src/app/(authenticated)/settings/maintenance/maintenance-settings.tsx

@@ -0,0 +1,128 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Button } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
+import { Switch } from "@/components/ui/switch";
+import { toast } from "sonner";
+import { setSettings } from "@/features/settings/Actions/settingsActions";
+import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
+import { Gauge, Loader2, Save } from "lucide-react";
+import { ReadOnlyBanner, SaveButton, ReadOnlyWrapper } from "../read-only-guard";
+
+export function MaintenanceSettings({ settings }: { settings: Record<string, string> }) {
+  const router = useRouter();
+  const [saving, setSaving] = useState(false);
+
+  const unitSystem = settings[SETTING_KEYS.UNIT_SYSTEM] || "imperial";
+  const distUnit = unitSystem === "metric" ? "km" : "mi";
+
+  const [enabled, setEnabled] = useState(
+    settings[SETTING_KEYS.PREDICTED_MAINTENANCE_ENABLED] === "true"
+  );
+  const [serviceInterval, setServiceInterval] = useState(
+    settings[SETTING_KEYS.MAINTENANCE_SERVICE_INTERVAL] || "15000"
+  );
+  const [approachingThreshold, setApproachingThreshold] = useState(
+    settings[SETTING_KEYS.MAINTENANCE_APPROACHING_THRESHOLD] || "1000"
+  );
+
+  const handleSave = async () => {
+    setSaving(true);
+    await setSettings({
+      [SETTING_KEYS.PREDICTED_MAINTENANCE_ENABLED]: enabled ? "true" : "false",
+      [SETTING_KEYS.MAINTENANCE_SERVICE_INTERVAL]: serviceInterval,
+      [SETTING_KEYS.MAINTENANCE_APPROACHING_THRESHOLD]: approachingThreshold,
+    });
+    setSaving(false);
+    router.refresh();
+    toast.success("Maintenance settings saved");
+  };
+
+  return (
+    <div className="space-y-6">
+      <ReadOnlyBanner />
+      <ReadOnlyWrapper>
+        <Card className="border-0 shadow-sm">
+          <CardHeader className="flex flex-row items-center gap-3 pb-4">
+            <Gauge className="h-5 w-5 text-muted-foreground" />
+            <CardTitle className="text-lg">Predicted Maintenance</CardTitle>
+          </CardHeader>
+          <CardContent className="space-y-6">
+            <p className="text-sm text-muted-foreground">
+              Predict each vehicle&apos;s current mileage from its service history and
+              surface vehicles that are due or approaching their next service.
+            </p>
+
+            <div className="flex items-center justify-between">
+              <div className="space-y-0.5">
+                <Label htmlFor="maintenance-enabled">Enable predicted maintenance</Label>
+                <p className="text-xs text-muted-foreground">
+                  Show predictions on vehicle details and dashboard
+                </p>
+              </div>
+              <Switch
+                id="maintenance-enabled"
+                checked={enabled}
+                onCheckedChange={setEnabled}
+              />
+            </div>
+
+            <Separator />
+
+            <div className="grid gap-4 sm:grid-cols-2">
+              <div className="space-y-2">
+                <Label htmlFor="serviceInterval">Service interval ({distUnit})</Label>
+                <Input
+                  id="serviceInterval"
+                  type="number"
+                  placeholder="15000"
+                  value={serviceInterval}
+                  onChange={(e) => setServiceInterval(e.target.value)}
+                  disabled={!enabled}
+                />
+                <p className="text-xs text-muted-foreground">
+                  Distance between recommended services
+                </p>
+              </div>
+              <div className="space-y-2">
+                <Label htmlFor="approachingThreshold">
+                  Approaching threshold ({distUnit})
+                </Label>
+                <Input
+                  id="approachingThreshold"
+                  type="number"
+                  placeholder="1000"
+                  value={approachingThreshold}
+                  onChange={(e) => setApproachingThreshold(e.target.value)}
+                  disabled={!enabled}
+                />
+                <p className="text-xs text-muted-foreground">
+                  Show warning when within this distance of next service
+                </p>
+              </div>
+            </div>
+
+            <SaveButton>
+              <Separator />
+              <div className="flex items-center gap-3">
+                <Button onClick={handleSave} disabled={saving}>
+                  {saving ? (
+                    <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+                  ) : (
+                    <Save className="mr-2 h-4 w-4" />
+                  )}
+                  Save Maintenance Settings
+                </Button>
+              </div>
+            </SaveButton>
+          </CardContent>
+        </Card>
+      </ReadOnlyWrapper>
+    </div>
+  );
+}

+ 15 - 0
src/app/(authenticated)/settings/maintenance/page.tsx

@@ -0,0 +1,15 @@
+import { getSettings } from "@/features/settings/Actions/settingsActions";
+import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
+import { MaintenanceSettings } from "./maintenance-settings";
+
+export default async function MaintenanceSettingsPage() {
+  const result = await getSettings([
+    SETTING_KEYS.PREDICTED_MAINTENANCE_ENABLED,
+    SETTING_KEYS.MAINTENANCE_SERVICE_INTERVAL,
+    SETTING_KEYS.MAINTENANCE_APPROACHING_THRESHOLD,
+    SETTING_KEYS.UNIT_SYSTEM,
+  ]);
+  const settings = result.success && result.data ? result.data : {};
+
+  return <MaintenanceSettings settings={settings} />;
+}

+ 7 - 0
src/app/(authenticated)/settings/settings-nav.tsx

@@ -11,6 +11,7 @@ import {
   CreditCard,
   Database,
   FileText,
+  Gauge,
   Info,
   Key,
   Layout,
@@ -104,6 +105,12 @@ const settingsNav: SettingsNavItem[] = [
     icon: Wrench,
     description: "Technician & labor defaults",
   },
+  {
+    title: "Maintenance",
+    href: "/settings/maintenance",
+    icon: Gauge,
+    description: "Predicted service intervals",
+  },
   {
     title: "Appearance",
     href: "/settings/appearance",

+ 51 - 1
src/app/(authenticated)/settings/workshop/workshop-settings.tsx

@@ -10,7 +10,14 @@ import { Separator } from "@/components/ui/separator";
 import { toast } from "sonner";
 import { setSettings } from "@/features/settings/Actions/settingsActions";
 import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
-import { Loader2, Save, Wrench } from "lucide-react";
+import { Loader2, Ruler, Save, Wrench } from "lucide-react";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
 import { ReadOnlyBanner, SaveButton, ReadOnlyWrapper } from "../read-only-guard";
 
 export function WorkshopSettings({ settings }: { settings: Record<string, string> }) {
@@ -26,6 +33,9 @@ export function WorkshopSettings({ settings }: { settings: Record<string, string
   const [workingHours, setWorkingHours] = useState(
     settings[SETTING_KEYS.WORKING_HOURS] || ""
   );
+  const [unitSystem, setUnitSystem] = useState(
+    settings[SETTING_KEYS.UNIT_SYSTEM] || "imperial"
+  );
 
   const handleSave = async () => {
     setSaving(true);
@@ -33,6 +43,7 @@ export function WorkshopSettings({ settings }: { settings: Record<string, string
       [SETTING_KEYS.DEFAULT_TECHNICIAN]: defaultTechnician,
       [SETTING_KEYS.DEFAULT_LABOR_RATE]: defaultLaborRate,
       [SETTING_KEYS.WORKING_HOURS]: workingHours,
+      [SETTING_KEYS.UNIT_SYSTEM]: unitSystem,
     });
     setSaving(false);
     router.refresh();
@@ -85,6 +96,45 @@ export function WorkshopSettings({ settings }: { settings: Record<string, string
             />
           </div>
 
+          <Separator />
+
+          <div className="space-y-4">
+            <div className="flex flex-row items-center gap-3">
+              <Ruler className="h-5 w-5 text-muted-foreground" />
+              <h3 className="text-lg font-semibold">Units</h3>
+            </div>
+            <p className="text-sm text-muted-foreground">
+              Choose between metric (km, liters) and imperial (miles, gallons) units.
+            </p>
+
+            <div className="space-y-2">
+              <Label htmlFor="unitSystem">Unit System</Label>
+              <Select value={unitSystem} onValueChange={setUnitSystem}>
+                <SelectTrigger className="w-48">
+                  <SelectValue />
+                </SelectTrigger>
+                <SelectContent>
+                  <SelectItem value="metric">Metric (km, liters)</SelectItem>
+                  <SelectItem value="imperial">Imperial (miles, gallons)</SelectItem>
+                </SelectContent>
+              </Select>
+            </div>
+
+            <div className="rounded-lg border p-3 text-sm text-muted-foreground">
+              {unitSystem === "metric" ? (
+                <div className="space-y-1">
+                  <p>Distance: <span className="font-medium text-foreground">kilometers (km)</span></p>
+                  <p>Volume: <span className="font-medium text-foreground">liters (L)</span></p>
+                </div>
+              ) : (
+                <div className="space-y-1">
+                  <p>Distance: <span className="font-medium text-foreground">miles (mi)</span></p>
+                  <p>Volume: <span className="font-medium text-foreground">gallons (gal)</span></p>
+                </div>
+              )}
+            </div>
+          </div>
+
           <SaveButton>
             <Separator />
             <div className="flex items-center gap-3">

+ 46 - 1
src/app/(authenticated)/vehicles/[id]/page.tsx

@@ -3,6 +3,7 @@ import { getServiceRecordsPaginated } from "@/features/vehicles/Actions/serviceA
 import { getCustomersList } from "@/features/customers/Actions/customerActions";
 import { getSettings } from "@/features/settings/Actions/settingsActions";
 import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
+import { getVehiclePredictedMileage } from "@/features/vehicles/Actions/predictedMaintenanceActions";
 import { VehicleDetailClient } from "./vehicle-detail-client";
 import { PageHeader } from "@/components/page-header";
 
@@ -21,11 +22,16 @@ export default async function VehicleDetailPage({
   const search = typeof sp.search === "string" ? sp.search : "";
   const type = typeof sp.type === "string" ? sp.type : "all";
 
-  const [result, customersResult, serviceResult, settingsResult] = await Promise.all([
+  const [result, customersResult, serviceResult, settingsResult, maintenanceSettingsResult] = await Promise.all([
     getVehicle(id),
     getCustomersList(),
     getServiceRecordsPaginated(id, { page, pageSize, search, type }),
     getSettings([SETTING_KEYS.CURRENCY_CODE, SETTING_KEYS.UNIT_SYSTEM]),
+    getSettings([
+      SETTING_KEYS.PREDICTED_MAINTENANCE_ENABLED,
+      SETTING_KEYS.MAINTENANCE_SERVICE_INTERVAL,
+      SETTING_KEYS.MAINTENANCE_APPROACHING_THRESHOLD,
+    ]),
   ]);
 
   if (!result.success || !result.data) {
@@ -49,6 +55,44 @@ export default async function VehicleDetailPage({
   const currencyCode = currencySettings[SETTING_KEYS.CURRENCY_CODE] || "USD";
   const unitSystem = (currencySettings[SETTING_KEYS.UNIT_SYSTEM] || "imperial") as "metric" | "imperial";
 
+  const maintenanceSettings = maintenanceSettingsResult.success && maintenanceSettingsResult.data
+    ? maintenanceSettingsResult.data
+    : {};
+  const maintenanceEnabled = maintenanceSettings[SETTING_KEYS.PREDICTED_MAINTENANCE_ENABLED] === "true";
+  const serviceInterval = parseInt(maintenanceSettings[SETTING_KEYS.MAINTENANCE_SERVICE_INTERVAL] || "15000", 10);
+
+  let predictionData: {
+    predictedMileage: number;
+    avgPerDay: number;
+    lastServiceMileage: number;
+    serviceInterval: number;
+    mileageSinceLastService: number;
+    status: "overdue" | "approaching" | "ok";
+  } | 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 approachingThreshold = parseInt(maintenanceSettings[SETTING_KEYS.MAINTENANCE_APPROACHING_THRESHOLD] || "1000", 10);
+      let status: "overdue" | "approaching" | "ok" = "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,
+      };
+    }
+  }
+
   return (
     <>
       <PageHeader />
@@ -61,6 +105,7 @@ export default async function VehicleDetailPage({
           serviceType={type}
           currencyCode={currencyCode}
           unitSystem={unitSystem}
+          predictionData={predictionData}
         />
       </div>
     </>

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

@@ -46,6 +46,7 @@ import {
   Plus,
   StickyNote,
   Trash2,
+  TrendingUp,
   Wrench,
 } from 'lucide-react'
 import {
@@ -155,6 +156,7 @@ export function VehicleDetailClient({
   serviceType,
   currencyCode = 'USD',
   unitSystem = 'imperial',
+  predictionData,
 }: {
   vehicle: VehicleDetail
   customers: CustomerOption[]
@@ -163,6 +165,14 @@ export function VehicleDetailClient({
   serviceType: string
   currencyCode?: string
   unitSystem?: 'metric' | 'imperial'
+  predictionData?: {
+    predictedMileage: number
+    avgPerDay: number
+    lastServiceMileage: number
+    serviceInterval: number
+    mileageSinceLastService: number
+    status: 'overdue' | 'approaching' | 'ok'
+  } | null
 }) {
   const distUnit = unitSystem === 'metric' ? 'km' : 'mi'
   const router = useRouter()
@@ -408,6 +418,25 @@ export function VehicleDetailClient({
             </span>
             <span className="text-xs">{distUnit}</span>
           </div>
+          {predictionData && (
+            <div className="flex items-center gap-1.5 text-muted-foreground">
+              <TrendingUp className="h-3.5 w-3.5" />
+              <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>
+              )}
+            </div>
+          )}
           <div className="flex items-center gap-1.5 text-muted-foreground">
             <Wrench className="h-3.5 w-3.5" />
             <span className="font-semibold text-foreground">{vehicle._count.serviceRecords}</span>

+ 3 - 0
src/features/settings/Schema/settingsSchema.ts

@@ -57,6 +57,9 @@ export const SETTING_KEYS = {
   QUOTE_PRIMARY_COLOR: "quote.primaryColor",
   QUOTE_FONT_FAMILY: "quote.fontFamily",
   QUOTE_HEADER_STYLE: "quote.headerStyle",
+  PREDICTED_MAINTENANCE_ENABLED: "maintenance.enabled",
+  MAINTENANCE_SERVICE_INTERVAL: "maintenance.serviceInterval",
+  MAINTENANCE_APPROACHING_THRESHOLD: "maintenance.approachingThreshold",
 } as const;
 
 export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS];

+ 199 - 0
src/features/vehicles/Actions/predictedMaintenanceActions.ts

@@ -0,0 +1,199 @@
+"use server";
+
+import { db } from "@/lib/db";
+import { withAuth } from "@/lib/with-auth";
+import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+
+export type PredictedMileage = {
+  predictedMileage: number;
+  avgPerDay: number;
+  lastServiceDate: Date;
+  lastServiceMileage: number;
+  confidence: number;
+};
+
+export type VehicleDueForService = {
+  vehicleId: string;
+  make: string;
+  model: string;
+  year: number;
+  licensePlate: string | null;
+  predictedMileage: number;
+  lastServiceMileage: number;
+  mileageSinceLastService: number;
+  serviceInterval: number;
+  status: "overdue" | "approaching";
+};
+
+export async function getVehiclePredictedMileage(vehicleId: string) {
+  return withAuth(async ({ organizationId }) => {
+    const records = await db.serviceRecord.findMany({
+      where: {
+        vehicleId,
+        vehicle: { organizationId },
+        mileage: { not: null },
+        status: "completed",
+      },
+      orderBy: { serviceDate: "asc" },
+      select: { serviceDate: true, mileage: true },
+    });
+
+    if (records.length < 2) return null;
+
+    const dataPoints = records.map((r) => ({
+      date: new Date(r.serviceDate),
+      mileage: r.mileage!,
+    }));
+
+    const earliest = dataPoints[0];
+    const latest = dataPoints[dataPoints.length - 1];
+
+    // Linear regression: calculate average distance per day across all data points
+    const totalDays =
+      (latest.date.getTime() - earliest.date.getTime()) / (1000 * 60 * 60 * 24);
+
+    if (totalDays <= 0) return null;
+
+    const totalMileage = latest.mileage - earliest.mileage;
+    const avgPerDay = totalMileage / totalDays;
+
+    if (avgPerDay <= 0) return null;
+
+    // Project from latest known data point to today
+    const daysSinceLatest =
+      (Date.now() - latest.date.getTime()) / (1000 * 60 * 60 * 24);
+    const predictedMileage = Math.round(
+      latest.mileage + daysSinceLatest * avgPerDay
+    );
+
+    return {
+      predictedMileage,
+      avgPerDay: Math.round(avgPerDay * 10) / 10,
+      lastServiceDate: latest.date,
+      lastServiceMileage: latest.mileage,
+      confidence: dataPoints.length,
+    } satisfies PredictedMileage;
+  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.VEHICLES }] });
+}
+
+export async function getVehiclesDueForService() {
+  return withAuth(async ({ organizationId }) => {
+    // Check if feature is enabled
+    const settings = await db.appSetting.findMany({
+      where: {
+        organizationId,
+        key: {
+          in: [
+            SETTING_KEYS.PREDICTED_MAINTENANCE_ENABLED,
+            SETTING_KEYS.MAINTENANCE_SERVICE_INTERVAL,
+            SETTING_KEYS.MAINTENANCE_APPROACHING_THRESHOLD,
+          ],
+        },
+      },
+    });
+
+    const settingsMap: Record<string, string> = {};
+    for (const s of settings) {
+      settingsMap[s.key] = s.value;
+    }
+
+    if (settingsMap[SETTING_KEYS.PREDICTED_MAINTENANCE_ENABLED] !== "true") {
+      return [];
+    }
+
+    const serviceInterval = parseInt(
+      settingsMap[SETTING_KEYS.MAINTENANCE_SERVICE_INTERVAL] || "15000",
+      10
+    );
+    const approachingThreshold = parseInt(
+      settingsMap[SETTING_KEYS.MAINTENANCE_APPROACHING_THRESHOLD] || "1000",
+      10
+    );
+
+    // Get all non-archived vehicles with their service records that have mileage
+    const vehicles = await db.vehicle.findMany({
+      where: { organizationId, isArchived: false },
+      select: {
+        id: true,
+        make: true,
+        model: true,
+        year: true,
+        licensePlate: true,
+        serviceRecords: {
+          where: { mileage: { not: null }, status: "completed" },
+          orderBy: { serviceDate: "asc" },
+          select: { serviceDate: true, mileage: true },
+        },
+      },
+    });
+
+    const results: VehicleDueForService[] = [];
+
+    for (const vehicle of vehicles) {
+      const records = vehicle.serviceRecords;
+      if (records.length < 2) continue;
+
+      const earliest = records[0];
+      const latest = records[records.length - 1];
+
+      const totalDays =
+        (new Date(latest.serviceDate).getTime() -
+          new Date(earliest.serviceDate).getTime()) /
+        (1000 * 60 * 60 * 24);
+
+      if (totalDays <= 0) continue;
+
+      const totalMileage = latest.mileage! - earliest.mileage!;
+      const avgPerDay = totalMileage / totalDays;
+
+      if (avgPerDay <= 0) continue;
+
+      const daysSinceLatest =
+        (Date.now() - new Date(latest.serviceDate).getTime()) /
+        (1000 * 60 * 60 * 24);
+      const predictedMileage = Math.round(
+        latest.mileage! + daysSinceLatest * avgPerDay
+      );
+
+      const lastServiceMileage = latest.mileage!;
+      const mileageSinceLastService = predictedMileage - lastServiceMileage;
+
+      let status: "overdue" | "approaching" | null = null;
+
+      if (mileageSinceLastService >= serviceInterval) {
+        status = "overdue";
+      } else if (
+        mileageSinceLastService >=
+        serviceInterval - approachingThreshold
+      ) {
+        status = "approaching";
+      }
+
+      if (status) {
+        results.push({
+          vehicleId: vehicle.id,
+          make: vehicle.make,
+          model: vehicle.model,
+          year: vehicle.year,
+          licensePlate: vehicle.licensePlate,
+          predictedMileage,
+          lastServiceMileage,
+          mileageSinceLastService,
+          serviceInterval,
+          status,
+        });
+      }
+    }
+
+    // Sort: overdue first, then by mileage since last service descending
+    results.sort((a, b) => {
+      if (a.status !== b.status) {
+        return a.status === "overdue" ? -1 : 1;
+      }
+      return b.mileageSinceLastService - a.mileageSinceLastService;
+    });
+
+    return results;
+  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.VEHICLES }] });
+}