فهرست منبع

recurring payments

Bernt Christian Egeland 7 ماه پیش
والد
کامیت
7940aec6a8
29فایلهای تغییر یافته به همراه3174 افزوده شده و 110 حذف شده
  1. 25 5
      plans/project_overview.md
  2. 59 0
      prisma/migrations/20260216105753_recurring_invoices/migration.sql
  3. 62 5
      prisma/schema.prisma
  4. 9 0
      src/app/(authenticated)/billing/billing-client.tsx
  5. 49 0
      src/app/(authenticated)/billing/recurring/page.tsx
  6. 29 0
      src/app/(authenticated)/calendar/page.tsx
  7. 446 30
      src/app/(authenticated)/reports/reports-client.tsx
  8. 0 8
      src/app/api/backup/import-invoice-ninja/route.ts
  9. 156 0
      src/app/api/cron/recurring-invoices/route.ts
  10. 4 0
      src/app/api/share/invoice/[orgId]/[token]/checkout/route.ts
  11. 4 0
      src/app/api/share/invoice/[orgId]/[token]/pdf/route.ts
  12. 4 0
      src/app/api/share/invoice/[orgId]/[token]/verify/route.ts
  13. 2 0
      src/components/app-sidebar.tsx
  14. 163 62
      src/features/billing/Actions/billingActions.ts
  15. 332 0
      src/features/billing/Actions/recurringInvoiceActions.ts
  16. 545 0
      src/features/billing/Components/RecurringInvoicesClient.tsx
  17. 36 0
      src/features/billing/Schema/recurringInvoiceSchema.ts
  18. 101 0
      src/features/calendar/Actions/calendarActions.ts
  19. 175 0
      src/features/calendar/Components/CalendarClient.tsx
  20. 62 0
      src/features/calendar/Components/CalendarDayCell.tsx
  21. 84 0
      src/features/calendar/Components/CalendarEventList.tsx
  22. 276 0
      src/features/reports/Actions/reportActions.ts
  23. 155 0
      src/features/reports/Components/JobAnalyticsCharts.tsx
  24. 69 0
      src/features/reports/Components/PartsCharts.tsx
  25. 63 0
      src/features/reports/Components/RetentionCharts.tsx
  26. 61 0
      src/features/reports/Components/TechnicianCharts.tsx
  27. 72 0
      src/features/reports/Components/csv-export.ts
  28. 74 0
      src/features/reports/Schema/reportTypes.ts
  29. 57 0
      src/lib/rate-limit.ts

+ 25 - 5
plans/project_overview.md

@@ -18,8 +18,28 @@ For submenus, use same layout as /settings were we have a child sidebar.
 
 Task List:
 
-- Create email providers:
-  - Postmark
-  - Mailgun
-  - SendGrid
-  - Amazon SES
+  1. Recurring invoices
+
+  Common for fleet customers or maintenance contracts. Currently there's no way to schedule automatic invoice generation.
+
+  2. Billing page shows payment status but filters in memory
+
+  getBillingHistory fetches all records then filters by payment status in JS. Should be a database query for performance at scale.
+
+  3. No rate limiting on public invoice endpoints
+
+  /share/invoice/[orgId]/[token] is open to the internet with no rate limiting. Easy to add middleware.
+
+  4. Reports are basic
+
+  Only revenue by month and by service type. Missing: technician productivity, average job time, most common services, customer retention,
+  parts usage.
+
+  5. No calendar/scheduling view
+
+  Shops need to see upcoming appointments. A simple calendar view using existing service records + reminders would be very useful.
+
+  10. Console.log cleanup in API routes
+
+  Several API routes use console.log/console.error for error handling. Replace with a proper
+  logger or remove for production. Low effort, improves production hygiene.

+ 59 - 0
prisma/migrations/20260216105753_recurring_invoices/migration.sql

@@ -0,0 +1,59 @@
+-- CreateTable
+CREATE TABLE "recurring_invoices" (
+    "id" TEXT NOT NULL,
+    "title" TEXT NOT NULL,
+    "description" TEXT,
+    "frequency" TEXT NOT NULL,
+    "nextRunDate" TIMESTAMP(3) NOT NULL,
+    "endDate" TIMESTAMP(3),
+    "isActive" BOOLEAN NOT NULL DEFAULT true,
+    "lastRunAt" TIMESTAMP(3),
+    "runCount" INTEGER NOT NULL DEFAULT 0,
+    "type" TEXT NOT NULL DEFAULT 'maintenance',
+    "cost" DOUBLE PRECISION NOT NULL DEFAULT 0,
+    "taxRate" DOUBLE PRECISION NOT NULL DEFAULT 0,
+    "invoiceNotes" TEXT,
+    "vehicleId" TEXT NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+
+    CONSTRAINT "recurring_invoices_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "recurring_parts" (
+    "id" TEXT NOT NULL,
+    "name" TEXT NOT NULL,
+    "partNumber" TEXT,
+    "quantity" INTEGER NOT NULL DEFAULT 1,
+    "unitPrice" DOUBLE PRECISION NOT NULL DEFAULT 0,
+    "recurringInvoiceId" TEXT NOT NULL,
+
+    CONSTRAINT "recurring_parts_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "recurring_labor" (
+    "id" TEXT NOT NULL,
+    "description" TEXT NOT NULL,
+    "hours" DOUBLE PRECISION NOT NULL DEFAULT 0,
+    "rate" DOUBLE PRECISION NOT NULL DEFAULT 0,
+    "recurringInvoiceId" TEXT NOT NULL,
+
+    CONSTRAINT "recurring_labor_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "recurring_invoices_vehicleId_idx" ON "recurring_invoices"("vehicleId");
+
+-- CreateIndex
+CREATE INDEX "recurring_invoices_nextRunDate_isActive_idx" ON "recurring_invoices"("nextRunDate", "isActive");
+
+-- AddForeignKey
+ALTER TABLE "recurring_invoices" ADD CONSTRAINT "recurring_invoices_vehicleId_fkey" FOREIGN KEY ("vehicleId") REFERENCES "vehicles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "recurring_parts" ADD CONSTRAINT "recurring_parts_recurringInvoiceId_fkey" FOREIGN KEY ("recurringInvoiceId") REFERENCES "recurring_invoices"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "recurring_labor" ADD CONSTRAINT "recurring_labor_recurringInvoiceId_fkey" FOREIGN KEY ("recurringInvoiceId") REFERENCES "recurring_invoices"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 62 - 5
prisma/schema.prisma

@@ -133,11 +133,12 @@ model Vehicle {
   customerId String?
   customer   Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
 
-  serviceRecords ServiceRecord[]
-  notes          Note[]
-  fuelLogs       FuelLog[]
-  reminders      Reminder[]
-  quotes         Quote[]
+  serviceRecords    ServiceRecord[]
+  notes             Note[]
+  fuelLogs          FuelLog[]
+  reminders         Reminder[]
+  quotes            Quote[]
+  recurringInvoices RecurringInvoice[]
 
   @@index([organizationId])
   @@map("vehicles")
@@ -587,3 +588,59 @@ model Subscription {
 
   @@map("subscriptions")
 }
+
+model RecurringInvoice {
+  id            String    @id @default(cuid())
+  title         String
+  description   String?
+  frequency     String    // "weekly" | "biweekly" | "monthly" | "quarterly" | "yearly"
+  nextRunDate   DateTime
+  endDate       DateTime?
+  isActive      Boolean   @default(true)
+  lastRunAt     DateTime?
+  runCount      Int       @default(0)
+
+  // Template fields (copied to ServiceRecord on generation)
+  type          String    @default("maintenance")
+  cost          Float     @default(0)
+  taxRate       Float     @default(0)
+  invoiceNotes  String?
+
+  vehicleId     String
+  vehicle       Vehicle   @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
+
+  createdAt     DateTime  @default(now())
+  updatedAt     DateTime  @updatedAt
+
+  templateParts RecurringPart[]
+  templateLabor RecurringLabor[]
+
+  @@index([vehicleId])
+  @@index([nextRunDate, isActive])
+  @@map("recurring_invoices")
+}
+
+model RecurringPart {
+  id                 String  @id @default(cuid())
+  name               String
+  partNumber         String?
+  quantity           Int     @default(1)
+  unitPrice          Float   @default(0)
+
+  recurringInvoiceId String
+  recurringInvoice   RecurringInvoice @relation(fields: [recurringInvoiceId], references: [id], onDelete: Cascade)
+
+  @@map("recurring_parts")
+}
+
+model RecurringLabor {
+  id                 String  @id @default(cuid())
+  description        String
+  hours              Float   @default(0)
+  rate               Float   @default(0)
+
+  recurringInvoiceId String
+  recurringInvoice   RecurringInvoice @relation(fields: [recurringInvoiceId], references: [id], onDelete: Cascade)
+
+  @@map("recurring_labor")
+}

+ 9 - 0
src/app/(authenticated)/billing/billing-client.tsx

@@ -1,6 +1,7 @@
 "use client";
 
 import { useState, useCallback, useTransition } from "react";
+import Link from "next/link";
 import { useRouter, usePathname, useSearchParams } from "next/navigation";
 import { format } from "date-fns";
 import { Button } from "@/components/ui/button";
@@ -175,6 +176,14 @@ export default function BillingClient({
 
   return (
     <div className="space-y-6">
+      {/* Navigation */}
+      <div className="flex items-center gap-3">
+        <Button variant="outline" size="sm" disabled>Billing History</Button>
+        <Link href="/billing/recurring">
+          <Button variant="outline" size="sm">Recurring</Button>
+        </Link>
+      </div>
+
       {/* Summary Cards */}
       <div className="grid gap-3 md:grid-cols-3">
         <Card className="border-0 shadow-sm">

+ 49 - 0
src/app/(authenticated)/billing/recurring/page.tsx

@@ -0,0 +1,49 @@
+import { getRecurringInvoices } from "@/features/billing/Actions/recurringInvoiceActions";
+import { getSettings } from "@/features/settings/Actions/settingsActions";
+import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
+import { PageHeader } from "@/components/page-header";
+import RecurringInvoicesClient from "@/features/billing/Components/RecurringInvoicesClient";
+import { db } from "@/lib/db";
+import { getCachedSession, getCachedMembership } from "@/lib/cached-session";
+import { redirect } from "next/navigation";
+
+export default async function RecurringInvoicesPage() {
+  const session = await getCachedSession();
+  if (!session?.user?.id) redirect("/auth/sign-in");
+
+  const membership = await getCachedMembership(session.user.id);
+  if (!membership?.organizationId) redirect("/onboarding");
+
+  const [result, settingsResult, vehicles] = await Promise.all([
+    getRecurringInvoices(),
+    getSettings([SETTING_KEYS.CURRENCY_CODE]),
+    db.vehicle.findMany({
+      where: { organizationId: membership.organizationId, isArchived: false },
+      select: {
+        id: true,
+        make: true,
+        model: true,
+        year: true,
+        customer: { select: { id: true, name: true } },
+      },
+      orderBy: [{ make: "asc" }, { model: "asc" }],
+    }),
+  ]);
+
+  const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {};
+  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || "USD";
+  const invoices = result.success && result.data ? result.data : [];
+
+  return (
+    <>
+      <PageHeader />
+      <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
+        <RecurringInvoicesClient
+          invoices={invoices}
+          vehicles={vehicles}
+          currencyCode={currencyCode}
+        />
+      </div>
+    </>
+  );
+}

+ 29 - 0
src/app/(authenticated)/calendar/page.tsx

@@ -0,0 +1,29 @@
+import { getCalendarEvents } from "@/features/calendar/Actions/calendarActions";
+import { PageHeader } from "@/components/page-header";
+import CalendarClient from "@/features/calendar/Components/CalendarClient";
+
+export default async function CalendarPage() {
+  const now = new Date();
+  const start = new Date(now.getFullYear(), now.getMonth(), 1);
+  const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
+
+  const result = await getCalendarEvents({
+    start: start.toISOString().split("T")[0],
+    end: end.toISOString().split("T")[0],
+  });
+
+  const events = result.success && result.data ? result.data : [];
+
+  return (
+    <>
+      <PageHeader />
+      <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
+        <CalendarClient
+          initialEvents={events}
+          initialMonth={now.getMonth()}
+          initialYear={now.getFullYear()}
+        />
+      </div>
+    </>
+  );
+}

+ 446 - 30
src/app/(authenticated)/reports/reports-client.tsx

@@ -28,12 +28,20 @@ import {
   Download,
   TrendingUp,
   AlertTriangle,
+  Wrench,
+  Cog,
+  CalendarDays,
+  UserCheck,
 } from "lucide-react";
 import {
   getRevenueReport,
   getServiceReport,
   getCustomerReport,
   getInventoryReport,
+  getTechnicianReport,
+  getPartsUsageReport,
+  getJobAnalyticsReport,
+  getCustomerRetentionReport,
 } from "@/features/reports/Actions/reportActions";
 import { formatCurrency } from "@/lib/format";
 import type {
@@ -41,18 +49,30 @@ import type {
   ServiceReport,
   CustomerReport,
   InventoryReport,
+  TechnicianReport,
+  PartsUsageReport,
+  JobAnalyticsReport,
+  CustomerRetentionReport,
 } from "@/features/reports/Schema/reportTypes";
 import { RevenueBarChart, RevenueTypeDonut } from "@/features/reports/Components/RevenueCharts";
 import { ServiceStatusChart, ServiceTypeDonut } from "@/features/reports/Components/ServiceCharts";
 import { TopCustomersChart } from "@/features/reports/Components/CustomerCharts";
+import { TechnicianBarChart } from "@/features/reports/Components/TechnicianCharts";
+import { PartsDonut } from "@/features/reports/Components/PartsCharts";
+import { DayOfWeekChart, ServiceTypeAnalyticsDonut, MonthlyTrendChart } from "@/features/reports/Components/JobAnalyticsCharts";
+import { RetentionBarChart } from "@/features/reports/Components/RetentionCharts";
 import {
   exportRevenueCsv,
   exportServicesCsv,
   exportCustomersCsv,
   exportInventoryCsv,
+  exportTechniciansCsv,
+  exportPartsCsv,
+  exportJobAnalyticsCsv,
+  exportRetentionCsv,
 } from "@/features/reports/Components/csv-export";
 
-type ReportTab = "revenue" | "services" | "customers" | "inventory";
+type ReportTab = "revenue" | "services" | "customers" | "inventory" | "technicians" | "parts" | "job-analytics" | "retention";
 
 interface ReportsClientProps {
   currencyCode: string;
@@ -72,6 +92,10 @@ export default function ReportsClient({ currencyCode }: ReportsClientProps) {
   const [serviceData, setServiceData] = useState<ServiceReport | null>(null);
   const [customerData, setCustomerData] = useState<CustomerReport | null>(null);
   const [inventoryData, setInventoryData] = useState<InventoryReport | null>(null);
+  const [technicianData, setTechnicianData] = useState<TechnicianReport | null>(null);
+  const [partsData, setPartsData] = useState<PartsUsageReport | null>(null);
+  const [jobAnalyticsData, setJobAnalyticsData] = useState<JobAnalyticsReport | null>(null);
+  const [retentionData, setRetentionData] = useState<CustomerRetentionReport | null>(null);
 
   const fmtCurrency = useCallback(
     (value: number) => formatCurrency(value, currencyCode),
@@ -104,6 +128,26 @@ export default function ReportsClient({ currencyCode }: ReportsClientProps) {
             if (result.success && result.data) setInventoryData(result.data);
             break;
           }
+          case "technicians": {
+            const result = await getTechnicianReport(dateParams);
+            if (result.success && result.data) setTechnicianData(result.data);
+            break;
+          }
+          case "parts": {
+            const result = await getPartsUsageReport(dateParams);
+            if (result.success && result.data) setPartsData(result.data);
+            break;
+          }
+          case "job-analytics": {
+            const result = await getJobAnalyticsReport(dateParams);
+            if (result.success && result.data) setJobAnalyticsData(result.data);
+            break;
+          }
+          case "retention": {
+            const result = await getCustomerRetentionReport(dateParams);
+            if (result.success && result.data) setRetentionData(result.data);
+            break;
+          }
         }
       } catch (error) {
         console.error("Failed to fetch report:", error);
@@ -122,13 +166,17 @@ export default function ReportsClient({ currencyCode }: ReportsClientProps) {
   const handleTabChange = (value: string) => {
     const tab = value as ReportTab;
     setActiveTab(tab);
-    // Fetch if not already loaded
-    if (
-      (tab === "revenue" && !revenueData) ||
-      (tab === "services" && !serviceData) ||
-      (tab === "customers" && !customerData) ||
-      (tab === "inventory" && !inventoryData)
-    ) {
+    const dataMap: Record<ReportTab, unknown> = {
+      revenue: revenueData,
+      services: serviceData,
+      customers: customerData,
+      inventory: inventoryData,
+      technicians: technicianData,
+      parts: partsData,
+      "job-analytics": jobAnalyticsData,
+      retention: retentionData,
+    };
+    if (!dataMap[tab]) {
       fetchReport(tab);
     }
   };
@@ -151,20 +199,40 @@ export default function ReportsClient({ currencyCode }: ReportsClientProps) {
       case "inventory":
         if (inventoryData) exportInventoryCsv(inventoryData, currencyCode);
         break;
+      case "technicians":
+        if (technicianData) exportTechniciansCsv(technicianData, currencyCode);
+        break;
+      case "parts":
+        if (partsData) exportPartsCsv(partsData, currencyCode);
+        break;
+      case "job-analytics":
+        if (jobAnalyticsData) exportJobAnalyticsCsv(jobAnalyticsData, currencyCode);
+        break;
+      case "retention":
+        if (retentionData) exportRetentionCsv(retentionData, currencyCode);
+        break;
     }
   };
 
-  const hasData =
-    (activeTab === "revenue" && revenueData) ||
-    (activeTab === "services" && serviceData) ||
-    (activeTab === "customers" && customerData) ||
-    (activeTab === "inventory" && inventoryData);
+  const dataMap: Record<ReportTab, unknown> = {
+    revenue: revenueData,
+    services: serviceData,
+    customers: customerData,
+    inventory: inventoryData,
+    technicians: technicianData,
+    parts: partsData,
+    "job-analytics": jobAnalyticsData,
+    retention: retentionData,
+  };
+  const hasData = !!dataMap[activeTab];
+
+  const showDateRange = activeTab !== "inventory";
 
   return (
     <Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-4">
       {/* Tab bar with date range and actions */}
       <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
-        <TabsList>
+        <TabsList className="flex-wrap h-auto">
           <TabsTrigger value="revenue" className="gap-1.5">
             <DollarSign className="h-4 w-4" />
             Revenue
@@ -181,10 +249,26 @@ export default function ReportsClient({ currencyCode }: ReportsClientProps) {
             <Package className="h-4 w-4" />
             Inventory
           </TabsTrigger>
+          <TabsTrigger value="technicians" className="gap-1.5">
+            <Wrench className="h-4 w-4" />
+            Technicians
+          </TabsTrigger>
+          <TabsTrigger value="parts" className="gap-1.5">
+            <Cog className="h-4 w-4" />
+            Parts
+          </TabsTrigger>
+          <TabsTrigger value="job-analytics" className="gap-1.5">
+            <CalendarDays className="h-4 w-4" />
+            Job Analytics
+          </TabsTrigger>
+          <TabsTrigger value="retention" className="gap-1.5">
+            <UserCheck className="h-4 w-4" />
+            Retention
+          </TabsTrigger>
         </TabsList>
 
         <div className="flex items-center gap-2">
-          {activeTab !== "inventory" && (
+          {showDateRange && (
             <>
               <Input
                 type="date"
@@ -343,7 +427,6 @@ export default function ReportsClient({ currencyCode }: ReportsClientProps) {
       <TabsContent value="services">
         {!loading && serviceData && (
           <div className="space-y-4">
-            {/* Summary card */}
             <div className="grid gap-3 grid-cols-1 max-w-xs">
               <Card className="border-0 shadow-sm">
                 <CardContent className="flex items-center gap-3 p-4">
@@ -357,8 +440,6 @@ export default function ReportsClient({ currencyCode }: ReportsClientProps) {
                 </CardContent>
               </Card>
             </div>
-
-            {/* Charts */}
             <div className="grid gap-4 lg:grid-cols-2">
               {serviceData.byStatus.length > 0 && (
                 <Card className="border-0 shadow-sm">
@@ -390,7 +471,6 @@ export default function ReportsClient({ currencyCode }: ReportsClientProps) {
       <TabsContent value="customers">
         {!loading && customerData && (
           <div className="space-y-4">
-            {/* Summary cards */}
             <div className="grid gap-3 grid-cols-2 max-w-lg">
               <Card className="border-0 shadow-sm">
                 <CardContent className="flex items-center gap-3 p-4">
@@ -415,23 +495,16 @@ export default function ReportsClient({ currencyCode }: ReportsClientProps) {
                 </CardContent>
               </Card>
             </div>
-
-            {/* Chart */}
             {customerData.topCustomers.length > 0 && (
               <Card className="border-0 shadow-sm">
                 <CardHeader className="pb-2">
                   <CardTitle className="text-sm font-medium">Top Customers by Spend</CardTitle>
                 </CardHeader>
                 <CardContent>
-                  <TopCustomersChart
-                    data={customerData.topCustomers}
-                    formatCurrency={fmtCurrency}
-                  />
+                  <TopCustomersChart data={customerData.topCustomers} formatCurrency={fmtCurrency} />
                 </CardContent>
               </Card>
             )}
-
-            {/* Table */}
             {customerData.topCustomers.length > 0 && (
               <Card className="border-0 shadow-sm">
                 <CardHeader className="pb-2">
@@ -472,7 +545,6 @@ export default function ReportsClient({ currencyCode }: ReportsClientProps) {
       <TabsContent value="inventory">
         {!loading && inventoryData && (
           <div className="space-y-4">
-            {/* Summary cards */}
             <div className="grid gap-3 grid-cols-3">
               <Card className="border-0 shadow-sm">
                 <CardContent className="flex items-center gap-3 p-4">
@@ -510,8 +582,6 @@ export default function ReportsClient({ currencyCode }: ReportsClientProps) {
                 </CardContent>
               </Card>
             </div>
-
-            {/* Low stock table */}
             {inventoryData.lowStock.length > 0 && (
               <Card className="border-0 shadow-sm">
                 <CardHeader className="pb-2">
@@ -557,6 +627,352 @@ export default function ReportsClient({ currencyCode }: ReportsClientProps) {
         )}
         {!loading && !inventoryData && <EmptyState />}
       </TabsContent>
+
+      {/* Technicians Tab */}
+      <TabsContent value="technicians">
+        {!loading && technicianData && (
+          <div className="space-y-4">
+            <div className="grid gap-3 grid-cols-2 max-w-lg">
+              <Card className="border-0 shadow-sm">
+                <CardContent className="flex items-center gap-3 p-4">
+                  <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-violet-500/10">
+                    <Wrench className="h-4 w-4 text-violet-500" />
+                  </div>
+                  <div className="min-w-0">
+                    <p className="text-xs text-muted-foreground">Total Jobs</p>
+                    <p className="text-lg font-semibold">{technicianData.totalJobs}</p>
+                  </div>
+                </CardContent>
+              </Card>
+              <Card className="border-0 shadow-sm">
+                <CardContent className="flex items-center gap-3 p-4">
+                  <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-emerald-500/10">
+                    <DollarSign className="h-4 w-4 text-emerald-500" />
+                  </div>
+                  <div className="min-w-0">
+                    <p className="text-xs text-muted-foreground">Total Revenue</p>
+                    <p className="text-lg font-semibold truncate">
+                      {fmtCurrency(technicianData.totalRevenue)}
+                    </p>
+                  </div>
+                </CardContent>
+              </Card>
+            </div>
+            {technicianData.technicians.length > 0 && (
+              <Card className="border-0 shadow-sm">
+                <CardHeader className="pb-2">
+                  <CardTitle className="text-sm font-medium">Revenue by Technician</CardTitle>
+                </CardHeader>
+                <CardContent>
+                  <TechnicianBarChart data={technicianData.technicians} formatCurrency={fmtCurrency} />
+                </CardContent>
+              </Card>
+            )}
+            {technicianData.technicians.length > 0 && (
+              <Card className="border-0 shadow-sm">
+                <CardHeader className="pb-2">
+                  <CardTitle className="text-sm font-medium">Technician Breakdown</CardTitle>
+                </CardHeader>
+                <CardContent>
+                  <Table>
+                    <TableHeader>
+                      <TableRow>
+                        <TableHead>Technician</TableHead>
+                        <TableHead className="text-right">Jobs</TableHead>
+                        <TableHead className="text-right">Revenue</TableHead>
+                        <TableHead className="text-right">Avg Revenue</TableHead>
+                        <TableHead className="text-right">Total Hours</TableHead>
+                        <TableHead className="text-right">Avg Hours</TableHead>
+                      </TableRow>
+                    </TableHeader>
+                    <TableBody>
+                      {technicianData.technicians.map((row) => (
+                        <TableRow key={row.techName}>
+                          <TableCell className="text-sm font-medium">{row.techName}</TableCell>
+                          <TableCell className="text-right text-sm">{row.jobCount}</TableCell>
+                          <TableCell className="text-right text-sm">{fmtCurrency(row.totalRevenue)}</TableCell>
+                          <TableCell className="text-right text-sm">{fmtCurrency(row.avgRevenue)}</TableCell>
+                          <TableCell className="text-right text-sm">{row.totalLaborHours.toFixed(1)}</TableCell>
+                          <TableCell className="text-right text-sm">{row.avgHours.toFixed(1)}</TableCell>
+                        </TableRow>
+                      ))}
+                    </TableBody>
+                  </Table>
+                </CardContent>
+              </Card>
+            )}
+          </div>
+        )}
+        {!loading && !technicianData && <EmptyState />}
+      </TabsContent>
+
+      {/* Parts Tab */}
+      <TabsContent value="parts">
+        {!loading && partsData && (
+          <div className="space-y-4">
+            <div className="grid gap-3 grid-cols-2 max-w-lg">
+              <Card className="border-0 shadow-sm">
+                <CardContent className="flex items-center gap-3 p-4">
+                  <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-blue-500/10">
+                    <Cog className="h-4 w-4 text-blue-500" />
+                  </div>
+                  <div className="min-w-0">
+                    <p className="text-xs text-muted-foreground">Parts Used</p>
+                    <p className="text-lg font-semibold">{partsData.totalPartsUsed}</p>
+                  </div>
+                </CardContent>
+              </Card>
+              <Card className="border-0 shadow-sm">
+                <CardContent className="flex items-center gap-3 p-4">
+                  <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-emerald-500/10">
+                    <DollarSign className="h-4 w-4 text-emerald-500" />
+                  </div>
+                  <div className="min-w-0">
+                    <p className="text-xs text-muted-foreground">Parts Revenue</p>
+                    <p className="text-lg font-semibold truncate">
+                      {fmtCurrency(partsData.totalPartsRevenue)}
+                    </p>
+                  </div>
+                </CardContent>
+              </Card>
+            </div>
+            {partsData.parts.length > 0 && (
+              <Card className="border-0 shadow-sm">
+                <CardHeader className="pb-2">
+                  <CardTitle className="text-sm font-medium">Top Parts by Usage</CardTitle>
+                </CardHeader>
+                <CardContent>
+                  <PartsDonut data={partsData.parts} />
+                </CardContent>
+              </Card>
+            )}
+            {partsData.parts.length > 0 && (
+              <Card className="border-0 shadow-sm">
+                <CardHeader className="pb-2">
+                  <CardTitle className="text-sm font-medium">Parts Usage</CardTitle>
+                </CardHeader>
+                <CardContent>
+                  <Table>
+                    <TableHeader>
+                      <TableRow>
+                        <TableHead>Part Name</TableHead>
+                        <TableHead>Part #</TableHead>
+                        <TableHead className="text-right">Usage Count</TableHead>
+                        <TableHead className="text-right">Total Qty</TableHead>
+                        <TableHead className="text-right">Revenue</TableHead>
+                      </TableRow>
+                    </TableHeader>
+                    <TableBody>
+                      {partsData.parts.map((row) => (
+                        <TableRow key={row.name}>
+                          <TableCell className="text-sm font-medium">{row.name}</TableCell>
+                          <TableCell className="text-sm">{row.partNumber ?? "-"}</TableCell>
+                          <TableCell className="text-right text-sm">{row.usageCount}</TableCell>
+                          <TableCell className="text-right text-sm">{row.totalQuantity}</TableCell>
+                          <TableCell className="text-right text-sm">{fmtCurrency(row.totalRevenue)}</TableCell>
+                        </TableRow>
+                      ))}
+                    </TableBody>
+                  </Table>
+                </CardContent>
+              </Card>
+            )}
+          </div>
+        )}
+        {!loading && !partsData && <EmptyState />}
+      </TabsContent>
+
+      {/* Job Analytics Tab */}
+      <TabsContent value="job-analytics">
+        {!loading && jobAnalyticsData && (
+          <div className="space-y-4">
+            <div className="grid gap-3 grid-cols-2 max-w-lg">
+              <Card className="border-0 shadow-sm">
+                <CardContent className="flex items-center gap-3 p-4">
+                  <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-blue-500/10">
+                    <BarChart3 className="h-4 w-4 text-blue-500" />
+                  </div>
+                  <div className="min-w-0">
+                    <p className="text-xs text-muted-foreground">Total Jobs</p>
+                    <p className="text-lg font-semibold">{jobAnalyticsData.totalJobs}</p>
+                  </div>
+                </CardContent>
+              </Card>
+              <Card className="border-0 shadow-sm">
+                <CardContent className="flex items-center gap-3 p-4">
+                  <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-emerald-500/10">
+                    <DollarSign className="h-4 w-4 text-emerald-500" />
+                  </div>
+                  <div className="min-w-0">
+                    <p className="text-xs text-muted-foreground">Avg Job Value</p>
+                    <p className="text-lg font-semibold truncate">
+                      {fmtCurrency(jobAnalyticsData.avgJobValue)}
+                    </p>
+                  </div>
+                </CardContent>
+              </Card>
+            </div>
+            <div className="grid gap-4 lg:grid-cols-2">
+              <Card className="border-0 shadow-sm">
+                <CardHeader className="pb-2">
+                  <CardTitle className="text-sm font-medium">Jobs by Day of Week</CardTitle>
+                </CardHeader>
+                <CardContent>
+                  <DayOfWeekChart data={jobAnalyticsData.dayOfWeek} />
+                </CardContent>
+              </Card>
+              {jobAnalyticsData.topServiceTypes.length > 0 && (
+                <Card className="border-0 shadow-sm">
+                  <CardHeader className="pb-2">
+                    <CardTitle className="text-sm font-medium">Service Types</CardTitle>
+                  </CardHeader>
+                  <CardContent>
+                    <ServiceTypeAnalyticsDonut data={jobAnalyticsData.topServiceTypes} />
+                  </CardContent>
+                </Card>
+              )}
+            </div>
+            {jobAnalyticsData.monthlyTrend.length > 0 && (
+              <Card className="border-0 shadow-sm">
+                <CardHeader className="pb-2">
+                  <CardTitle className="text-sm font-medium">Monthly Trend</CardTitle>
+                </CardHeader>
+                <CardContent>
+                  <MonthlyTrendChart data={jobAnalyticsData.monthlyTrend} formatCurrency={fmtCurrency} />
+                </CardContent>
+              </Card>
+            )}
+            {jobAnalyticsData.topServiceTypes.length > 0 && (
+              <Card className="border-0 shadow-sm">
+                <CardHeader className="pb-2">
+                  <CardTitle className="text-sm font-medium">Service Type Breakdown</CardTitle>
+                </CardHeader>
+                <CardContent>
+                  <Table>
+                    <TableHeader>
+                      <TableRow>
+                        <TableHead>Type</TableHead>
+                        <TableHead className="text-right">Count</TableHead>
+                        <TableHead className="text-right">Avg Value</TableHead>
+                        <TableHead className="text-right">Avg Hours</TableHead>
+                      </TableRow>
+                    </TableHeader>
+                    <TableBody>
+                      {jobAnalyticsData.topServiceTypes.map((row) => (
+                        <TableRow key={row.type}>
+                          <TableCell className="text-sm font-medium">{row.type}</TableCell>
+                          <TableCell className="text-right text-sm">{row.count}</TableCell>
+                          <TableCell className="text-right text-sm">{fmtCurrency(row.avgValue)}</TableCell>
+                          <TableCell className="text-right text-sm">{row.avgHours.toFixed(1)}</TableCell>
+                        </TableRow>
+                      ))}
+                    </TableBody>
+                  </Table>
+                </CardContent>
+              </Card>
+            )}
+          </div>
+        )}
+        {!loading && !jobAnalyticsData && <EmptyState />}
+      </TabsContent>
+
+      {/* Retention Tab */}
+      <TabsContent value="retention">
+        {!loading && retentionData && (
+          <div className="space-y-4">
+            <div className="grid gap-3 grid-cols-2 lg:grid-cols-4">
+              <Card className="border-0 shadow-sm">
+                <CardContent className="flex items-center gap-3 p-4">
+                  <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-emerald-500/10">
+                    <UserCheck className="h-4 w-4 text-emerald-500" />
+                  </div>
+                  <div className="min-w-0">
+                    <p className="text-xs text-muted-foreground">Returning</p>
+                    <p className="text-lg font-semibold">{retentionData.returningCustomers}</p>
+                  </div>
+                </CardContent>
+              </Card>
+              <Card className="border-0 shadow-sm">
+                <CardContent className="flex items-center gap-3 p-4">
+                  <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-blue-500/10">
+                    <Users className="h-4 w-4 text-blue-500" />
+                  </div>
+                  <div className="min-w-0">
+                    <p className="text-xs text-muted-foreground">New</p>
+                    <p className="text-lg font-semibold">{retentionData.newCustomers}</p>
+                  </div>
+                </CardContent>
+              </Card>
+              <Card className="border-0 shadow-sm">
+                <CardContent className="flex items-center gap-3 p-4">
+                  <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-violet-500/10">
+                    <Users className="h-4 w-4 text-violet-500" />
+                  </div>
+                  <div className="min-w-0">
+                    <p className="text-xs text-muted-foreground">Total Active</p>
+                    <p className="text-lg font-semibold">{retentionData.totalActive}</p>
+                  </div>
+                </CardContent>
+              </Card>
+              <Card className="border-0 shadow-sm">
+                <CardContent className="flex items-center gap-3 p-4">
+                  <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-amber-500/10">
+                    <CalendarDays className="h-4 w-4 text-amber-500" />
+                  </div>
+                  <div className="min-w-0">
+                    <p className="text-xs text-muted-foreground">Avg Days Between</p>
+                    <p className="text-lg font-semibold">
+                      {retentionData.avgTimeBetweenVisits ?? "-"}
+                    </p>
+                  </div>
+                </CardContent>
+              </Card>
+            </div>
+            {retentionData.topReturning.length > 0 && (
+              <Card className="border-0 shadow-sm">
+                <CardHeader className="pb-2">
+                  <CardTitle className="text-sm font-medium">Top Returning Customers</CardTitle>
+                </CardHeader>
+                <CardContent>
+                  <RetentionBarChart data={retentionData.topReturning} formatCurrency={fmtCurrency} />
+                </CardContent>
+              </Card>
+            )}
+            {retentionData.topReturning.length > 0 && (
+              <Card className="border-0 shadow-sm">
+                <CardHeader className="pb-2">
+                  <CardTitle className="text-sm font-medium">Returning Customers</CardTitle>
+                </CardHeader>
+                <CardContent>
+                  <Table>
+                    <TableHeader>
+                      <TableRow>
+                        <TableHead>Customer</TableHead>
+                        <TableHead>Company</TableHead>
+                        <TableHead className="text-right">Visits</TableHead>
+                        <TableHead className="text-right">Total Spent</TableHead>
+                        <TableHead className="text-right">Avg Days Between</TableHead>
+                      </TableRow>
+                    </TableHeader>
+                    <TableBody>
+                      {retentionData.topReturning.map((row) => (
+                        <TableRow key={row.id}>
+                          <TableCell className="text-sm font-medium">{row.name}</TableCell>
+                          <TableCell className="text-sm">{row.company ?? "-"}</TableCell>
+                          <TableCell className="text-right text-sm">{row.visitCount}</TableCell>
+                          <TableCell className="text-right text-sm">{fmtCurrency(row.totalSpent)}</TableCell>
+                          <TableCell className="text-right text-sm">{row.avgTimeBetweenVisits ?? "-"}</TableCell>
+                        </TableRow>
+                      ))}
+                    </TableBody>
+                  </Table>
+                </CardContent>
+              </Card>
+            )}
+          </div>
+        )}
+        {!loading && !retentionData && <EmptyState />}
+      </TabsContent>
     </Tabs>
   );
 }

+ 0 - 8
src/app/api/backup/import-invoice-ninja/route.ts

@@ -383,10 +383,6 @@ export async function POST(request: NextRequest) {
         const partItems = allItems.filter((li) => li.type_id === "1");
         const laborItems = allItems.filter((li) => li.type_id === "2");
 
-        console.log(
-          `[IN import] Invoice #${invoice.number}: ${allItems.length} items (${partItems.length} parts, ${laborItems.length} labor)`
-        );
-
         const amount = parseNum(invoice.amount);
         const discount = parseNum(invoice.discount);
 
@@ -474,10 +470,6 @@ export async function POST(request: NextRequest) {
           laborCreated++;
         }
 
-        console.log(
-          `[IN import] Invoice #${invoice.number}: created record ${record.id}, ${partsCreated} parts, ${laborCreated} labor`
-        );
-
         // ── Copy attached documents ─────────────────────────────────
         const invoiceDocs = docsByInvoice.get(invoice.hashed_id) || [];
         for (const doc of invoiceDocs) {

+ 156 - 0
src/app/api/cron/recurring-invoices/route.ts

@@ -0,0 +1,156 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { resolveInvoicePrefix } from "@/lib/invoice-utils";
+
+export async function GET(request: Request) {
+  const authHeader = request.headers.get("authorization");
+  const cronSecret = process.env.CRON_SECRET;
+
+  if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) {
+    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+  }
+
+  const now = new Date();
+
+  const dueInvoices = await db.recurringInvoice.findMany({
+    where: {
+      isActive: true,
+      nextRunDate: { lte: now },
+    },
+    include: {
+      templateParts: true,
+      templateLabor: true,
+      vehicle: { select: { organizationId: true } },
+    },
+  });
+
+  const results: { recurringInvoiceId: string; serviceRecordId: string }[] = [];
+
+  for (const ri of dueInvoices) {
+    const organizationId = ri.vehicle.organizationId;
+    if (!organizationId) continue;
+
+    // Generate invoice number for this org
+    const settings = await db.appSetting.findMany({
+      where: {
+        organizationId,
+        key: { in: ["workshop.invoicePrefix", "workshop.invoiceStartNumber"] },
+      },
+    });
+
+    const settingsMap: Record<string, string> = {};
+    for (const s of settings) settingsMap[s.key] = s.value;
+
+    const prefix = resolveInvoicePrefix(settingsMap["workshop.invoicePrefix"] || "{year}-");
+    const startNumber = parseInt(settingsMap["workshop.invoiceStartNumber"] || "0", 10);
+
+    const lastRecord = await db.serviceRecord.findFirst({
+      where: { vehicle: { organizationId } },
+      orderBy: { createdAt: "desc" },
+      select: { invoiceNumber: true },
+    });
+
+    let nextNum = startNumber || 1001;
+    if (lastRecord?.invoiceNumber) {
+      const match = lastRecord.invoiceNumber.match(/(\d+)$/);
+      if (match) {
+        const lastNum = parseInt(match[1], 10) + 1;
+        nextNum = Math.max(nextNum, lastNum);
+      }
+    }
+    const invoiceNumber = `${prefix}${nextNum}`;
+
+    // Calculate totals
+    const partsSubtotal = ri.templateParts.reduce((s, p) => s + p.quantity * p.unitPrice, 0);
+    const laborSubtotal = ri.templateLabor.reduce((s, l) => s + l.hours * l.rate, 0);
+    const subtotal = ri.cost + partsSubtotal + laborSubtotal;
+    const taxAmount = subtotal * (ri.taxRate / 100);
+    const totalAmount = subtotal + taxAmount;
+
+    const serviceRecord = await db.$transaction(async (tx) => {
+      const sr = await tx.serviceRecord.create({
+        data: {
+          title: ri.title,
+          description: ri.description,
+          type: ri.type,
+          status: "completed",
+          cost: ri.cost,
+          serviceDate: now,
+          invoiceNotes: ri.invoiceNotes,
+          subtotal,
+          taxRate: ri.taxRate,
+          taxAmount,
+          totalAmount,
+          invoiceNumber,
+          vehicleId: ri.vehicleId,
+          partItems: {
+            create: ri.templateParts.map((p) => ({
+              name: p.name,
+              partNumber: p.partNumber,
+              quantity: p.quantity,
+              unitPrice: p.unitPrice,
+              total: p.quantity * p.unitPrice,
+            })),
+          },
+          laborItems: {
+            create: ri.templateLabor.map((l) => ({
+              description: l.description,
+              hours: l.hours,
+              rate: l.rate,
+              total: l.hours * l.rate,
+            })),
+          },
+        },
+      });
+
+      // Update recurring invoice schedule
+      const nextRunDate = calculateNextRunDate(ri.nextRunDate, ri.frequency);
+      const shouldDeactivate = ri.endDate && nextRunDate > ri.endDate;
+
+      await tx.recurringInvoice.update({
+        where: { id: ri.id },
+        data: {
+          lastRunAt: now,
+          runCount: { increment: 1 },
+          nextRunDate,
+          ...(shouldDeactivate && { isActive: false }),
+        },
+      });
+
+      return sr;
+    });
+
+    results.push({
+      recurringInvoiceId: ri.id,
+      serviceRecordId: serviceRecord.id,
+    });
+  }
+
+  return NextResponse.json({
+    processed: results.length,
+    results,
+    timestamp: now.toISOString(),
+  });
+}
+
+function calculateNextRunDate(current: Date, frequency: string): Date {
+  const next = new Date(current);
+  switch (frequency) {
+    case "weekly":
+      next.setDate(next.getDate() + 7);
+      break;
+    case "biweekly":
+      next.setDate(next.getDate() + 14);
+      break;
+    case "monthly":
+      next.setMonth(next.getMonth() + 1);
+      break;
+    case "quarterly":
+      next.setMonth(next.getMonth() + 3);
+      break;
+    case "yearly":
+      next.setFullYear(next.getFullYear() + 1);
+      break;
+  }
+  return next;
+}

+ 4 - 0
src/app/api/share/invoice/[orgId]/[token]/checkout/route.ts

@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
 import { z } from "zod";
 import { db } from "@/lib/db";
 import { getPaymentProvider, getEnabledProviders } from "@/lib/payment-providers";
+import { rateLimit } from "@/lib/rate-limit";
 
 const checkoutSchema = z.object({
   provider: z.enum(["stripe", "vipps"]),
@@ -12,6 +13,9 @@ export async function POST(
   request: Request,
   { params }: { params: Promise<{ orgId: string; token: string }> },
 ) {
+  const limited = rateLimit(request, { limit: 10, windowMs: 60_000 });
+  if (limited) return limited;
+
   try {
     const { orgId, token } = await params;
 

+ 4 - 0
src/app/api/share/invoice/[orgId]/[token]/pdf/route.ts

@@ -7,11 +7,15 @@ import { readFile } from "fs/promises";
 import { resolveUploadPath } from "@/lib/resolve-upload-path";
 import { getFeatures } from "@/lib/features";
 import { getTorqvoiceLogoDataUri } from "@/lib/torqvoice-branding";
+import { rateLimit } from "@/lib/rate-limit";
 
 export async function GET(
   _request: Request,
   { params }: { params: Promise<{ orgId: string; token: string }> }
 ) {
+  const limited = rateLimit(_request, { limit: 20, windowMs: 60_000 });
+  if (limited) return limited;
+
   try {
     const { orgId, token } = await params;
 

+ 4 - 0
src/app/api/share/invoice/[orgId]/[token]/verify/route.ts

@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
 import { z } from "zod";
 import { db } from "@/lib/db";
 import { getPaymentProvider, getEnabledProviders } from "@/lib/payment-providers";
+import { rateLimit } from "@/lib/rate-limit";
 
 const verifySchema = z.object({
   provider: z.enum(["stripe", "vipps"]),
@@ -12,6 +13,9 @@ export async function POST(
   request: Request,
   { params }: { params: Promise<{ orgId: string; token: string }> },
 ) {
+  const limited = rateLimit(request, { limit: 10, windowMs: 60_000 });
+  if (limited) return limited;
+
   try {
     const { orgId, token } = await params;
 

+ 2 - 0
src/components/app-sidebar.tsx

@@ -32,6 +32,7 @@ import { Button } from '@/components/ui/button'
 import {
   BarChart3,
   Building2,
+  CalendarDays,
   Car,
   Check,
   ChevronsUpDown,
@@ -56,6 +57,7 @@ const baseNavItems = [
   { title: 'Vehicles', url: '/vehicles', icon: Car },
   { title: 'Customers', url: '/customers', icon: Users },
   { title: 'Work Orders', url: '/work-orders', icon: ClipboardList },
+  { title: 'Calendar', url: '/calendar', icon: CalendarDays },
   { title: 'Quotes', url: '/quotes', icon: FileText },
   { title: 'Billing', url: '/billing', icon: Receipt },
   { title: 'Inventory', url: '/inventory', icon: Package },

+ 163 - 62
src/features/billing/Actions/billingActions.ts

@@ -3,6 +3,57 @@
 import { db } from "@/lib/db";
 import { withAuth } from "@/lib/with-auth";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { Prisma } from "@prisma/client";
+
+interface BillingSummary {
+  totalRevenue: number;
+  totalPaid: number;
+  outstanding: number;
+  paidCount: number;
+  unpaidCount: number;
+  partialCount: number;
+}
+
+async function getBillingSummary(organizationId: string): Promise<BillingSummary> {
+  const rows = await db.$queryRaw<
+    {
+      total_revenue: number;
+      total_paid: number;
+      paid_count: bigint;
+      unpaid_count: bigint;
+      partial_count: bigint;
+    }[]
+  >(Prisma.sql`
+    SELECT
+      COALESCE(SUM(CASE WHEN sub.total > 0 THEN sub.total ELSE sub.cost END), 0) AS total_revenue,
+      COALESCE(SUM(sub.paid), 0) AS total_paid,
+      COUNT(*) FILTER (WHERE sub.paid >= (CASE WHEN sub.total > 0 THEN sub.total ELSE sub.cost END) AND (CASE WHEN sub.total > 0 THEN sub.total ELSE sub.cost END) > 0) AS paid_count,
+      COUNT(*) FILTER (WHERE sub.paid > 0 AND sub.paid < (CASE WHEN sub.total > 0 THEN sub.total ELSE sub.cost END)) AS partial_count,
+      COUNT(*) FILTER (WHERE sub.paid = 0) AS unpaid_count
+    FROM (
+      SELECT
+        sr."totalAmount" AS total,
+        sr.cost,
+        COALESCE((SELECT SUM(p.amount) FROM "Payment" p WHERE p."serviceRecordId" = sr.id), 0) AS paid
+      FROM "ServiceRecord" sr
+      JOIN "Vehicle" v ON v.id = sr."vehicleId"
+      WHERE v."organizationId" = ${organizationId}
+    ) sub
+  `);
+
+  const row = rows[0];
+  const totalRevenue = Number(row?.total_revenue ?? 0);
+  const totalPaid = Number(row?.total_paid ?? 0);
+
+  return {
+    totalRevenue,
+    totalPaid,
+    outstanding: totalRevenue - totalPaid,
+    paidCount: Number(row?.paid_count ?? 0),
+    unpaidCount: Number(row?.unpaid_count ?? 0),
+    partialCount: Number(row?.partial_count ?? 0),
+  };
+}
 
 export async function getBillingHistory(params: {
   page?: number;
@@ -15,6 +66,9 @@ export async function getBillingHistory(params: {
     const pageSize = params.pageSize || 20;
     const skip = (page - 1) * pageSize;
 
+    const summary = await getBillingSummary(organizationId);
+
+    // Build where clause
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
     const where: any = { vehicle: { organizationId } };
 
@@ -27,82 +81,129 @@ export async function getBillingHistory(params: {
       ];
     }
 
-    const allRecords = await db.serviceRecord.findMany({
-      where: { vehicle: { organizationId } },
-      select: {
-        totalAmount: true,
-        cost: true,
-        payments: { select: { amount: true } },
-      },
-    });
-
-    let totalRevenue = 0;
-    let totalPaid = 0;
-    let paidCount = 0;
-    let unpaidCount = 0;
-    let partialCount = 0;
-
-    for (const r of allRecords) {
-      const total = r.totalAmount > 0 ? r.totalAmount : r.cost;
-      const paid = r.payments.reduce((s, p) => s + p.amount, 0);
-      totalRevenue += total;
-      totalPaid += paid;
-      if (paid >= total && total > 0) paidCount++;
-      else if (paid > 0) partialCount++;
-      else unpaidCount++;
-    }
-
-    // Filter by payment status
+    // When filtering by payment status, use raw SQL for efficiency
     if (params.status === "paid" || params.status === "partial" || params.status === "unpaid") {
-      // We need to filter records by computed payment status
-      // Use raw approach: get all matching records and filter in JS
-      const allMatching = await db.serviceRecord.findMany({
-        where,
-        include: {
-          payments: { select: { amount: true } },
-          vehicle: {
-            select: {
-              id: true,
-              make: true,
-              model: true,
-              year: true,
-              licensePlate: true,
-              customer: { select: { id: true, name: true } },
-            },
-          },
-        },
-        orderBy: { serviceDate: "desc" },
-      });
+      const searchCondition = params.search
+        ? Prisma.sql`AND (
+            sr.title ILIKE ${`%${params.search}%`}
+            OR sr."invoiceNumber" ILIKE ${`%${params.search}%`}
+            OR v.make ILIKE ${`%${params.search}%`}
+            OR v.model ILIKE ${`%${params.search}%`}
+          )`
+        : Prisma.empty;
+
+      let statusCondition: Prisma.Sql;
+      if (params.status === "paid") {
+        statusCondition = Prisma.sql`AND paid_amount >= effective_total AND effective_total > 0`;
+      } else if (params.status === "partial") {
+        statusCondition = Prisma.sql`AND paid_amount > 0 AND paid_amount < effective_total`;
+      } else {
+        statusCondition = Prisma.sql`AND paid_amount = 0`;
+      }
 
-      const filtered = allMatching.filter((r) => {
-        const total = r.totalAmount > 0 ? r.totalAmount : r.cost;
-        const paid = r.payments.reduce((s, p) => s + p.amount, 0);
-        if (params.status === "paid") return paid >= total && total > 0;
-        if (params.status === "partial") return paid > 0 && paid < total;
-        return paid === 0;
-      });
+      const countRows = await db.$queryRaw<{ cnt: bigint }[]>(Prisma.sql`
+        SELECT COUNT(*) AS cnt FROM (
+          SELECT
+            sr.id,
+            CASE WHEN sr."totalAmount" > 0 THEN sr."totalAmount" ELSE sr.cost END AS effective_total,
+            COALESCE((SELECT SUM(p.amount) FROM "Payment" p WHERE p."serviceRecordId" = sr.id), 0) AS paid_amount
+          FROM "ServiceRecord" sr
+          JOIN "Vehicle" v ON v.id = sr."vehicleId"
+          WHERE v."organizationId" = ${organizationId}
+          ${searchCondition}
+        ) sub
+        WHERE 1=1 ${statusCondition}
+      `);
 
-      const paged = filtered.slice(skip, skip + pageSize);
+      const total = Number(countRows[0]?.cnt ?? 0);
+
+      const rows = await db.$queryRaw<
+        {
+          id: string;
+          title: string | null;
+          invoiceNumber: string | null;
+          serviceDate: Date;
+          effective_total: number;
+          paid_amount: number;
+          status: string;
+          vehicle_id: string;
+          vehicle_make: string | null;
+          vehicle_model: string | null;
+          vehicle_year: number | null;
+          vehicle_license_plate: string | null;
+          customer_id: string | null;
+          customer_name: string | null;
+        }[]
+      >(Prisma.sql`
+        SELECT
+          sub.id,
+          sub.title,
+          sub."invoiceNumber",
+          sub."serviceDate",
+          sub.effective_total,
+          sub.paid_amount,
+          sub.status,
+          sub.vehicle_id,
+          sub.vehicle_make,
+          sub.vehicle_model,
+          sub.vehicle_year,
+          sub.vehicle_license_plate,
+          sub.customer_id,
+          sub.customer_name
+        FROM (
+          SELECT
+            sr.id,
+            sr.title,
+            sr."invoiceNumber",
+            sr."serviceDate",
+            sr.status,
+            CASE WHEN sr."totalAmount" > 0 THEN sr."totalAmount" ELSE sr.cost END AS effective_total,
+            COALESCE((SELECT SUM(p.amount) FROM "Payment" p WHERE p."serviceRecordId" = sr.id), 0) AS paid_amount,
+            v.id AS vehicle_id,
+            v.make AS vehicle_make,
+            v.model AS vehicle_model,
+            v.year AS vehicle_year,
+            v."licensePlate" AS vehicle_license_plate,
+            c.id AS customer_id,
+            c.name AS customer_name
+          FROM "ServiceRecord" sr
+          JOIN "Vehicle" v ON v.id = sr."vehicleId"
+          LEFT JOIN "Customer" c ON c.id = v."customerId"
+          WHERE v."organizationId" = ${organizationId}
+          ${searchCondition}
+        ) sub
+        WHERE 1=1 ${statusCondition}
+        ORDER BY sub."serviceDate" DESC
+        LIMIT ${pageSize} OFFSET ${skip}
+      `);
 
       return {
-        records: paged.map((r) => ({
+        records: rows.map((r) => ({
           id: r.id,
-          title: r.title,
+          title: r.title || "",
           invoiceNumber: r.invoiceNumber,
           serviceDate: r.serviceDate,
-          totalAmount: r.totalAmount > 0 ? r.totalAmount : r.cost,
-          totalPaid: r.payments.reduce((s, p) => s + p.amount, 0),
+          totalAmount: Number(r.effective_total),
+          totalPaid: Number(r.paid_amount),
           status: r.status,
-          vehicle: r.vehicle,
+          vehicle: {
+            id: r.vehicle_id,
+            make: r.vehicle_make || "",
+            model: r.vehicle_model || "",
+            year: r.vehicle_year || 0,
+            licensePlate: r.vehicle_license_plate,
+            customer: r.customer_id ? { id: r.customer_id, name: r.customer_name || "" } : null,
+          },
         })),
-        total: filtered.length,
+        total,
         page,
         pageSize,
-        totalPages: Math.ceil(filtered.length / pageSize),
-        summary: { totalRevenue, totalPaid, outstanding: totalRevenue - totalPaid, paidCount, unpaidCount, partialCount },
+        totalPages: Math.ceil(total / pageSize),
+        summary,
       };
     }
 
+    // No status filter — use Prisma for clean pagination
     const [records, total] = await Promise.all([
       db.serviceRecord.findMany({
         where,
@@ -141,7 +242,7 @@ export async function getBillingHistory(params: {
       page,
       pageSize,
       totalPages: Math.ceil(total / pageSize),
-      summary: { totalRevenue, totalPaid, outstanding: totalRevenue - totalPaid, paidCount, unpaidCount, partialCount },
+      summary,
     };
   }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.BILLING }] });
 }

+ 332 - 0
src/features/billing/Actions/recurringInvoiceActions.ts

@@ -0,0 +1,332 @@
+"use server";
+
+import { db } from "@/lib/db";
+import { withAuth } from "@/lib/with-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { revalidatePath } from "next/cache";
+import {
+  createRecurringInvoiceSchema,
+  updateRecurringInvoiceSchema,
+  type CreateRecurringInvoiceInput,
+  type UpdateRecurringInvoiceInput,
+} from "../Schema/recurringInvoiceSchema";
+import { resolveInvoicePrefix } from "@/lib/invoice-utils";
+
+export async function getRecurringInvoices() {
+  return withAuth(async ({ organizationId }) => {
+    const invoices = await db.recurringInvoice.findMany({
+      where: { vehicle: { organizationId } },
+      include: {
+        vehicle: {
+          select: {
+            id: true,
+            make: true,
+            model: true,
+            year: true,
+            customer: { select: { id: true, name: true } },
+          },
+        },
+        templateParts: true,
+        templateLabor: true,
+      },
+      orderBy: { nextRunDate: "asc" },
+    });
+
+    return invoices;
+  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.BILLING }] });
+}
+
+export async function createRecurringInvoice(input: CreateRecurringInvoiceInput) {
+  return withAuth(async ({ organizationId }) => {
+    const parsed = createRecurringInvoiceSchema.parse(input);
+
+    // Verify vehicle belongs to org
+    const vehicle = await db.vehicle.findFirst({
+      where: { id: parsed.vehicleId, organizationId },
+    });
+    if (!vehicle) throw new Error("Vehicle not found");
+
+    const invoice = await db.recurringInvoice.create({
+      data: {
+        title: parsed.title,
+        description: parsed.description,
+        frequency: parsed.frequency,
+        nextRunDate: new Date(parsed.nextRunDate),
+        endDate: parsed.endDate ? new Date(parsed.endDate) : null,
+        vehicleId: parsed.vehicleId,
+        type: parsed.type,
+        cost: parsed.cost,
+        taxRate: parsed.taxRate,
+        invoiceNotes: parsed.invoiceNotes,
+        templateParts: {
+          create: parsed.templateParts.map((p) => ({
+            name: p.name,
+            partNumber: p.partNumber,
+            quantity: p.quantity,
+            unitPrice: p.unitPrice,
+          })),
+        },
+        templateLabor: {
+          create: parsed.templateLabor.map((l) => ({
+            description: l.description,
+            hours: l.hours,
+            rate: l.rate,
+          })),
+        },
+      },
+    });
+
+    revalidatePath("/billing/recurring");
+    return invoice;
+  }, { requiredPermissions: [{ action: PermissionAction.CREATE, subject: PermissionSubject.BILLING }] });
+}
+
+export async function updateRecurringInvoice(input: UpdateRecurringInvoiceInput) {
+  return withAuth(async ({ organizationId }) => {
+    const parsed = updateRecurringInvoiceSchema.parse(input);
+
+    const existing = await db.recurringInvoice.findFirst({
+      where: { id: parsed.id, vehicle: { organizationId } },
+    });
+    if (!existing) throw new Error("Recurring invoice not found");
+
+    if (parsed.vehicleId) {
+      const vehicle = await db.vehicle.findFirst({
+        where: { id: parsed.vehicleId, organizationId },
+      });
+      if (!vehicle) throw new Error("Vehicle not found");
+    }
+
+    // Update main record
+    const updated = await db.$transaction(async (tx) => {
+      // Delete existing template items if new ones provided
+      if (parsed.templateParts) {
+        await tx.recurringPart.deleteMany({ where: { recurringInvoiceId: parsed.id } });
+      }
+      if (parsed.templateLabor) {
+        await tx.recurringLabor.deleteMany({ where: { recurringInvoiceId: parsed.id } });
+      }
+
+      return tx.recurringInvoice.update({
+        where: { id: parsed.id },
+        data: {
+          ...(parsed.title !== undefined && { title: parsed.title }),
+          ...(parsed.description !== undefined && { description: parsed.description }),
+          ...(parsed.frequency !== undefined && { frequency: parsed.frequency }),
+          ...(parsed.nextRunDate !== undefined && { nextRunDate: new Date(parsed.nextRunDate) }),
+          ...(parsed.endDate !== undefined && { endDate: parsed.endDate ? new Date(parsed.endDate) : null }),
+          ...(parsed.vehicleId !== undefined && { vehicleId: parsed.vehicleId }),
+          ...(parsed.type !== undefined && { type: parsed.type }),
+          ...(parsed.cost !== undefined && { cost: parsed.cost }),
+          ...(parsed.taxRate !== undefined && { taxRate: parsed.taxRate }),
+          ...(parsed.invoiceNotes !== undefined && { invoiceNotes: parsed.invoiceNotes }),
+          ...(parsed.templateParts && {
+            templateParts: {
+              create: parsed.templateParts.map((p) => ({
+                name: p.name,
+                partNumber: p.partNumber,
+                quantity: p.quantity,
+                unitPrice: p.unitPrice,
+              })),
+            },
+          }),
+          ...(parsed.templateLabor && {
+            templateLabor: {
+              create: parsed.templateLabor.map((l) => ({
+                description: l.description,
+                hours: l.hours,
+                rate: l.rate,
+              })),
+            },
+          }),
+        },
+      });
+    });
+
+    revalidatePath("/billing/recurring");
+    return updated;
+  }, { requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.BILLING }] });
+}
+
+export async function deleteRecurringInvoice(id: string) {
+  return withAuth(async ({ organizationId }) => {
+    const existing = await db.recurringInvoice.findFirst({
+      where: { id, vehicle: { organizationId } },
+    });
+    if (!existing) throw new Error("Recurring invoice not found");
+
+    await db.recurringInvoice.delete({ where: { id } });
+
+    revalidatePath("/billing/recurring");
+    return { deleted: true };
+  }, { requiredPermissions: [{ action: PermissionAction.DELETE, subject: PermissionSubject.BILLING }] });
+}
+
+export async function toggleRecurringInvoice(id: string) {
+  return withAuth(async ({ organizationId }) => {
+    const existing = await db.recurringInvoice.findFirst({
+      where: { id, vehicle: { organizationId } },
+    });
+    if (!existing) throw new Error("Recurring invoice not found");
+
+    const updated = await db.recurringInvoice.update({
+      where: { id },
+      data: { isActive: !existing.isActive },
+    });
+
+    revalidatePath("/billing/recurring");
+    return updated;
+  }, { requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.BILLING }] });
+}
+
+function calculateNextRunDate(current: Date, frequency: string): Date {
+  const next = new Date(current);
+  switch (frequency) {
+    case "weekly":
+      next.setDate(next.getDate() + 7);
+      break;
+    case "biweekly":
+      next.setDate(next.getDate() + 14);
+      break;
+    case "monthly":
+      next.setMonth(next.getMonth() + 1);
+      break;
+    case "quarterly":
+      next.setMonth(next.getMonth() + 3);
+      break;
+    case "yearly":
+      next.setFullYear(next.getFullYear() + 1);
+      break;
+  }
+  return next;
+}
+
+async function generateInvoiceNumber(organizationId: string): Promise<string> {
+  const settings = await db.appSetting.findMany({
+    where: {
+      organizationId,
+      key: { in: ["workshop.invoicePrefix", "workshop.invoiceStartNumber"] },
+    },
+  });
+
+  const settingsMap: Record<string, string> = {};
+  for (const s of settings) settingsMap[s.key] = s.value;
+
+  const prefix = resolveInvoicePrefix(settingsMap["workshop.invoicePrefix"] || "{year}-");
+  const startNumber = parseInt(settingsMap["workshop.invoiceStartNumber"] || "0", 10);
+
+  const lastRecord = await db.serviceRecord.findFirst({
+    where: { vehicle: { organizationId } },
+    orderBy: { createdAt: "desc" },
+    select: { invoiceNumber: true },
+  });
+
+  let nextNum = startNumber || 1001;
+  if (lastRecord?.invoiceNumber) {
+    const match = lastRecord.invoiceNumber.match(/(\d+)$/);
+    if (match) {
+      const lastNum = parseInt(match[1], 10) + 1;
+      nextNum = Math.max(nextNum, lastNum);
+    }
+  }
+
+  return `${prefix}${nextNum}`;
+}
+
+export async function processRecurringInvoices() {
+  return withAuth(async ({ organizationId }) => {
+    const now = new Date();
+
+    const dueInvoices = await db.recurringInvoice.findMany({
+      where: {
+        isActive: true,
+        nextRunDate: { lte: now },
+        vehicle: { organizationId },
+      },
+      include: {
+        templateParts: true,
+        templateLabor: true,
+        vehicle: { select: { organizationId: true } },
+      },
+    });
+
+    const results: { id: string; serviceRecordId: string }[] = [];
+
+    for (const ri of dueInvoices) {
+      const invoiceNumber = await generateInvoiceNumber(organizationId);
+
+      // Calculate totals
+      const partsSubtotal = ri.templateParts.reduce(
+        (s, p) => s + p.quantity * p.unitPrice,
+        0,
+      );
+      const laborSubtotal = ri.templateLabor.reduce(
+        (s, l) => s + l.hours * l.rate,
+        0,
+      );
+      const subtotal = ri.cost + partsSubtotal + laborSubtotal;
+      const taxAmount = subtotal * (ri.taxRate / 100);
+      const totalAmount = subtotal + taxAmount;
+
+      const serviceRecord = await db.$transaction(async (tx) => {
+        const sr = await tx.serviceRecord.create({
+          data: {
+            title: ri.title,
+            description: ri.description,
+            type: ri.type,
+            status: "completed",
+            cost: ri.cost,
+            serviceDate: now,
+            invoiceNotes: ri.invoiceNotes,
+            subtotal,
+            taxRate: ri.taxRate,
+            taxAmount,
+            totalAmount,
+            invoiceNumber,
+            vehicleId: ri.vehicleId,
+            partItems: {
+              create: ri.templateParts.map((p) => ({
+                name: p.name,
+                partNumber: p.partNumber,
+                quantity: p.quantity,
+                unitPrice: p.unitPrice,
+                total: p.quantity * p.unitPrice,
+              })),
+            },
+            laborItems: {
+              create: ri.templateLabor.map((l) => ({
+                description: l.description,
+                hours: l.hours,
+                rate: l.rate,
+                total: l.hours * l.rate,
+              })),
+            },
+          },
+        });
+
+        // Update recurring invoice
+        const nextRunDate = calculateNextRunDate(ri.nextRunDate, ri.frequency);
+        const shouldDeactivate = ri.endDate && nextRunDate > ri.endDate;
+
+        await tx.recurringInvoice.update({
+          where: { id: ri.id },
+          data: {
+            lastRunAt: now,
+            runCount: { increment: 1 },
+            nextRunDate,
+            ...(shouldDeactivate && { isActive: false }),
+          },
+        });
+
+        return sr;
+      });
+
+      results.push({ id: ri.id, serviceRecordId: serviceRecord.id });
+    }
+
+    revalidatePath("/billing");
+    revalidatePath("/billing/recurring");
+
+    return { processed: results.length, results };
+  }, { requiredPermissions: [{ action: PermissionAction.CREATE, subject: PermissionSubject.BILLING }] });
+}

+ 545 - 0
src/features/billing/Components/RecurringInvoicesClient.tsx

@@ -0,0 +1,545 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import Link from "next/link";
+import { format } from "date-fns";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from "@/components/ui/table";
+import {
+  Dialog,
+  DialogContent,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import {
+  Loader2,
+  Plus,
+  Pause,
+  Play,
+  Trash2,
+  Zap,
+  X,
+} from "lucide-react";
+import { formatCurrency } from "@/lib/format";
+import {
+  createRecurringInvoice,
+  toggleRecurringInvoice,
+  deleteRecurringInvoice,
+  processRecurringInvoices,
+} from "../Actions/recurringInvoiceActions";
+import { toast } from "sonner";
+
+interface Vehicle {
+  id: string;
+  make: string;
+  model: string;
+  year: number;
+  customer: { id: string; name: string } | null;
+}
+
+interface TemplatePart {
+  id: string;
+  name: string;
+  partNumber: string | null;
+  quantity: number;
+  unitPrice: number;
+}
+
+interface TemplateLabor {
+  id: string;
+  description: string;
+  hours: number;
+  rate: number;
+}
+
+interface RecurringInvoice {
+  id: string;
+  title: string;
+  description: string | null;
+  frequency: string;
+  nextRunDate: Date;
+  endDate: Date | null;
+  isActive: boolean;
+  lastRunAt: Date | null;
+  runCount: number;
+  type: string;
+  cost: number;
+  taxRate: number;
+  invoiceNotes: string | null;
+  vehicleId: string;
+  vehicle: {
+    id: string;
+    make: string;
+    model: string;
+    year: number;
+    customer: { id: string; name: string } | null;
+  };
+  templateParts: TemplatePart[];
+  templateLabor: TemplateLabor[];
+}
+
+interface RecurringInvoicesClientProps {
+  invoices: RecurringInvoice[];
+  vehicles: Vehicle[];
+  currencyCode: string;
+}
+
+const FREQUENCY_LABELS: Record<string, string> = {
+  weekly: "Weekly",
+  biweekly: "Bi-weekly",
+  monthly: "Monthly",
+  quarterly: "Quarterly",
+  yearly: "Yearly",
+};
+
+const SERVICE_TYPES = [
+  { value: "maintenance", label: "Maintenance" },
+  { value: "repair", label: "Repair" },
+  { value: "inspection", label: "Inspection" },
+  { value: "upgrade", label: "Upgrade" },
+];
+
+interface PartRow {
+  name: string;
+  partNumber: string;
+  quantity: number;
+  unitPrice: number;
+}
+
+interface LaborRow {
+  description: string;
+  hours: number;
+  rate: number;
+}
+
+export default function RecurringInvoicesClient({
+  invoices,
+  vehicles,
+  currencyCode,
+}: RecurringInvoicesClientProps) {
+  const router = useRouter();
+  const [isPending, startTransition] = useTransition();
+  const [showCreate, setShowCreate] = useState(false);
+
+  // Form state
+  const [title, setTitle] = useState("");
+  const [description, setDescription] = useState("");
+  const [frequency, setFrequency] = useState<"weekly" | "biweekly" | "monthly" | "quarterly" | "yearly">("monthly");
+  const [nextRunDate, setNextRunDate] = useState(
+    new Date().toISOString().split("T")[0],
+  );
+  const [endDate, setEndDate] = useState("");
+  const [vehicleId, setVehicleId] = useState("");
+  const [serviceType, setServiceType] = useState("maintenance");
+  const [cost, setCost] = useState("0");
+  const [taxRate, setTaxRate] = useState("0");
+  const [invoiceNotes, setInvoiceNotes] = useState("");
+  const [parts, setParts] = useState<PartRow[]>([]);
+  const [labor, setLabor] = useState<LaborRow[]>([]);
+
+  const resetForm = () => {
+    setTitle("");
+    setDescription("");
+    setFrequency("monthly");
+    setNextRunDate(new Date().toISOString().split("T")[0]);
+    setEndDate("");
+    setVehicleId("");
+    setServiceType("maintenance");
+    setCost("0");
+    setTaxRate("0");
+    setInvoiceNotes("");
+    setParts([]);
+    setLabor([]);
+  };
+
+  const handleCreate = () => {
+    if (!title.trim() || !vehicleId) {
+      toast.error("Title and vehicle are required");
+      return;
+    }
+
+    startTransition(async () => {
+      const result = await createRecurringInvoice({
+        title: title.trim(),
+        description: description.trim() || undefined,
+        frequency,
+        nextRunDate,
+        endDate: endDate || undefined,
+        vehicleId,
+        type: serviceType,
+        cost: parseFloat(cost) || 0,
+        taxRate: parseFloat(taxRate) || 0,
+        invoiceNotes: invoiceNotes.trim() || undefined,
+        templateParts: parts
+          .filter((p) => p.name.trim())
+          .map((p) => ({
+            name: p.name,
+            partNumber: p.partNumber || undefined,
+            quantity: p.quantity,
+            unitPrice: p.unitPrice,
+          })),
+        templateLabor: labor
+          .filter((l) => l.description.trim())
+          .map((l) => ({
+            description: l.description,
+            hours: l.hours,
+            rate: l.rate,
+          })),
+      });
+
+      if (result.success) {
+        toast.success("Recurring invoice created");
+        setShowCreate(false);
+        resetForm();
+        router.refresh();
+      } else {
+        toast.error(result.error || "Failed to create");
+      }
+    });
+  };
+
+  const handleToggle = (id: string) => {
+    startTransition(async () => {
+      const result = await toggleRecurringInvoice(id);
+      if (result.success) {
+        toast.success("Status updated");
+        router.refresh();
+      } else {
+        toast.error(result.error || "Failed to update");
+      }
+    });
+  };
+
+  const handleDelete = (id: string) => {
+    startTransition(async () => {
+      const result = await deleteRecurringInvoice(id);
+      if (result.success) {
+        toast.success("Recurring invoice deleted");
+        router.refresh();
+      } else {
+        toast.error(result.error || "Failed to delete");
+      }
+    });
+  };
+
+  const handleProcessNow = () => {
+    startTransition(async () => {
+      const result = await processRecurringInvoices();
+      if (result.success && result.data) {
+        toast.success(`Processed ${result.data.processed} invoice(s)`);
+        router.refresh();
+      } else {
+        toast.error(result.error || "Failed to process");
+      }
+    });
+  };
+
+  const addPart = () => setParts([...parts, { name: "", partNumber: "", quantity: 1, unitPrice: 0 }]);
+  const removePart = (i: number) => setParts(parts.filter((_, idx) => idx !== i));
+  const updatePart = (i: number, field: keyof PartRow, value: string | number) => {
+    const updated = [...parts];
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    (updated[i] as any)[field] = value;
+    setParts(updated);
+  };
+
+  const addLabor = () => setLabor([...labor, { description: "", hours: 0, rate: 0 }]);
+  const removeLabor = (i: number) => setLabor(labor.filter((_, idx) => idx !== i));
+  const updateLabor = (i: number, field: keyof LaborRow, value: string | number) => {
+    const updated = [...labor];
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    (updated[i] as any)[field] = value;
+    setLabor(updated);
+  };
+
+  const fmt = (n: number) => formatCurrency(n, currencyCode);
+
+  return (
+    <div className="space-y-4">
+      {/* Header */}
+      <div className="flex items-center justify-between">
+        <div className="flex items-center gap-3">
+          <Link href="/billing">
+            <Button variant="outline" size="sm">Billing History</Button>
+          </Link>
+          <Button variant="outline" size="sm" disabled>Recurring</Button>
+        </div>
+        <div className="flex items-center gap-2">
+          <Button
+            size="sm"
+            variant="outline"
+            onClick={handleProcessNow}
+            disabled={isPending}
+          >
+            {isPending ? <Loader2 className="h-4 w-4 animate-spin mr-1.5" /> : <Zap className="h-4 w-4 mr-1.5" />}
+            Process Now
+          </Button>
+          <Button size="sm" onClick={() => setShowCreate(true)} disabled={isPending}>
+            <Plus className="h-4 w-4 mr-1.5" />
+            New Recurring Invoice
+          </Button>
+        </div>
+      </div>
+
+      {/* Table */}
+      {invoices.length === 0 ? (
+        <Card className="border-0 shadow-sm">
+          <CardContent className="flex flex-col items-center justify-center py-12">
+            <p className="text-muted-foreground">No recurring invoices yet.</p>
+          </CardContent>
+        </Card>
+      ) : (
+        <Card className="border-0 shadow-sm">
+          <CardContent className="p-0">
+            <Table>
+              <TableHeader>
+                <TableRow>
+                  <TableHead>Title</TableHead>
+                  <TableHead>Vehicle</TableHead>
+                  <TableHead>Customer</TableHead>
+                  <TableHead>Frequency</TableHead>
+                  <TableHead>Next Run</TableHead>
+                  <TableHead className="text-right">Amount</TableHead>
+                  <TableHead>Status</TableHead>
+                  <TableHead className="text-right">Runs</TableHead>
+                  <TableHead />
+                </TableRow>
+              </TableHeader>
+              <TableBody>
+                {invoices.map((inv) => {
+                  const partsTotal = inv.templateParts.reduce((s, p) => s + p.quantity * p.unitPrice, 0);
+                  const laborTotal = inv.templateLabor.reduce((s, l) => s + l.hours * l.rate, 0);
+                  const subtotal = inv.cost + partsTotal + laborTotal;
+                  const total = subtotal + subtotal * (inv.taxRate / 100);
+
+                  return (
+                    <TableRow key={inv.id}>
+                      <TableCell className="text-sm font-medium">{inv.title}</TableCell>
+                      <TableCell className="text-sm">
+                        {inv.vehicle.year} {inv.vehicle.make} {inv.vehicle.model}
+                      </TableCell>
+                      <TableCell className="text-sm">{inv.vehicle.customer?.name ?? "-"}</TableCell>
+                      <TableCell className="text-sm">{FREQUENCY_LABELS[inv.frequency] ?? inv.frequency}</TableCell>
+                      <TableCell className="text-sm">
+                        {format(new Date(inv.nextRunDate), "MMM d, yyyy")}
+                      </TableCell>
+                      <TableCell className="text-right text-sm">{fmt(total)}</TableCell>
+                      <TableCell>
+                        <Badge variant={inv.isActive ? "default" : "secondary"}>
+                          {inv.isActive ? "Active" : "Paused"}
+                        </Badge>
+                      </TableCell>
+                      <TableCell className="text-right text-sm">{inv.runCount}</TableCell>
+                      <TableCell className="text-right">
+                        <div className="flex items-center justify-end gap-1">
+                          <Button
+                            size="icon"
+                            variant="ghost"
+                            className="h-7 w-7"
+                            onClick={() => handleToggle(inv.id)}
+                            disabled={isPending}
+                          >
+                            {inv.isActive ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5" />}
+                          </Button>
+                          <Button
+                            size="icon"
+                            variant="ghost"
+                            className="h-7 w-7 text-destructive"
+                            onClick={() => handleDelete(inv.id)}
+                            disabled={isPending}
+                          >
+                            <Trash2 className="h-3.5 w-3.5" />
+                          </Button>
+                        </div>
+                      </TableCell>
+                    </TableRow>
+                  );
+                })}
+              </TableBody>
+            </Table>
+          </CardContent>
+        </Card>
+      )}
+
+      {/* Create dialog */}
+      <Dialog open={showCreate} onOpenChange={setShowCreate}>
+        <DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
+          <DialogHeader>
+            <DialogTitle>New Recurring Invoice</DialogTitle>
+          </DialogHeader>
+          <div className="space-y-4">
+            {/* Basic info */}
+            <div className="grid gap-3 sm:grid-cols-2">
+              <div className="space-y-1.5">
+                <Label>Title *</Label>
+                <Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="e.g. Monthly Oil Change" />
+              </div>
+              <div className="space-y-1.5">
+                <Label>Vehicle *</Label>
+                <Select value={vehicleId} onValueChange={setVehicleId}>
+                  <SelectTrigger>
+                    <SelectValue placeholder="Select vehicle" />
+                  </SelectTrigger>
+                  <SelectContent>
+                    {vehicles.map((v) => (
+                      <SelectItem key={v.id} value={v.id}>
+                        {v.year} {v.make} {v.model}
+                        {v.customer ? ` (${v.customer.name})` : ""}
+                      </SelectItem>
+                    ))}
+                  </SelectContent>
+                </Select>
+              </div>
+            </div>
+
+            <div className="space-y-1.5">
+              <Label>Description</Label>
+              <Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={2} />
+            </div>
+
+            {/* Schedule */}
+            <div className="grid gap-3 sm:grid-cols-3">
+              <div className="space-y-1.5">
+                <Label>Frequency</Label>
+                <Select value={frequency} onValueChange={(v) => setFrequency(v as typeof frequency)}>
+                  <SelectTrigger>
+                    <SelectValue />
+                  </SelectTrigger>
+                  <SelectContent>
+                    <SelectItem value="weekly">Weekly</SelectItem>
+                    <SelectItem value="biweekly">Bi-weekly</SelectItem>
+                    <SelectItem value="monthly">Monthly</SelectItem>
+                    <SelectItem value="quarterly">Quarterly</SelectItem>
+                    <SelectItem value="yearly">Yearly</SelectItem>
+                  </SelectContent>
+                </Select>
+              </div>
+              <div className="space-y-1.5">
+                <Label>Start Date *</Label>
+                <Input type="date" value={nextRunDate} onChange={(e) => setNextRunDate(e.target.value)} />
+              </div>
+              <div className="space-y-1.5">
+                <Label>End Date</Label>
+                <Input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
+              </div>
+            </div>
+
+            {/* Pricing */}
+            <div className="grid gap-3 sm:grid-cols-3">
+              <div className="space-y-1.5">
+                <Label>Service Type</Label>
+                <Select value={serviceType} onValueChange={setServiceType}>
+                  <SelectTrigger>
+                    <SelectValue />
+                  </SelectTrigger>
+                  <SelectContent>
+                    {SERVICE_TYPES.map((t) => (
+                      <SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
+                    ))}
+                  </SelectContent>
+                </Select>
+              </div>
+              <div className="space-y-1.5">
+                <Label>Base Cost</Label>
+                <Input type="number" step="0.01" min="0" value={cost} onChange={(e) => setCost(e.target.value)} />
+              </div>
+              <div className="space-y-1.5">
+                <Label>Tax Rate (%)</Label>
+                <Input type="number" step="0.01" min="0" max="100" value={taxRate} onChange={(e) => setTaxRate(e.target.value)} />
+              </div>
+            </div>
+
+            <div className="space-y-1.5">
+              <Label>Invoice Notes</Label>
+              <Textarea value={invoiceNotes} onChange={(e) => setInvoiceNotes(e.target.value)} rows={2} />
+            </div>
+
+            {/* Template Parts */}
+            <Card className="border shadow-none">
+              <CardHeader className="pb-2 pt-3 px-3">
+                <div className="flex items-center justify-between">
+                  <CardTitle className="text-sm font-medium">Template Parts</CardTitle>
+                  <Button size="sm" variant="outline" onClick={addPart}>
+                    <Plus className="h-3.5 w-3.5 mr-1" /> Add Part
+                  </Button>
+                </div>
+              </CardHeader>
+              {parts.length > 0 && (
+                <CardContent className="px-3 pb-3 space-y-2">
+                  {parts.map((p, i) => (
+                    <div key={i} className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-2 items-end">
+                      <Input placeholder="Part name" value={p.name} onChange={(e) => updatePart(i, "name", e.target.value)} className="text-sm h-8" />
+                      <Input placeholder="Part #" value={p.partNumber} onChange={(e) => updatePart(i, "partNumber", e.target.value)} className="text-sm h-8 w-24" />
+                      <Input type="number" min="1" value={p.quantity} onChange={(e) => updatePart(i, "quantity", parseInt(e.target.value) || 1)} className="text-sm h-8 w-16" />
+                      <Input type="number" step="0.01" min="0" value={p.unitPrice} onChange={(e) => updatePart(i, "unitPrice", parseFloat(e.target.value) || 0)} className="text-sm h-8 w-20" />
+                      <Button size="icon" variant="ghost" className="h-8 w-8" onClick={() => removePart(i)}>
+                        <X className="h-3.5 w-3.5" />
+                      </Button>
+                    </div>
+                  ))}
+                </CardContent>
+              )}
+            </Card>
+
+            {/* Template Labor */}
+            <Card className="border shadow-none">
+              <CardHeader className="pb-2 pt-3 px-3">
+                <div className="flex items-center justify-between">
+                  <CardTitle className="text-sm font-medium">Template Labor</CardTitle>
+                  <Button size="sm" variant="outline" onClick={addLabor}>
+                    <Plus className="h-3.5 w-3.5 mr-1" /> Add Labor
+                  </Button>
+                </div>
+              </CardHeader>
+              {labor.length > 0 && (
+                <CardContent className="px-3 pb-3 space-y-2">
+                  {labor.map((l, i) => (
+                    <div key={i} className="grid grid-cols-[1fr_auto_auto_auto] gap-2 items-end">
+                      <Input placeholder="Description" value={l.description} onChange={(e) => updateLabor(i, "description", e.target.value)} className="text-sm h-8" />
+                      <Input type="number" step="0.5" min="0" placeholder="Hours" value={l.hours} onChange={(e) => updateLabor(i, "hours", parseFloat(e.target.value) || 0)} className="text-sm h-8 w-20" />
+                      <Input type="number" step="0.01" min="0" placeholder="Rate" value={l.rate} onChange={(e) => updateLabor(i, "rate", parseFloat(e.target.value) || 0)} className="text-sm h-8 w-20" />
+                      <Button size="icon" variant="ghost" className="h-8 w-8" onClick={() => removeLabor(i)}>
+                        <X className="h-3.5 w-3.5" />
+                      </Button>
+                    </div>
+                  ))}
+                </CardContent>
+              )}
+            </Card>
+
+            {/* Actions */}
+            <div className="flex justify-end gap-2 pt-2">
+              <Button variant="outline" onClick={() => { setShowCreate(false); resetForm(); }}>
+                Cancel
+              </Button>
+              <Button onClick={handleCreate} disabled={isPending}>
+                {isPending && <Loader2 className="h-4 w-4 animate-spin mr-1.5" />}
+                Create
+              </Button>
+            </div>
+          </div>
+        </DialogContent>
+      </Dialog>
+    </div>
+  );
+}

+ 36 - 0
src/features/billing/Schema/recurringInvoiceSchema.ts

@@ -0,0 +1,36 @@
+import { z } from "zod";
+
+const recurringPartSchema = z.object({
+  name: z.string().min(1),
+  partNumber: z.string().optional(),
+  quantity: z.number().int().min(1).default(1),
+  unitPrice: z.number().min(0).default(0),
+});
+
+const recurringLaborSchema = z.object({
+  description: z.string().min(1),
+  hours: z.number().min(0).default(0),
+  rate: z.number().min(0).default(0),
+});
+
+export const createRecurringInvoiceSchema = z.object({
+  title: z.string().min(1, "Title is required"),
+  description: z.string().optional(),
+  frequency: z.enum(["weekly", "biweekly", "monthly", "quarterly", "yearly"]),
+  nextRunDate: z.string().min(1, "Start date is required"),
+  endDate: z.string().optional(),
+  vehicleId: z.string().min(1, "Vehicle is required"),
+  type: z.string().default("maintenance"),
+  cost: z.number().min(0).default(0),
+  taxRate: z.number().min(0).max(100).default(0),
+  invoiceNotes: z.string().optional(),
+  templateParts: z.array(recurringPartSchema).default([]),
+  templateLabor: z.array(recurringLaborSchema).default([]),
+});
+
+export const updateRecurringInvoiceSchema = createRecurringInvoiceSchema.partial().extend({
+  id: z.string().min(1),
+});
+
+export type CreateRecurringInvoiceInput = z.infer<typeof createRecurringInvoiceSchema>;
+export type UpdateRecurringInvoiceInput = z.infer<typeof updateRecurringInvoiceSchema>;

+ 101 - 0
src/features/calendar/Actions/calendarActions.ts

@@ -0,0 +1,101 @@
+"use server";
+
+import { db } from "@/lib/db";
+import { withAuth } from "@/lib/with-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+
+export type CalendarEvent = {
+  id: string;
+  title: string;
+  date: string;
+  type: "service" | "reminder";
+  status: string;
+  vehicleId: string;
+  vehicleLabel: string;
+  customerName: string | null;
+};
+
+export async function getCalendarEvents(params: {
+  start: string;
+  end: string;
+}) {
+  return withAuth(async ({ organizationId }) => {
+    const start = new Date(params.start);
+    const end = new Date(params.end);
+    end.setHours(23, 59, 59, 999);
+
+    const [services, reminders] = await Promise.all([
+      db.serviceRecord.findMany({
+        where: {
+          vehicle: { organizationId },
+          serviceDate: { gte: start, lte: end },
+        },
+        select: {
+          id: true,
+          title: true,
+          serviceDate: true,
+          status: true,
+          vehicleId: true,
+          vehicle: {
+            select: {
+              make: true,
+              model: true,
+              year: true,
+              customer: { select: { name: true } },
+            },
+          },
+        },
+        orderBy: { serviceDate: "asc" },
+      }),
+      db.reminder.findMany({
+        where: {
+          vehicle: { organizationId },
+          dueDate: { gte: start, lte: end },
+        },
+        select: {
+          id: true,
+          title: true,
+          dueDate: true,
+          isCompleted: true,
+          vehicleId: true,
+          vehicle: {
+            select: {
+              make: true,
+              model: true,
+              year: true,
+              customer: { select: { name: true } },
+            },
+          },
+        },
+        orderBy: { dueDate: "asc" },
+      }),
+    ]);
+
+    const events: CalendarEvent[] = [
+      ...services.map((s) => ({
+        id: s.id,
+        title: s.title,
+        date: s.serviceDate.toISOString(),
+        type: "service" as const,
+        status: s.status,
+        vehicleId: s.vehicleId,
+        vehicleLabel: `${s.vehicle.year} ${s.vehicle.make} ${s.vehicle.model}`,
+        customerName: s.vehicle.customer?.name ?? null,
+      })),
+      ...reminders
+        .filter((r) => r.dueDate !== null)
+        .map((r) => ({
+          id: r.id,
+          title: r.title,
+          date: r.dueDate!.toISOString(),
+          type: "reminder" as const,
+          status: r.isCompleted ? "completed" : new Date(r.dueDate!) < new Date() ? "overdue" : "upcoming",
+          vehicleId: r.vehicleId,
+          vehicleLabel: `${r.vehicle.year} ${r.vehicle.make} ${r.vehicle.model}`,
+          customerName: r.vehicle.customer?.name ?? null,
+        })),
+    ];
+
+    return events;
+  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SERVICES }] });
+}

+ 175 - 0
src/features/calendar/Components/CalendarClient.tsx

@@ -0,0 +1,175 @@
+"use client";
+
+import { useState, useCallback, useEffect } from "react";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { ChevronLeft, ChevronRight, Loader2 } from "lucide-react";
+import { CalendarDayCell } from "./CalendarDayCell";
+import { CalendarEventList } from "./CalendarEventList";
+import { getCalendarEvents } from "../Actions/calendarActions";
+import type { CalendarEvent } from "../Actions/calendarActions";
+
+interface CalendarClientProps {
+  initialEvents: CalendarEvent[];
+  initialMonth: number;
+  initialYear: number;
+}
+
+function getMonthDays(year: number, month: number) {
+  const firstDay = new Date(year, month, 1);
+  const lastDay = new Date(year, month + 1, 0);
+  const startPad = firstDay.getDay();
+
+  const days: Date[] = [];
+
+  // Previous month padding
+  for (let i = startPad - 1; i >= 0; i--) {
+    days.push(new Date(year, month, -i));
+  }
+
+  // Current month days
+  for (let d = 1; d <= lastDay.getDate(); d++) {
+    days.push(new Date(year, month, d));
+  }
+
+  // Next month padding (fill to 42 or at least complete the last week)
+  const remaining = 7 - (days.length % 7);
+  if (remaining < 7) {
+    for (let i = 1; i <= remaining; i++) {
+      days.push(new Date(year, month + 1, i));
+    }
+  }
+
+  return days;
+}
+
+const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
+const MONTH_NAMES = [
+  "January", "February", "March", "April", "May", "June",
+  "July", "August", "September", "October", "November", "December",
+];
+
+export default function CalendarClient({ initialEvents, initialMonth, initialYear }: CalendarClientProps) {
+  const [month, setMonth] = useState(initialMonth);
+  const [year, setYear] = useState(initialYear);
+  const [events, setEvents] = useState<CalendarEvent[]>(initialEvents);
+  const [loading, setLoading] = useState(false);
+  const [selectedDate, setSelectedDate] = useState<Date>(new Date());
+
+  const today = new Date();
+  const todayStr = today.toISOString().split("T")[0];
+
+  const days = getMonthDays(year, month);
+
+  const fetchEvents = useCallback(async (y: number, m: number) => {
+    setLoading(true);
+    const start = new Date(y, m, 1);
+    const end = new Date(y, m + 1, 0);
+    const result = await getCalendarEvents({
+      start: start.toISOString().split("T")[0],
+      end: end.toISOString().split("T")[0],
+    });
+    if (result.success && result.data) {
+      setEvents(result.data);
+    }
+    setLoading(false);
+  }, []);
+
+  const goToPrev = () => {
+    const newMonth = month === 0 ? 11 : month - 1;
+    const newYear = month === 0 ? year - 1 : year;
+    setMonth(newMonth);
+    setYear(newYear);
+    fetchEvents(newYear, newMonth);
+  };
+
+  const goToNext = () => {
+    const newMonth = month === 11 ? 0 : month + 1;
+    const newYear = month === 11 ? year + 1 : year;
+    setMonth(newMonth);
+    setYear(newYear);
+    fetchEvents(newYear, newMonth);
+  };
+
+  const goToToday = () => {
+    const t = new Date();
+    setMonth(t.getMonth());
+    setYear(t.getFullYear());
+    setSelectedDate(t);
+    if (t.getMonth() !== month || t.getFullYear() !== year) {
+      fetchEvents(t.getFullYear(), t.getMonth());
+    }
+  };
+
+  // Sync selected date when month changes
+  useEffect(() => {
+    setSelectedDate(new Date(year, month, 1));
+  }, [month, year]);
+
+  return (
+    <div className="grid gap-4 lg:grid-cols-3">
+      {/* Calendar grid */}
+      <div className="lg:col-span-2">
+        <Card className="border-0 shadow-sm">
+          <CardContent className="p-4">
+            {/* Header */}
+            <div className="flex items-center justify-between mb-4">
+              <div className="flex items-center gap-2">
+                <Button size="icon" variant="outline" className="h-8 w-8" onClick={goToPrev}>
+                  <ChevronLeft className="h-4 w-4" />
+                </Button>
+                <Button size="icon" variant="outline" className="h-8 w-8" onClick={goToNext}>
+                  <ChevronRight className="h-4 w-4" />
+                </Button>
+                <h2 className="text-lg font-semibold ml-2">
+                  {MONTH_NAMES[month]} {year}
+                </h2>
+                {loading && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground ml-2" />}
+              </div>
+              <Button size="sm" variant="outline" onClick={goToToday}>
+                Today
+              </Button>
+            </div>
+
+            {/* Weekday headers */}
+            <div className="grid grid-cols-7 gap-px mb-1">
+              {WEEKDAYS.map((day) => (
+                <div key={day} className="text-center text-xs font-medium text-muted-foreground py-1">
+                  {day}
+                </div>
+              ))}
+            </div>
+
+            {/* Day grid */}
+            <div className="grid grid-cols-7 gap-px">
+              {days.map((date) => {
+                const dateStr = date.toISOString().split("T")[0];
+                const selStr = selectedDate.toISOString().split("T")[0];
+                return (
+                  <CalendarDayCell
+                    key={dateStr}
+                    date={date}
+                    events={events}
+                    isCurrentMonth={date.getMonth() === month}
+                    isToday={dateStr === todayStr}
+                    isSelected={dateStr === selStr}
+                    onClick={() => setSelectedDate(date)}
+                  />
+                );
+              })}
+            </div>
+          </CardContent>
+        </Card>
+      </div>
+
+      {/* Event list sidebar */}
+      <div>
+        <Card className="border-0 shadow-sm">
+          <CardContent className="p-4">
+            <CalendarEventList events={events} selectedDate={selectedDate} />
+          </CardContent>
+        </Card>
+      </div>
+    </div>
+  );
+}

+ 62 - 0
src/features/calendar/Components/CalendarDayCell.tsx

@@ -0,0 +1,62 @@
+"use client";
+
+import type { CalendarEvent } from "../Actions/calendarActions";
+
+function getEventDotColor(event: CalendarEvent) {
+  if (event.type === "service") {
+    switch (event.status) {
+      case "completed": return "bg-emerald-500";
+      case "in_progress": return "bg-blue-500";
+      default: return "bg-amber-500";
+    }
+  }
+  switch (event.status) {
+    case "completed": return "bg-emerald-500";
+    case "overdue": return "bg-red-500";
+    default: return "bg-muted-foreground";
+  }
+}
+
+interface CalendarDayCellProps {
+  date: Date;
+  events: CalendarEvent[];
+  isCurrentMonth: boolean;
+  isToday: boolean;
+  isSelected: boolean;
+  onClick: () => void;
+}
+
+export function CalendarDayCell({ date, events, isCurrentMonth, isToday, isSelected, onClick }: CalendarDayCellProps) {
+  const dateStr = date.toISOString().split("T")[0];
+  const dayEvents = events.filter((e) => e.date.split("T")[0] === dateStr);
+
+  return (
+    <button
+      type="button"
+      onClick={onClick}
+      className={`
+        relative flex flex-col items-center justify-start p-1.5 min-h-[72px] w-full rounded-md text-sm transition-colors
+        ${isCurrentMonth ? "" : "text-muted-foreground/40"}
+        ${isToday ? "bg-primary/10 font-semibold" : ""}
+        ${isSelected ? "ring-2 ring-primary bg-primary/5" : "hover:bg-muted/50"}
+      `}
+    >
+      <span className={`text-xs ${isToday ? "text-primary font-bold" : ""}`}>
+        {date.getDate()}
+      </span>
+      {dayEvents.length > 0 && (
+        <div className="flex gap-0.5 mt-1 flex-wrap justify-center max-w-full">
+          {dayEvents.slice(0, 4).map((event) => (
+            <div
+              key={`${event.type}-${event.id}`}
+              className={`h-1.5 w-1.5 rounded-full ${getEventDotColor(event)}`}
+            />
+          ))}
+          {dayEvents.length > 4 && (
+            <span className="text-[9px] text-muted-foreground">+{dayEvents.length - 4}</span>
+          )}
+        </div>
+      )}
+    </button>
+  );
+}

+ 84 - 0
src/features/calendar/Components/CalendarEventList.tsx

@@ -0,0 +1,84 @@
+"use client";
+
+import Link from "next/link";
+import { Badge } from "@/components/ui/badge";
+import type { CalendarEvent } from "../Actions/calendarActions";
+
+function getStatusColor(event: CalendarEvent) {
+  if (event.type === "service") {
+    switch (event.status) {
+      case "completed": return "bg-emerald-500";
+      case "in_progress": return "bg-blue-500";
+      default: return "bg-amber-500";
+    }
+  }
+  // reminder
+  switch (event.status) {
+    case "completed": return "bg-emerald-500";
+    case "overdue": return "bg-red-500";
+    default: return "bg-muted-foreground";
+  }
+}
+
+function getStatusBadge(event: CalendarEvent) {
+  if (event.type === "service") {
+    switch (event.status) {
+      case "completed": return <Badge variant="outline" className="text-emerald-600 border-emerald-300">Completed</Badge>;
+      case "in_progress": return <Badge variant="outline" className="text-blue-600 border-blue-300">In Progress</Badge>;
+      default: return <Badge variant="outline" className="text-amber-600 border-amber-300">Pending</Badge>;
+    }
+  }
+  switch (event.status) {
+    case "completed": return <Badge variant="outline" className="text-emerald-600 border-emerald-300">Done</Badge>;
+    case "overdue": return <Badge variant="destructive">Overdue</Badge>;
+    default: return <Badge variant="outline">Upcoming</Badge>;
+  }
+}
+
+interface CalendarEventListProps {
+  events: CalendarEvent[];
+  selectedDate: Date;
+}
+
+export function CalendarEventList({ events, selectedDate }: CalendarEventListProps) {
+  const dateStr = selectedDate.toISOString().split("T")[0];
+  const dayEvents = events.filter((e) => e.date.split("T")[0] === dateStr);
+
+  if (dayEvents.length === 0) {
+    return (
+      <div className="text-center py-8 text-sm text-muted-foreground">
+        No events on {selectedDate.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" })}
+      </div>
+    );
+  }
+
+  return (
+    <div className="space-y-2">
+      <p className="text-sm font-medium text-muted-foreground mb-3">
+        {selectedDate.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" })}
+      </p>
+      {dayEvents.map((event) => (
+        <Link
+          key={`${event.type}-${event.id}`}
+          href={`/vehicles/${event.vehicleId}`}
+          className="flex items-start gap-3 rounded-lg border p-3 transition-colors hover:bg-muted/50"
+        >
+          <div className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${getStatusColor(event)}`} />
+          <div className="min-w-0 flex-1">
+            <div className="flex items-center gap-2">
+              <p className="text-sm font-medium truncate">{event.title}</p>
+              {getStatusBadge(event)}
+            </div>
+            <p className="text-xs text-muted-foreground mt-0.5">{event.vehicleLabel}</p>
+            {event.customerName && (
+              <p className="text-xs text-muted-foreground">{event.customerName}</p>
+            )}
+          </div>
+          <Badge variant="secondary" className="shrink-0 text-xs">
+            {event.type === "service" ? "Service" : "Reminder"}
+          </Badge>
+        </Link>
+      ))}
+    </div>
+  );
+}

+ 276 - 0
src/features/reports/Actions/reportActions.ts

@@ -151,6 +151,282 @@ export async function getCustomerReport(params: {
   }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.REPORTS }] });
 }
 
+export async function getTechnicianReport(params: {
+  startDate?: string;
+  endDate?: string;
+}) {
+  return withAuth(async ({ organizationId }) => {
+    const start = params.startDate ? new Date(params.startDate) : new Date(new Date().getFullYear(), 0, 1);
+    const end = params.endDate ? new Date(params.endDate) : new Date();
+    end.setHours(23, 59, 59, 999);
+
+    const records = await db.serviceRecord.findMany({
+      where: {
+        vehicle: { organizationId },
+        serviceDate: { gte: start, lte: end },
+        techName: { not: null },
+      },
+      select: {
+        techName: true,
+        totalAmount: true,
+        cost: true,
+        laborItems: { select: { hours: true } },
+      },
+    });
+
+    const byTech: Record<string, { jobCount: number; totalRevenue: number; totalLaborHours: number }> = {};
+
+    for (const r of records) {
+      const tech = r.techName || "Unassigned";
+      if (!byTech[tech]) byTech[tech] = { jobCount: 0, totalRevenue: 0, totalLaborHours: 0 };
+      byTech[tech].jobCount += 1;
+      byTech[tech].totalRevenue += r.totalAmount > 0 ? r.totalAmount : r.cost;
+      byTech[tech].totalLaborHours += r.laborItems.reduce((s, l) => s + l.hours, 0);
+    }
+
+    const technicians = Object.entries(byTech)
+      .map(([techName, data]) => ({
+        techName,
+        jobCount: data.jobCount,
+        totalRevenue: data.totalRevenue,
+        avgRevenue: data.jobCount > 0 ? data.totalRevenue / data.jobCount : 0,
+        totalLaborHours: data.totalLaborHours,
+        avgHours: data.jobCount > 0 ? data.totalLaborHours / data.jobCount : 0,
+      }))
+      .sort((a, b) => b.totalRevenue - a.totalRevenue);
+
+    return {
+      technicians,
+      totalJobs: records.length,
+      totalRevenue: technicians.reduce((s, t) => s + t.totalRevenue, 0),
+    };
+  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.REPORTS }] });
+}
+
+export async function getPartsUsageReport(params: {
+  startDate?: string;
+  endDate?: string;
+}) {
+  return withAuth(async ({ organizationId }) => {
+    const start = params.startDate ? new Date(params.startDate) : new Date(new Date().getFullYear(), 0, 1);
+    const end = params.endDate ? new Date(params.endDate) : new Date();
+    end.setHours(23, 59, 59, 999);
+
+    const parts = await db.servicePart.findMany({
+      where: {
+        serviceRecord: {
+          vehicle: { organizationId },
+          serviceDate: { gte: start, lte: end },
+        },
+      },
+      select: {
+        name: true,
+        partNumber: true,
+        quantity: true,
+        total: true,
+      },
+    });
+
+    const byPart: Record<string, { partNumber: string | null; usageCount: number; totalQuantity: number; totalRevenue: number }> = {};
+
+    for (const p of parts) {
+      const key = p.name.toLowerCase();
+      if (!byPart[key]) byPart[key] = { partNumber: p.partNumber, usageCount: 0, totalQuantity: 0, totalRevenue: 0 };
+      byPart[key].usageCount += 1;
+      byPart[key].totalQuantity += p.quantity;
+      byPart[key].totalRevenue += p.total;
+      if (p.partNumber && !byPart[key].partNumber) byPart[key].partNumber = p.partNumber;
+    }
+
+    const partsByKey: Record<string, string> = {};
+    for (const p of parts) {
+      const key = p.name.toLowerCase();
+      if (!partsByKey[key]) partsByKey[key] = p.name;
+    }
+
+    const result = Object.entries(byPart)
+      .map(([key, data]) => ({
+        name: partsByKey[key] || key,
+        partNumber: data.partNumber,
+        usageCount: data.usageCount,
+        totalQuantity: data.totalQuantity,
+        totalRevenue: data.totalRevenue,
+      }))
+      .sort((a, b) => b.usageCount - a.usageCount)
+      .slice(0, 20);
+
+    return {
+      parts: result,
+      totalPartsRevenue: parts.reduce((s, p) => s + p.total, 0),
+      totalPartsUsed: parts.reduce((s, p) => s + p.quantity, 0),
+    };
+  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.REPORTS }] });
+}
+
+export async function getJobAnalyticsReport(params: {
+  startDate?: string;
+  endDate?: string;
+}) {
+  return withAuth(async ({ organizationId }) => {
+    const start = params.startDate ? new Date(params.startDate) : new Date(new Date().getFullYear(), 0, 1);
+    const end = params.endDate ? new Date(params.endDate) : new Date();
+    end.setHours(23, 59, 59, 999);
+
+    const records = await db.serviceRecord.findMany({
+      where: {
+        vehicle: { organizationId },
+        serviceDate: { gte: start, lte: end },
+      },
+      select: {
+        type: true,
+        totalAmount: true,
+        cost: true,
+        serviceDate: true,
+        laborItems: { select: { hours: true } },
+      },
+    });
+
+    // Average job value
+    const totalValue = records.reduce((s, r) => s + (r.totalAmount > 0 ? r.totalAmount : r.cost), 0);
+    const avgJobValue = records.length > 0 ? totalValue / records.length : 0;
+
+    // By type with avg value and avg hours
+    const byType: Record<string, { count: number; totalValue: number; totalHours: number }> = {};
+    for (const r of records) {
+      if (!byType[r.type]) byType[r.type] = { count: 0, totalValue: 0, totalHours: 0 };
+      byType[r.type].count += 1;
+      byType[r.type].totalValue += r.totalAmount > 0 ? r.totalAmount : r.cost;
+      byType[r.type].totalHours += r.laborItems.reduce((s, l) => s + l.hours, 0);
+    }
+
+    const topServiceTypes = Object.entries(byType)
+      .map(([type, data]) => ({
+        type,
+        count: data.count,
+        avgValue: data.count > 0 ? data.totalValue / data.count : 0,
+        avgHours: data.count > 0 ? data.totalHours / data.count : 0,
+      }))
+      .sort((a, b) => b.count - a.count)
+      .slice(0, 10);
+
+    // Day of week distribution
+    const dayNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
+    const dayCount = [0, 0, 0, 0, 0, 0, 0];
+    for (const r of records) {
+      dayCount[r.serviceDate.getDay()] += 1;
+    }
+    const dayOfWeek = dayNames.map((day, i) => ({ day, count: dayCount[i] }));
+
+    // Monthly trend
+    const monthly: Record<string, { count: number; revenue: number }> = {};
+    for (const r of records) {
+      const month = `${r.serviceDate.getFullYear()}-${String(r.serviceDate.getMonth() + 1).padStart(2, "0")}`;
+      if (!monthly[month]) monthly[month] = { count: 0, revenue: 0 };
+      monthly[month].count += 1;
+      monthly[month].revenue += r.totalAmount > 0 ? r.totalAmount : r.cost;
+    }
+    const monthlyTrend = Object.entries(monthly)
+      .map(([month, data]) => ({ month, ...data }))
+      .sort((a, b) => a.month.localeCompare(b.month));
+
+    return {
+      avgJobValue,
+      totalJobs: records.length,
+      topServiceTypes,
+      dayOfWeek,
+      monthlyTrend,
+    };
+  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.REPORTS }] });
+}
+
+export async function getCustomerRetentionReport(params: {
+  startDate?: string;
+  endDate?: string;
+}) {
+  return withAuth(async ({ organizationId }) => {
+    const start = params.startDate ? new Date(params.startDate) : new Date(new Date().getFullYear(), 0, 1);
+    const end = params.endDate ? new Date(params.endDate) : new Date();
+    end.setHours(23, 59, 59, 999);
+
+    const customers = await db.customer.findMany({
+      where: { organizationId },
+      select: {
+        id: true,
+        name: true,
+        company: true,
+        vehicles: {
+          select: {
+            serviceRecords: {
+              where: { serviceDate: { gte: start, lte: end } },
+              select: { serviceDate: true, totalAmount: true, cost: true },
+              orderBy: { serviceDate: "asc" },
+            },
+          },
+        },
+      },
+    });
+
+    let returningCustomers = 0;
+    let newCustomers = 0;
+    const allTimeBetween: number[] = [];
+    const topReturning: {
+      id: string;
+      name: string;
+      company: string | null;
+      visitCount: number;
+      totalSpent: number;
+      avgTimeBetweenVisits: number | null;
+    }[] = [];
+
+    for (const c of customers) {
+      const visits: { date: Date; amount: number }[] = [];
+      for (const v of c.vehicles) {
+        for (const sr of v.serviceRecords) {
+          visits.push({ date: sr.serviceDate, amount: sr.totalAmount > 0 ? sr.totalAmount : sr.cost });
+        }
+      }
+
+      if (visits.length === 0) continue;
+
+      visits.sort((a, b) => a.date.getTime() - b.date.getTime());
+
+      if (visits.length > 1) {
+        returningCustomers += 1;
+        const gaps: number[] = [];
+        for (let i = 1; i < visits.length; i++) {
+          const diffDays = (visits[i].date.getTime() - visits[i - 1].date.getTime()) / (1000 * 60 * 60 * 24);
+          gaps.push(diffDays);
+        }
+        const avgGap = gaps.reduce((s, g) => s + g, 0) / gaps.length;
+        allTimeBetween.push(avgGap);
+
+        topReturning.push({
+          id: c.id,
+          name: c.name,
+          company: c.company,
+          visitCount: visits.length,
+          totalSpent: visits.reduce((s, v) => s + v.amount, 0),
+          avgTimeBetweenVisits: Math.round(avgGap),
+        });
+      } else {
+        newCustomers += 1;
+      }
+    }
+
+    topReturning.sort((a, b) => b.visitCount - a.visitCount);
+
+    return {
+      returningCustomers,
+      newCustomers,
+      totalActive: returningCustomers + newCustomers,
+      avgTimeBetweenVisits: allTimeBetween.length > 0
+        ? Math.round(allTimeBetween.reduce((s, v) => s + v, 0) / allTimeBetween.length)
+        : null,
+      topReturning: topReturning.slice(0, 20),
+    };
+  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.REPORTS }] });
+}
+
 export async function getInventoryReport() {
   return withAuth(async ({ organizationId }) => {
     const parts = await db.inventoryPart.findMany({

+ 155 - 0
src/features/reports/Components/JobAnalyticsCharts.tsx

@@ -0,0 +1,155 @@
+"use client";
+
+import {
+  BarChart,
+  Bar,
+  XAxis,
+  YAxis,
+  CartesianGrid,
+  Tooltip,
+  ResponsiveContainer,
+  PieChart,
+  Pie,
+  Cell,
+  Legend,
+  LineChart,
+  Line,
+} from "recharts";
+import type { DayOfWeekDistribution, ServiceTypeAnalytics, MonthlyTrend } from "../Schema/reportTypes";
+
+const CHART_COLORS = [
+  "#3b82f6",
+  "#10b981",
+  "#f59e0b",
+  "#8b5cf6",
+  "#f43f5e",
+  "#06b6d4",
+  "#ec4899",
+  "#84cc16",
+];
+
+interface DayOfWeekChartProps {
+  data: DayOfWeekDistribution[];
+}
+
+export function DayOfWeekChart({ data }: DayOfWeekChartProps) {
+  if (data.length === 0) return null;
+
+  return (
+    <ResponsiveContainer width="100%" height={300}>
+      <BarChart data={data} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
+        <CartesianGrid strokeDasharray="3 3" className="stroke-border" />
+        <XAxis
+          dataKey="day"
+          className="text-xs fill-muted-foreground"
+          tick={{ fontSize: 12 }}
+          tickFormatter={(v) => v.slice(0, 3)}
+        />
+        <YAxis className="text-xs fill-muted-foreground" tick={{ fontSize: 12 }} />
+        <Tooltip
+          contentStyle={{
+            backgroundColor: "hsl(var(--popover))",
+            border: "1px solid hsl(var(--border))",
+            borderRadius: "8px",
+            color: "hsl(var(--popover-foreground))",
+          }}
+        />
+        <Bar dataKey="count" name="Jobs" fill="#3b82f6" radius={[4, 4, 0, 0]} />
+      </BarChart>
+    </ResponsiveContainer>
+  );
+}
+
+interface ServiceTypeDonutProps {
+  data: ServiceTypeAnalytics[];
+}
+
+export function ServiceTypeAnalyticsDonut({ data }: ServiceTypeDonutProps) {
+  if (data.length === 0) return null;
+
+  return (
+    <ResponsiveContainer width="100%" height={300}>
+      <PieChart>
+        <Pie
+          data={data}
+          dataKey="count"
+          nameKey="type"
+          cx="50%"
+          cy="50%"
+          innerRadius={60}
+          outerRadius={80}
+          paddingAngle={2}
+        >
+          {data.map((_, i) => (
+            <Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
+          ))}
+        </Pie>
+        <Tooltip
+          contentStyle={{
+            backgroundColor: "hsl(var(--popover))",
+            border: "1px solid hsl(var(--border))",
+            borderRadius: "8px",
+            color: "hsl(var(--popover-foreground))",
+          }}
+        />
+        <Legend
+          formatter={(value) => (
+            <span className="text-xs text-muted-foreground">{value}</span>
+          )}
+        />
+      </PieChart>
+    </ResponsiveContainer>
+  );
+}
+
+interface MonthlyTrendChartProps {
+  data: MonthlyTrend[];
+  formatCurrency: (value: number) => string;
+}
+
+export function MonthlyTrendChart({ data, formatCurrency }: MonthlyTrendChartProps) {
+  if (data.length === 0) return null;
+
+  return (
+    <ResponsiveContainer width="100%" height={300}>
+      <LineChart data={data} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
+        <CartesianGrid strokeDasharray="3 3" className="stroke-border" />
+        <XAxis
+          dataKey="month"
+          className="text-xs fill-muted-foreground"
+          tick={{ fontSize: 12 }}
+        />
+        <YAxis
+          yAxisId="left"
+          className="text-xs fill-muted-foreground"
+          tick={{ fontSize: 12 }}
+        />
+        <YAxis
+          yAxisId="right"
+          orientation="right"
+          className="text-xs fill-muted-foreground"
+          tick={{ fontSize: 12 }}
+          tickFormatter={(v) => formatCurrency(v)}
+        />
+        <Tooltip
+          formatter={(value, name) =>
+            name === "Revenue" ? formatCurrency(Number(value)) : value
+          }
+          contentStyle={{
+            backgroundColor: "hsl(var(--popover))",
+            border: "1px solid hsl(var(--border))",
+            borderRadius: "8px",
+            color: "hsl(var(--popover-foreground))",
+          }}
+        />
+        <Line yAxisId="left" type="monotone" dataKey="count" name="Jobs" stroke="#3b82f6" strokeWidth={2} dot={false} />
+        <Line yAxisId="right" type="monotone" dataKey="revenue" name="Revenue" stroke="#10b981" strokeWidth={2} dot={false} />
+        <Legend
+          formatter={(value) => (
+            <span className="text-xs text-muted-foreground">{value}</span>
+          )}
+        />
+      </LineChart>
+    </ResponsiveContainer>
+  );
+}

+ 69 - 0
src/features/reports/Components/PartsCharts.tsx

@@ -0,0 +1,69 @@
+"use client";
+
+import {
+  PieChart,
+  Pie,
+  Cell,
+  Tooltip,
+  Legend,
+  ResponsiveContainer,
+} from "recharts";
+import type { PartUsage } from "../Schema/reportTypes";
+
+const CHART_COLORS = [
+  "#3b82f6",
+  "#10b981",
+  "#f59e0b",
+  "#8b5cf6",
+  "#f43f5e",
+  "#06b6d4",
+  "#ec4899",
+  "#84cc16",
+];
+
+interface PartsDonutProps {
+  data: PartUsage[];
+}
+
+export function PartsDonut({ data }: PartsDonutProps) {
+  if (data.length === 0) return null;
+
+  const chartData = data.slice(0, 8).map((p) => ({
+    name: p.name.length > 25 ? p.name.slice(0, 23) + "\u2026" : p.name,
+    usageCount: p.usageCount,
+  }));
+
+  return (
+    <ResponsiveContainer width="100%" height={300}>
+      <PieChart>
+        <Pie
+          data={chartData}
+          dataKey="usageCount"
+          nameKey="name"
+          cx="50%"
+          cy="50%"
+          innerRadius={60}
+          outerRadius={80}
+          paddingAngle={2}
+        >
+          {chartData.map((_, i) => (
+            <Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
+          ))}
+        </Pie>
+        <Tooltip
+          contentStyle={{
+            backgroundColor: "hsl(var(--popover))",
+            border: "1px solid hsl(var(--border))",
+            borderRadius: "8px",
+            color: "hsl(var(--popover-foreground))",
+          }}
+        />
+        <Legend
+          formatter={(value) => (
+            <span className="text-xs text-muted-foreground">{value}</span>
+          )}
+        />
+      </PieChart>
+    </ResponsiveContainer>
+  );
+}

+ 63 - 0
src/features/reports/Components/RetentionCharts.tsx

@@ -0,0 +1,63 @@
+"use client";
+
+import {
+  BarChart,
+  Bar,
+  XAxis,
+  YAxis,
+  CartesianGrid,
+  Tooltip,
+  ResponsiveContainer,
+} from "recharts";
+import type { RetentionCustomer } from "../Schema/reportTypes";
+
+interface RetentionBarChartProps {
+  data: RetentionCustomer[];
+  formatCurrency: (value: number) => string;
+}
+
+export function RetentionBarChart({ data, formatCurrency }: RetentionBarChartProps) {
+  if (data.length === 0) return null;
+
+  const chartData = data.slice(0, 10).map((c) => ({
+    name: c.name.length > 20 ? c.name.slice(0, 18) + "\u2026" : c.name,
+    visitCount: c.visitCount,
+    totalSpent: c.totalSpent,
+  }));
+
+  return (
+    <ResponsiveContainer width="100%" height={Math.max(200, chartData.length * 40)}>
+      <BarChart
+        data={chartData}
+        layout="vertical"
+        margin={{ top: 5, right: 20, left: 10, bottom: 5 }}
+      >
+        <CartesianGrid strokeDasharray="3 3" className="stroke-border" />
+        <XAxis
+          type="number"
+          className="text-xs fill-muted-foreground"
+          tick={{ fontSize: 12 }}
+        />
+        <YAxis
+          type="category"
+          dataKey="name"
+          className="text-xs fill-muted-foreground"
+          tick={{ fontSize: 12 }}
+          width={140}
+        />
+        <Tooltip
+          formatter={(value, name) =>
+            name === "Total Spent" ? formatCurrency(Number(value)) : value
+          }
+          contentStyle={{
+            backgroundColor: "hsl(var(--popover))",
+            border: "1px solid hsl(var(--border))",
+            borderRadius: "8px",
+            color: "hsl(var(--popover-foreground))",
+          }}
+        />
+        <Bar dataKey="visitCount" name="Visits" fill="#8b5cf6" radius={[0, 4, 4, 0]} />
+      </BarChart>
+    </ResponsiveContainer>
+  );
+}

+ 61 - 0
src/features/reports/Components/TechnicianCharts.tsx

@@ -0,0 +1,61 @@
+"use client";
+
+import {
+  BarChart,
+  Bar,
+  XAxis,
+  YAxis,
+  CartesianGrid,
+  Tooltip,
+  ResponsiveContainer,
+} from "recharts";
+import type { TechnicianMetrics } from "../Schema/reportTypes";
+
+interface TechnicianBarChartProps {
+  data: TechnicianMetrics[];
+  formatCurrency: (value: number) => string;
+}
+
+export function TechnicianBarChart({ data, formatCurrency }: TechnicianBarChartProps) {
+  if (data.length === 0) return null;
+
+  const chartData = data.slice(0, 10).map((t) => ({
+    name: t.techName.length > 20 ? t.techName.slice(0, 18) + "\u2026" : t.techName,
+    totalRevenue: t.totalRevenue,
+  }));
+
+  return (
+    <ResponsiveContainer width="100%" height={Math.max(200, chartData.length * 40)}>
+      <BarChart
+        data={chartData}
+        layout="vertical"
+        margin={{ top: 5, right: 20, left: 10, bottom: 5 }}
+      >
+        <CartesianGrid strokeDasharray="3 3" className="stroke-border" />
+        <XAxis
+          type="number"
+          className="text-xs fill-muted-foreground"
+          tick={{ fontSize: 12 }}
+          tickFormatter={(v) => formatCurrency(v)}
+        />
+        <YAxis
+          type="category"
+          dataKey="name"
+          className="text-xs fill-muted-foreground"
+          tick={{ fontSize: 12 }}
+          width={140}
+        />
+        <Tooltip
+          formatter={(value) => formatCurrency(Number(value))}
+          contentStyle={{
+            backgroundColor: "hsl(var(--popover))",
+            border: "1px solid hsl(var(--border))",
+            borderRadius: "8px",
+            color: "hsl(var(--popover-foreground))",
+          }}
+        />
+        <Bar dataKey="totalRevenue" name="Revenue" fill="#8b5cf6" radius={[0, 4, 4, 0]} />
+      </BarChart>
+    </ResponsiveContainer>
+  );
+}

+ 72 - 0
src/features/reports/Components/csv-export.ts

@@ -3,6 +3,10 @@ import type {
   ServiceReport,
   CustomerReport,
   InventoryReport,
+  TechnicianReport,
+  PartsUsageReport,
+  JobAnalyticsReport,
+  CustomerRetentionReport,
 } from "../Schema/reportTypes";
 import { formatCurrency } from "@/lib/format";
 
@@ -94,3 +98,71 @@ export function exportInventoryCsv(data: InventoryReport, currencyCode: string)
     ["name", "partNumber", "quantity", "minQuantity", "unitCost"],
   );
 }
+
+export function exportTechniciansCsv(data: TechnicianReport, currencyCode: string) {
+  const fmt = (n: number) => formatCurrency(n, currencyCode);
+  const rows = data.technicians.map((t) => ({
+    techName: t.techName,
+    jobCount: t.jobCount,
+    totalRevenue: fmt(t.totalRevenue),
+    avgRevenue: fmt(t.avgRevenue),
+    totalHours: t.totalLaborHours.toFixed(1),
+    avgHours: t.avgHours.toFixed(1),
+  }));
+  downloadCsv(
+    "technicians-report.csv",
+    ["Technician", "Jobs", "Total Revenue", "Avg Revenue", "Total Hours", "Avg Hours"],
+    rows,
+    ["techName", "jobCount", "totalRevenue", "avgRevenue", "totalHours", "avgHours"],
+  );
+}
+
+export function exportPartsCsv(data: PartsUsageReport, currencyCode: string) {
+  const fmt = (n: number) => formatCurrency(n, currencyCode);
+  const rows = data.parts.map((p) => ({
+    name: p.name,
+    partNumber: p.partNumber ?? "",
+    usageCount: p.usageCount,
+    totalQuantity: p.totalQuantity,
+    totalRevenue: fmt(p.totalRevenue),
+  }));
+  downloadCsv(
+    "parts-usage-report.csv",
+    ["Part Name", "Part #", "Usage Count", "Total Qty", "Total Revenue"],
+    rows,
+    ["name", "partNumber", "usageCount", "totalQuantity", "totalRevenue"],
+  );
+}
+
+export function exportJobAnalyticsCsv(data: JobAnalyticsReport, currencyCode: string) {
+  const fmt = (n: number) => formatCurrency(n, currencyCode);
+  const rows = data.topServiceTypes.map((t) => ({
+    type: t.type,
+    count: t.count,
+    avgValue: fmt(t.avgValue),
+    avgHours: t.avgHours.toFixed(1),
+  }));
+  downloadCsv(
+    "job-analytics-report.csv",
+    ["Service Type", "Count", "Avg Value", "Avg Hours"],
+    rows,
+    ["type", "count", "avgValue", "avgHours"],
+  );
+}
+
+export function exportRetentionCsv(data: CustomerRetentionReport, currencyCode: string) {
+  const fmt = (n: number) => formatCurrency(n, currencyCode);
+  const rows = data.topReturning.map((c) => ({
+    name: c.name,
+    company: c.company ?? "",
+    visitCount: c.visitCount,
+    totalSpent: fmt(c.totalSpent),
+    avgDaysBetweenVisits: c.avgTimeBetweenVisits ?? "",
+  }));
+  downloadCsv(
+    "retention-report.csv",
+    ["Customer", "Company", "Visits", "Total Spent", "Avg Days Between Visits"],
+    rows,
+    ["name", "company", "visitCount", "totalSpent", "avgDaysBetweenVisits"],
+  );
+}

+ 74 - 0
src/features/reports/Schema/reportTypes.ts

@@ -69,3 +69,77 @@ export interface InventoryReport {
   totalValue: number;
   lowStock: LowStockPart[];
 }
+
+// Enhanced report types
+
+export interface TechnicianMetrics {
+  techName: string;
+  jobCount: number;
+  totalRevenue: number;
+  avgRevenue: number;
+  totalLaborHours: number;
+  avgHours: number;
+}
+
+export interface TechnicianReport {
+  technicians: TechnicianMetrics[];
+  totalJobs: number;
+  totalRevenue: number;
+}
+
+export interface PartUsage {
+  name: string;
+  partNumber: string | null;
+  usageCount: number;
+  totalQuantity: number;
+  totalRevenue: number;
+}
+
+export interface PartsUsageReport {
+  parts: PartUsage[];
+  totalPartsRevenue: number;
+  totalPartsUsed: number;
+}
+
+export interface DayOfWeekDistribution {
+  day: string;
+  count: number;
+}
+
+export interface MonthlyTrend {
+  month: string;
+  count: number;
+  revenue: number;
+}
+
+export interface ServiceTypeAnalytics {
+  type: string;
+  count: number;
+  avgValue: number;
+  avgHours: number;
+}
+
+export interface JobAnalyticsReport {
+  avgJobValue: number;
+  totalJobs: number;
+  topServiceTypes: ServiceTypeAnalytics[];
+  dayOfWeek: DayOfWeekDistribution[];
+  monthlyTrend: MonthlyTrend[];
+}
+
+export interface RetentionCustomer {
+  id: string;
+  name: string;
+  company: string | null;
+  visitCount: number;
+  totalSpent: number;
+  avgTimeBetweenVisits: number | null;
+}
+
+export interface CustomerRetentionReport {
+  returningCustomers: number;
+  newCustomers: number;
+  totalActive: number;
+  avgTimeBetweenVisits: number | null;
+  topReturning: RetentionCustomer[];
+}

+ 57 - 0
src/lib/rate-limit.ts

@@ -0,0 +1,57 @@
+import { NextResponse } from "next/server";
+
+interface RateLimitEntry {
+  count: number;
+  resetAt: number;
+}
+
+const store = new Map<string, RateLimitEntry>();
+
+// Clean up expired entries every 5 minutes
+setInterval(() => {
+  const now = Date.now();
+  for (const [key, entry] of store) {
+    if (now > entry.resetAt) {
+      store.delete(key);
+    }
+  }
+}, 5 * 60 * 1000);
+
+/**
+ * Simple in-memory rate limiter.
+ * Returns null if the request is allowed, or a 429 NextResponse if rate limited.
+ */
+export function rateLimit(
+  request: Request,
+  { limit = 30, windowMs = 60_000 }: { limit?: number; windowMs?: number } = {},
+): NextResponse | null {
+  const forwarded = request.headers.get("x-forwarded-for");
+  const ip = forwarded?.split(",")[0]?.trim() || "unknown";
+  const url = new URL(request.url);
+  const key = `${ip}:${url.pathname}`;
+
+  const now = Date.now();
+  const entry = store.get(key);
+
+  if (!entry || now > entry.resetAt) {
+    store.set(key, { count: 1, resetAt: now + windowMs });
+    return null;
+  }
+
+  entry.count++;
+
+  if (entry.count > limit) {
+    const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
+    return NextResponse.json(
+      { error: "Too many requests. Please try again later." },
+      {
+        status: 429,
+        headers: {
+          "Retry-After": String(retryAfter),
+        },
+      },
+    );
+  }
+
+  return null;
+}