Procházet zdrojové kódy

Merge branch 'main' of github.com:Torqvoice/torqvoice

Bernt Christian Egeland před 7 měsíci
rodič
revize
685bb8feab
99 změnil soubory, kde provedl 8837 přidání a 727 odebrání
  1. 0 1
      .devcontainer/devcontainer.json
  2. 7 0
      .devcontainer/docker-compose.yml
  3. 39 0
      .github/labeler.yml
  4. 53 0
      .github/release-drafter.yml
  5. 13 0
      .github/workflows/labeler.yml
  6. 37 0
      .github/workflows/release-drafter.yml
  7. 1 1
      README.md
  8. 8 1
      biome.json
  9. 1 1
      next.config.ts
  10. 1390 100
      package-lock.json
  11. 8 2
      package.json
  12. 0 0
      plans/aithauth_example.ts
  13. 25 4
      plans/project_overview.md
  14. 26 0
      prisma/migrations/20260216075347_user_invitations/migration.sql
  15. 59 0
      prisma/migrations/20260216105753_recurring_invoices/migration.sql
  16. 80 5
      prisma/schema.prisma
  17. 12 2
      src/app/(authenticated)/billing/billing-client.tsx
  18. 49 0
      src/app/(authenticated)/billing/recurring/page.tsx
  19. 65 0
      src/app/(authenticated)/calendar/page.tsx
  20. 4 3
      src/app/(authenticated)/dashboard-client.tsx
  21. 7 0
      src/app/(authenticated)/layout.tsx
  22. 4 3
      src/app/(authenticated)/quotes/[id]/quote-detail-client.tsx
  23. 3 2
      src/app/(authenticated)/quotes/quotes-client.tsx
  24. 446 30
      src/app/(authenticated)/reports/reports-client.tsx
  25. 98 1
      src/app/(authenticated)/settings/account/account-settings.tsx
  26. 174 2
      src/app/(authenticated)/settings/appearance/appearance-settings.tsx
  27. 25 2
      src/app/(authenticated)/settings/appearance/page.tsx
  28. 2 3
      src/app/(authenticated)/settings/currency/currency-settings.tsx
  29. 9 0
      src/app/(authenticated)/settings/email/page.tsx
  30. 8 0
      src/app/(authenticated)/settings/settings-nav.tsx
  31. 5 1
      src/app/(authenticated)/settings/team/page.tsx
  32. 105 4
      src/app/(authenticated)/settings/team/team-settings.tsx
  33. 3 2
      src/app/(authenticated)/vehicles/[id]/service-records-table.tsx
  34. 4 3
      src/app/(authenticated)/vehicles/[id]/service/[serviceId]/service-detail-client.tsx
  35. 0 4
      src/app/(authenticated)/vehicles/[id]/service/new/service-record-form.tsx
  36. 5 4
      src/app/(authenticated)/vehicles/[id]/vehicle-detail-client.tsx
  37. 7 1
      src/app/(authenticated)/work-orders/page.tsx
  38. 20 76
      src/app/(authenticated)/work-orders/work-orders-client.tsx
  39. 18 8
      src/app/(public)/auth/sign-up/page.tsx
  40. 17 3
      src/app/(public)/auth/sign-up/sign-up-form.tsx
  41. 15 13
      src/app/(public)/share/invoice/[orgId]/[token]/invoice-view.tsx
  42. 4 0
      src/app/(public)/share/invoice/[orgId]/[token]/page.tsx
  43. 1 9
      src/app/api/backup/import-invoice-ninja/route.ts
  44. 1 1
      src/app/api/backup/import-lubelog/route.ts
  45. 0 1
      src/app/api/backup/import/route.ts
  46. 156 0
      src/app/api/cron/recurring-invoices/route.ts
  47. 3 1
      src/app/api/quotes/[id]/pdf/route.ts
  48. 11 9
      src/app/api/services/[id]/pdf/route.ts
  49. 4 0
      src/app/api/share/invoice/[orgId]/[token]/checkout/route.ts
  50. 12 2
      src/app/api/share/invoice/[orgId]/[token]/pdf/route.ts
  51. 4 0
      src/app/api/share/invoice/[orgId]/[token]/verify/route.ts
  52. 1 0
      src/app/error.tsx
  53. 14 0
      src/components/app-sidebar.tsx
  54. 46 0
      src/components/date-settings-context.tsx
  55. 79 0
      src/components/new-work-order-button.tsx
  56. 13 8
      src/components/page-header.tsx
  57. 382 0
      src/components/vehicle-picker-dialog.tsx
  58. 3 28
      src/features/admin/Actions/testEmailConnection.ts
  59. 500 182
      src/features/admin/Components/admin-settings.tsx
  60. 24 0
      src/features/admin/Schema/systemSettingsSchema.ts
  61. 163 62
      src/features/billing/Actions/billingActions.ts
  62. 332 0
      src/features/billing/Actions/recurringInvoiceActions.ts
  63. 546 0
      src/features/billing/Components/RecurringInvoicesClient.tsx
  64. 36 0
      src/features/billing/Schema/recurringInvoiceSchema.ts
  65. 166 0
      src/features/calendar/Actions/calendarActions.ts
  66. 352 0
      src/features/calendar/Components/CalendarClient.tsx
  67. 104 0
      src/features/calendar/Components/CalendarDayCell.tsx
  68. 129 0
      src/features/calendar/Components/CalendarEventList.tsx
  69. 16 0
      src/features/calendar/Components/calendar-utils.ts
  70. 7 7
      src/features/email/Actions/emailActions.ts
  71. 99 0
      src/features/email/Actions/emailSettingsActions.ts
  72. 780 0
      src/features/email/Components/EmailSettingsForm.tsx
  73. 47 0
      src/features/email/Schema/emailSettingsSchema.ts
  74. 9 11
      src/features/quotes/Components/QuotePDF.tsx
  75. 276 0
      src/features/reports/Actions/reportActions.ts
  76. 155 0
      src/features/reports/Components/JobAnalyticsCharts.tsx
  77. 69 0
      src/features/reports/Components/PartsCharts.tsx
  78. 63 0
      src/features/reports/Components/RetentionCharts.tsx
  79. 61 0
      src/features/reports/Components/TechnicianCharts.tsx
  80. 74 2
      src/features/reports/Components/csv-export.ts
  81. 74 0
      src/features/reports/Schema/reportTypes.ts
  82. 137 0
      src/features/settings/Actions/deleteAccount.ts
  83. 3 0
      src/features/settings/Schema/settingsSchema.ts
  84. 80 0
      src/features/team/Actions/acceptInvitation.ts
  85. 33 0
      src/features/team/Actions/cancelInvitation.ts
  86. 27 0
      src/features/team/Actions/getPendingInvitations.ts
  87. 80 0
      src/features/team/Actions/sendInvitation.ts
  88. 1 1
      src/features/team/Actions/teamActions.ts
  89. 13 0
      src/features/team/Schema/teamSchema.ts
  90. 3 3
      src/features/vehicles/Actions/createDraftServiceRecord.ts
  91. 10 13
      src/features/vehicles/Components/InvoicePDF.tsx
  92. 1 2
      src/features/vehicles/Components/ReminderForm.tsx
  93. 14 39
      src/lib/auth.ts
  94. 531 64
      src/lib/email.ts
  95. 140 0
      src/lib/format.ts
  96. 9 0
      src/lib/get-layout-data.ts
  97. 57 0
      src/lib/rate-limit.ts
  98. 20 0
      src/lib/use-format-date.ts
  99. 10 0
      src/lib/with-auth.ts

+ 0 - 1
.devcontainer/devcontainer.json

@@ -18,7 +18,6 @@
         "esbenp.prettier-vscode"
       ],
       "settings": {
-        "editor.defaultFormatter": "esbenp.prettier-vscode",
         "editor.formatOnSave": true
       }
     }

+ 7 - 0
.devcontainer/docker-compose.yml

@@ -13,6 +13,9 @@ services:
       - 3000:3000
     environment:
       - DATABASE_URL=postgresql://torqvoice:torqvoice@db:5432/torqvoice
+    networks:
+      - default
+      - torqvoice-shared
     depends_on:
       db:
         condition: service_healthy
@@ -55,3 +58,7 @@ services:
 volumes:
   postgres-data:
   pgadmin-data:
+
+networks:
+  torqvoice-shared:
+    external: true

+ 39 - 0
.github/labeler.yml

@@ -0,0 +1,39 @@
+feature:
+  - changed-files:
+      - any-glob-to-any-file:
+          - src/features/**
+          - src/app/**
+
+bug:
+  - changed-files:
+      - any-glob-to-any-file:
+          - src/**
+
+dependencies:
+  - changed-files:
+      - any-glob-to-any-file:
+          - package.json
+          - package-lock.json
+
+database:
+  - changed-files:
+      - any-glob-to-any-file:
+          - prisma/**
+
+docker:
+  - changed-files:
+      - any-glob-to-any-file:
+          - Dockerfile
+          - docker-compose*.yml
+          - .devcontainer/**
+
+documentation:
+  - changed-files:
+      - any-glob-to-any-file:
+          - '*.md'
+          - docs/**
+
+ci:
+  - changed-files:
+      - any-glob-to-any-file:
+          - .github/**

+ 53 - 0
.github/release-drafter.yml

@@ -0,0 +1,53 @@
+name-template: 'v$RESOLVED_VERSION'
+tag-template: 'v$RESOLVED_VERSION'
+categories:
+  - title: '🚀 Features'
+    labels:
+      - 'feature'
+      - 'enhancement'
+  - title: '🐛 Bug Fixes'
+    labels:
+      - 'fix'
+      - 'bugfix'
+      - 'bug'
+  - title: '🗄️ Database'
+    labels:
+      - 'database'
+  - title: '🐳 Docker'
+    labels:
+      - 'docker'
+  - title: '📦 Dependencies'
+    labels:
+      - 'dependencies'
+  - title: '📝 Documentation'
+    labels:
+      - 'documentation'
+  - title: '⚙️ CI/CD'
+    labels:
+      - 'ci'
+change-template: '- $TITLE (#$NUMBER)'
+change-title-escapes: '\<*_&'
+exclude-contributors:
+  - sinamics
+version-resolver:
+  major:
+    labels:
+      - 'major'
+  minor:
+    labels:
+      - 'feature'
+      - 'enhancement'
+  patch:
+    labels:
+      - 'bug'
+      - 'bugfix'
+      - 'fix'
+      - 'dependencies'
+  default: patch
+template: |
+  ## Release Notes
+
+  $CHANGES
+
+  **Full Changelog**: https://github.com/$OWNER/$REPOSITORY/compare/$PREVIOUS_TAG...v$RESOLVED_VERSION
+sort-direction: 'ascending'

+ 13 - 0
.github/workflows/labeler.yml

@@ -0,0 +1,13 @@
+name: Pull Request Labeler
+
+on:
+  - pull_request_target
+
+jobs:
+  labeler:
+    permissions:
+      contents: read
+      pull-requests: write
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/labeler@v5

+ 37 - 0
.github/workflows/release-drafter.yml

@@ -0,0 +1,37 @@
+name: Release Drafter
+
+on:
+  pull_request:
+    types: [closed]
+  workflow_dispatch:
+
+permissions:
+  contents: write
+  pull-requests: write
+
+jobs:
+  update_release_draft:
+    permissions:
+      contents: write
+      pull-requests: write
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+          ref: main
+
+      - name: Get version
+        id: version
+        run: |
+          tag=${GITHUB_REF/refs\/tags\//}
+          version=${tag#v}
+          echo "tag=${tag}" >> $GITHUB_OUTPUT
+          echo "version=${version}" >> $GITHUB_OUTPUT
+
+      - name: Update release draft
+        uses: release-drafter/release-drafter@09c613e259eb8d4e7c81c2cb00618eb5fc4575a7 # v5.25.0
+        with:
+          version: ${{ steps.version.outputs.version }}
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

+ 1 - 1
README.md

@@ -33,7 +33,7 @@ Create a `docker-compose.yml`:
 ```yaml
 services:
   app:
-    image: ghcr.io/sinamics/torqvoice:main
+    image: ghcr.io/sinamics/torqvoice:latest
     restart: unless-stopped
     ports:
       - "3000:3000"

+ 8 - 1
biome.json

@@ -1,5 +1,5 @@
 {
-	"$schema": "https://biomejs.dev/schemas/2.2.2/schema.json",
+	"$schema": "https://biomejs.dev/schemas/2.3.15/schema.json",
 	"vcs": {
 		"enabled": true,
 		"clientKind": "git",
@@ -120,10 +120,17 @@
 		}
 	},
 	"css": {
+		"parser": {
+			"cssModules": true,
+			"allowWrongLineComments": true
+		},
 		"formatter": {
 			"enabled": true,
 			"indentStyle": "space",
 			"indentWidth": 2
+		},
+		"linter": {
+			"enabled": false
 		}
 	},
 	"assist": {

+ 1 - 1
next.config.ts

@@ -5,7 +5,7 @@ const nextConfig: NextConfig = {
   images: {
     remotePatterns: [],
   },
-  allowedDevOrigins: ['10.0.0.217'],
+  allowedDevOrigins: ['10.0.0.217', '192.168.30.111'],
   experimental: {
     proxyClientMaxBodySize: '2gb',
   },

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1390 - 100
package-lock.json


+ 8 - 2
package.json

@@ -4,18 +4,21 @@
   "private": true,
   "license": "Elastic-2.0",
   "scripts": {
-    "dev": "next dev",
+    "dev": "next dev --hostname 0.0.0.0",
     "build": "next build",
     "start": "next start",
     "lint": "biome lint .",
+    "lint:fix": "biome lint . --write",
     "format": "biome format . --write",
     "check": "biome check .",
     "check:fix": "biome check . --write"
   },
   "dependencies": {
+    "@aws-sdk/client-ses": "^3.990.0",
     "@hookform/resolvers": "^5.2.2",
     "@prisma/client": "^6.19.2",
     "@react-pdf/renderer": "^4.3.2",
+    "@sendgrid/mail": "^8.1.6",
     "@tanstack/react-query": "^5.90.21",
     "archiver": "^7.0.1",
     "better-auth": "^1.4.18",
@@ -24,12 +27,15 @@
     "clsx": "^2.1.1",
     "cmdk": "^1.1.1",
     "date-fns": "^4.1.0",
+    "form-data": "^4.0.5",
     "jszip": "^3.10.1",
     "lucide-react": "^0.563.0",
+    "mailgun.js": "^12.7.0",
     "next": "16.1.6",
     "next-themes": "^0.4.6",
     "nodemailer": "^8.0.1",
     "pdf-lib": "^1.17.1",
+    "postmark": "^4.0.5",
     "prisma": "^6.19.2",
     "qrcode.react": "^4.2.0",
     "radix-ui": "^1.4.3",
@@ -61,4 +67,4 @@
     "tw-animate-css": "^1.4.0",
     "typescript": "^5"
   }
-}
+}

+ 0 - 0
plans/aithauth_example.ts


+ 25 - 4
plans/project_overview.md

@@ -18,7 +18,28 @@ For submenus, use same layout as /settings were we have a child sidebar.
 
 Task List:
 
-- First user that register on application should automatically be super admin.
-- In the new super admin page, add new sidebar authentication and add option to disable new user registrations. also add other options that
-  you think is usefull for super admin for this type of applications.
-- Add email settings for super admin to set app wider email smtp setup with test mail function. this has to include all settings for smtp, tls starttls and so on. look online so we get all settings correct. 
+  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.

+ 26 - 0
prisma/migrations/20260216075347_user_invitations/migration.sql

@@ -0,0 +1,26 @@
+-- CreateTable
+CREATE TABLE "team_invitations" (
+    "id" TEXT NOT NULL,
+    "email" TEXT NOT NULL,
+    "role" TEXT NOT NULL DEFAULT 'member',
+    "token" TEXT NOT NULL,
+    "status" TEXT NOT NULL DEFAULT 'pending',
+    "expiresAt" TIMESTAMP(3) NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "organizationId" TEXT NOT NULL,
+    "invitedById" TEXT NOT NULL,
+
+    CONSTRAINT "team_invitations_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "team_invitations_token_key" ON "team_invitations"("token");
+
+-- CreateIndex
+CREATE INDEX "team_invitations_token_idx" ON "team_invitations"("token");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "team_invitations_email_organizationId_key" ON "team_invitations"("email", "organizationId");
+
+-- AddForeignKey
+ALTER TABLE "team_invitations" ADD CONSTRAINT "team_invitations_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 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;

+ 80 - 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")
@@ -474,6 +475,7 @@ model Organization {
   customFieldDefinitions CustomFieldDefinition[]
   settings               AppSetting[]
   subscription           Subscription?
+  invitations            TeamInvitation[]
 
   @@map("organizations")
 }
@@ -531,6 +533,23 @@ model SystemSetting {
   @@map("system_settings")
 }
 
+model TeamInvitation {
+  id             String       @id @default(cuid())
+  email          String
+  role           String       @default("member")
+  token          String       @unique
+  status         String       @default("pending") // pending | accepted | cancelled
+  expiresAt      DateTime
+  createdAt      DateTime     @default(now())
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+  invitedById    String
+
+  @@unique([email, organizationId])
+  @@index([token])
+  @@map("team_invitations")
+}
+
 // Stripe-ready models (implementation in follow-up)
 
 model SubscriptionPlan {
@@ -569,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")
+}

+ 12 - 2
src/app/(authenticated)/billing/billing-client.tsx

@@ -1,8 +1,9 @@
 "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 { useFormatDate } from "@/lib/use-format-date";
 import { Button } from "@/components/ui/button";
 import { Input } from "@/components/ui/input";
 import { Badge } from "@/components/ui/badge";
@@ -84,6 +85,7 @@ export default function BillingClient({
   statusFilter,
 }: BillingClientProps) {
   const router = useRouter();
+  const { formatDate } = useFormatDate();
   const pathname = usePathname();
   const searchParams = useSearchParams();
   const [isPending, startTransition] = useTransition();
@@ -175,6 +177,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">
@@ -313,7 +323,7 @@ export default function BillingClient({
                       {record.vehicle.customer?.name || "—"}
                     </TableCell>
                     <TableCell>
-                      {format(new Date(record.serviceDate), "MMM d, yyyy")}
+                      {formatDate(new Date(record.serviceDate))}
                     </TableCell>
                     <TableCell className="text-right">
                       {fmt(record.totalAmount)}

+ 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>
+    </>
+  );
+}

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

@@ -0,0 +1,65 @@
+import { getCalendarEvents } from "@/features/calendar/Actions/calendarActions";
+import { getVehicles } from "@/features/vehicles/Actions/vehicleActions";
+import { getCustomersList } from "@/features/customers/Actions/customerActions";
+import { getSettings } from "@/features/settings/Actions/settingsActions";
+import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
+import { PageHeader } from "@/components/page-header";
+import CalendarClient from "@/features/calendar/Components/CalendarClient";
+
+function toLocalDateStr(d: Date): string {
+  const y = d.getFullYear();
+  const m = String(d.getMonth() + 1).padStart(2, "0");
+  const day = String(d.getDate()).padStart(2, "0");
+  return `${y}-${m}-${day}`;
+}
+
+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 [eventsResult, settingsResult, vehiclesResult, customersResult] = await Promise.all([
+    getCalendarEvents({
+      start: toLocalDateStr(start),
+      end: toLocalDateStr(end),
+    }),
+    getSettings([SETTING_KEYS.CURRENCY_CODE]),
+    getVehicles(),
+    getCustomersList(),
+  ]);
+
+  const events = eventsResult.success && eventsResult.data ? eventsResult.data : [];
+  const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {};
+  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || "USD";
+  const vehicles = vehiclesResult.success && vehiclesResult.data
+    ? vehiclesResult.data.map((v) => ({
+        id: v.id,
+        make: v.make,
+        model: v.model,
+        year: v.year,
+        licensePlate: v.licensePlate,
+        customer: v.customer,
+      }))
+    : [];
+  const customers = customersResult.success && customersResult.data
+    ? customersResult.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()}
+          initialDay={now.getDate()}
+          todayStr={toLocalDateStr(now)}
+          vehicles={vehicles}
+          customers={customers}
+          currencyCode={currencyCode}
+        />
+      </div>
+    </>
+  );
+}

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

@@ -2,7 +2,7 @@
 
 import { useState } from "react";
 import { useRouter } from "next/navigation";
-import { format } from "date-fns";
+import { useFormatDate } from "@/lib/use-format-date";
 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
 import { Badge } from "@/components/ui/badge";
 import { Input } from "@/components/ui/input";
@@ -79,6 +79,7 @@ export function DashboardClient({
   upcomingReminders?: ReminderItem[];
 }) {
   const router = useRouter();
+  const { formatDate } = useFormatDate();
   const [navigatingId, setNavigatingId] = useState<string | null>(null);
 
   return (
@@ -200,7 +201,7 @@ export function DashboardClient({
                       )}
                       {r.dueDate && !isOverdue && !isDueSoon && (
                         <span className="text-xs text-muted-foreground">
-                          {format(new Date(r.dueDate), "MMM d")}
+                          {formatDate(new Date(r.dueDate))}
                         </span>
                       )}
                     </div>
@@ -248,7 +249,7 @@ export function DashboardClient({
                       }}
                     >
                       <TableCell className="font-mono text-xs">
-                        {format(new Date(s.serviceDate), "MM/dd/yy")}
+                        {formatDate(new Date(s.serviceDate))}
                       </TableCell>
                       <TableCell>
                         <div>

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

@@ -6,6 +6,7 @@ import { ConfirmProvider } from "@/components/confirm-dialog";
 import { getLayoutData } from "@/lib/get-layout-data";
 import { getFeatures, isCloudMode } from "@/lib/features";
 import { WhiteLabelCtaProvider } from "@/components/white-label-cta-context";
+import { DateSettingsProvider } from "@/components/date-settings-context";
 
 export default async function DashboardLayout({
   children,
@@ -29,6 +30,11 @@ export default async function DashboardLayout({
         } as React.CSSProperties
       }
     >
+      <DateSettingsProvider
+        dateFormat={data.dateFormat}
+        timeFormat={data.timeFormat}
+        timezone={data.timezone}
+      >
       <ConfirmProvider>
         <AppSidebar
           companyLogo={data.companyLogo}
@@ -40,6 +46,7 @@ export default async function DashboardLayout({
         <SidebarInset>{children}</SidebarInset>
         <SearchCommand />
       </ConfirmProvider>
+      </DateSettingsProvider>
     </SidebarProvider>
     </WhiteLabelCtaProvider>
   );

+ 4 - 3
src/app/(authenticated)/quotes/[id]/quote-detail-client.tsx

@@ -3,7 +3,7 @@
 import { useState } from "react";
 import Link from "next/link";
 import { useRouter } from "next/navigation";
-import { format } from "date-fns";
+import { useFormatDate } from "@/lib/use-format-date";
 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
 import { Button } from "@/components/ui/button";
 import { Badge } from "@/components/ui/badge";
@@ -95,6 +95,7 @@ export function QuoteDetailClient({
 }) {
   const cs = getCurrencySymbol(currencyCode);
   const router = useRouter();
+  const { formatDate } = useFormatDate();
   const modal = useGlassModal();
   const confirm = useConfirm();
   const [downloading, setDownloading] = useState(false);
@@ -175,8 +176,8 @@ export function QuoteDetailClient({
               <h1 className="text-2xl font-bold">{quote.title}</h1>
             </div>
             <p className="mt-1 text-sm text-muted-foreground">
-              {quote.quoteNumber} · Created {format(new Date(quote.createdAt), "MMMM d, yyyy")}
-              {quote.validUntil && ` · Valid until ${format(new Date(quote.validUntil), "MMM d, yyyy")}`}
+              {quote.quoteNumber} · Created {formatDate(new Date(quote.createdAt))}
+              {quote.validUntil && ` · Valid until ${formatDate(new Date(quote.validUntil))}`}
             </p>
           </div>
           <div className="flex items-center gap-2">

+ 3 - 2
src/app/(authenticated)/quotes/quotes-client.tsx

@@ -2,7 +2,7 @@
 
 import { useState, useCallback, useTransition } from "react";
 import { useRouter, usePathname, useSearchParams } from "next/navigation";
-import { format } from "date-fns";
+import { useFormatDate } from "@/lib/use-format-date";
 import { Badge } from "@/components/ui/badge";
 import { Button } from "@/components/ui/button";
 import { Input } from "@/components/ui/input";
@@ -64,6 +64,7 @@ export function QuotesClient({
   statusFilter: string;
 }) {
   const router = useRouter();
+  const { formatDate } = useFormatDate();
   const pathname = usePathname();
   const searchParams = useSearchParams();
   const [isPending, startTransition] = useTransition();
@@ -181,7 +182,7 @@ export function QuotesClient({
                     </Badge>
                   </TableCell>
                   <TableCell className="font-mono text-xs">
-                    {format(new Date(q.createdAt), "MM/dd/yy")}
+                    {formatDate(new Date(q.createdAt))}
                   </TableCell>
                   <TableCell className="text-right font-semibold">
                     {formatCurrency(q.totalAmount, currencyCode)}

+ 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>
   );
 }

+ 98 - 1
src/app/(authenticated)/settings/account/account-settings.tsx

@@ -17,12 +17,16 @@ import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
 import { Button } from '@/components/ui/button'
 import { Separator } from '@/components/ui/separator'
-import { Check, Copy, KeyRound, Loader2, Save, Shield, ShieldOff, User } from 'lucide-react'
+import { AlertTriangle, Check, Copy, KeyRound, Loader2, Save, Shield, ShieldOff, Trash2, User } from 'lucide-react'
 import { QRCodeSVG } from 'qrcode.react'
 import { updateEmail } from '@/features/settings/Actions/accountActions'
+import { deleteAccount } from '@/features/settings/Actions/deleteAccount'
+import { signOut } from '@/lib/auth-client'
+import { useRouter } from 'next/navigation'
 
 export function AccountSettings({ twoFactorEnabled: initialTwoFactorEnabled }: { twoFactorEnabled: boolean }) {
   const { data: session } = useSession()
+  const router = useRouter()
   const [name, setName] = useState('')
   const [email, setEmail] = useState('')
 
@@ -51,6 +55,29 @@ export function AccountSettings({ twoFactorEnabled: initialTwoFactorEnabled }: {
   const [verifying2FA, setVerifying2FA] = useState(false)
   const [copiedBackup, setCopiedBackup] = useState(false)
 
+  // Delete account state
+  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
+  const [deleteConfirmText, setDeleteConfirmText] = useState('')
+  const [deleting, setDeleting] = useState(false)
+
+  const handleDeleteAccount = async () => {
+    if (deleteConfirmText !== 'delete me') return
+    setDeleting(true)
+    try {
+      const result = await deleteAccount()
+      if (result.success) {
+        await signOut()
+        router.push('/auth/sign-in')
+      } else {
+        toast.error(result.error || 'Failed to delete account')
+        setDeleting(false)
+      }
+    } catch {
+      toast.error('Failed to delete account')
+      setDeleting(false)
+    }
+  }
+
   const handleEnable2FA = async () => {
     if (!twoFactorPassword) {
       toast.error('Please enter your password')
@@ -512,6 +539,76 @@ export function AccountSettings({ twoFactorEnabled: initialTwoFactorEnabled }: {
           )}
         </DialogContent>
       </Dialog>
+
+      {/* Danger Zone */}
+      <Card className="border-destructive/30 shadow-sm">
+        <CardHeader className="flex flex-row items-center gap-3 pb-4">
+          <AlertTriangle className="h-5 w-5 text-destructive" />
+          <CardTitle className="text-lg text-destructive">Danger Zone</CardTitle>
+        </CardHeader>
+        <CardContent className="space-y-4">
+          <div className="flex items-start justify-between gap-4">
+            <div>
+              <p className="font-medium">Delete Account</p>
+              <p className="text-sm text-muted-foreground">
+                Permanently delete your account and all associated data including vehicles, service records,
+                work orders, invoices, customers, files, and payments. This action cannot be undone.
+              </p>
+            </div>
+            <Button
+              variant="destructive"
+              onClick={() => { setDeleteConfirmText(''); setDeleteDialogOpen(true) }}
+            >
+              <Trash2 className="mr-2 h-4 w-4" />
+              Delete Account
+            </Button>
+          </div>
+        </CardContent>
+      </Card>
+
+      {/* Delete Account Confirmation Dialog */}
+      <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
+        <DialogContent className="sm:max-w-md">
+          <DialogHeader>
+            <DialogTitle className="text-destructive">Delete Account</DialogTitle>
+            <DialogDescription>
+              This will permanently delete your account and all data associated with it. This action is
+              irreversible. All your vehicles, service records, work orders, quotes, customers, files,
+              and payment history will be permanently removed.
+            </DialogDescription>
+          </DialogHeader>
+          <div className="space-y-3 py-2">
+            <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3">
+              <p className="text-sm font-medium text-destructive">
+                Type <span className="font-mono font-bold">delete me</span> to confirm
+              </p>
+            </div>
+            <Input
+              value={deleteConfirmText}
+              onChange={(e) => setDeleteConfirmText(e.target.value)}
+              placeholder="delete me"
+              autoComplete="off"
+            />
+          </div>
+          <DialogFooter>
+            <Button variant="outline" onClick={() => setDeleteDialogOpen(false)} disabled={deleting}>
+              Cancel
+            </Button>
+            <Button
+              variant="destructive"
+              onClick={handleDeleteAccount}
+              disabled={deleteConfirmText !== 'delete me' || deleting}
+            >
+              {deleting ? (
+                <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+              ) : (
+                <Trash2 className="mr-2 h-4 w-4" />
+              )}
+              {deleting ? 'Deleting...' : 'Permanently Delete'}
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
     </div>
   )
 }

+ 174 - 2
src/app/(authenticated)/settings/appearance/appearance-settings.tsx

@@ -1,13 +1,104 @@
 'use client'
 
+import { useState } from 'react'
+import { useRouter } from 'next/navigation'
 import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
 import { Label } from '@/components/ui/label'
 import { Switch } from '@/components/ui/switch'
+import { Button } from '@/components/ui/button'
 import { useTheme } from '@/components/theme-provider'
-import { Moon, Palette, Sun } from 'lucide-react'
+import { Calendar, Clock, Loader2, Moon, Palette, Save, Sun } from 'lucide-react'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+import { setSettings } from '@/features/settings/Actions/settingsActions'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { formatDate, formatDateTime, DEFAULT_DATE_FORMAT } from '@/lib/format'
+import { toast } from 'sonner'
 
-export function AppearanceSettings() {
+const DATE_FORMAT_OPTIONS = [
+  { value: "MMM d, yyyy", label: "MMM D, YYYY", example: "Feb 15, 2026" },
+  { value: "dd/MM/yyyy", label: "DD/MM/YYYY", example: "15/02/2026" },
+  { value: "MM/dd/yyyy", label: "MM/DD/YYYY", example: "02/15/2026" },
+  { value: "yyyy-MM-dd", label: "YYYY-MM-DD", example: "2026-02-15" },
+  { value: "d. MMM yyyy", label: "D. MMM YYYY", example: "15. Feb 2026" },
+  { value: "d MMMM yyyy", label: "D MMMM YYYY", example: "15 February 2026" },
+];
+
+const TIMEZONE_OPTIONS = [
+  "UTC",
+  "America/New_York",
+  "America/Chicago",
+  "America/Denver",
+  "America/Los_Angeles",
+  "America/Anchorage",
+  "Pacific/Honolulu",
+  "America/Toronto",
+  "America/Vancouver",
+  "America/Mexico_City",
+  "America/Sao_Paulo",
+  "America/Argentina/Buenos_Aires",
+  "Europe/London",
+  "Europe/Paris",
+  "Europe/Berlin",
+  "Europe/Madrid",
+  "Europe/Rome",
+  "Europe/Amsterdam",
+  "Europe/Brussels",
+  "Europe/Zurich",
+  "Europe/Vienna",
+  "Europe/Stockholm",
+  "Europe/Oslo",
+  "Europe/Copenhagen",
+  "Europe/Helsinki",
+  "Europe/Warsaw",
+  "Europe/Prague",
+  "Europe/Budapest",
+  "Europe/Bucharest",
+  "Europe/Athens",
+  "Europe/Istanbul",
+  "Europe/Moscow",
+  "Asia/Dubai",
+  "Asia/Kolkata",
+  "Asia/Bangkok",
+  "Asia/Singapore",
+  "Asia/Hong_Kong",
+  "Asia/Shanghai",
+  "Asia/Tokyo",
+  "Asia/Seoul",
+  "Australia/Sydney",
+  "Australia/Melbourne",
+  "Australia/Perth",
+  "Pacific/Auckland",
+  "Africa/Johannesburg",
+  "Africa/Cairo",
+  "Africa/Lagos",
+];
+
+export function AppearanceSettings({ settings }: { settings: Record<string, string> }) {
   const { theme, setTheme } = useTheme()
+  const router = useRouter()
+  const [saving, setSaving] = useState(false)
+
+  const [dateFormat, setDateFormat] = useState(settings[SETTING_KEYS.DATE_FORMAT] || DEFAULT_DATE_FORMAT)
+  const [timeFormat, setTimeFormat] = useState(settings[SETTING_KEYS.TIME_FORMAT] || "12h")
+  const [timezone, setTimezone] = useState(settings[SETTING_KEYS.TIMEZONE] || "")
+
+  const handleSave = async () => {
+    setSaving(true)
+    await setSettings({
+      [SETTING_KEYS.DATE_FORMAT]: dateFormat,
+      [SETTING_KEYS.TIME_FORMAT]: timeFormat,
+      [SETTING_KEYS.TIMEZONE]: timezone,
+    })
+    setSaving(false)
+    router.refresh()
+    toast.success("Settings saved")
+  }
 
   return (
     <div className="space-y-6">
@@ -40,6 +131,87 @@ export function AppearanceSettings() {
           </div>
         </CardContent>
       </Card>
+
+      <Card className="border-0 shadow-sm">
+        <CardHeader className="flex flex-row items-center gap-3 pb-4">
+          <Calendar className="h-5 w-5 text-muted-foreground" />
+          <CardTitle className="text-lg">Date & Time</CardTitle>
+        </CardHeader>
+        <CardContent className="space-y-6">
+          <p className="text-sm text-muted-foreground">
+            Configure how dates and times are displayed across the application.
+          </p>
+
+          <div className="space-y-2">
+            <Label>Date Format</Label>
+            <Select value={dateFormat} onValueChange={setDateFormat}>
+              <SelectTrigger className="w-64">
+                <SelectValue />
+              </SelectTrigger>
+              <SelectContent>
+                {DATE_FORMAT_OPTIONS.map((opt) => (
+                  <SelectItem key={opt.value} value={opt.value}>
+                    {opt.label} ({opt.example})
+                  </SelectItem>
+                ))}
+              </SelectContent>
+            </Select>
+          </div>
+
+          <div className="space-y-2">
+            <Label>Time Format</Label>
+            <Select value={timeFormat} onValueChange={setTimeFormat}>
+              <SelectTrigger className="w-48">
+                <SelectValue />
+              </SelectTrigger>
+              <SelectContent>
+                <SelectItem value="12h">12-hour (2:30 PM)</SelectItem>
+                <SelectItem value="24h">24-hour (14:30)</SelectItem>
+              </SelectContent>
+            </Select>
+          </div>
+
+          <div className="space-y-2">
+            <Label>Timezone</Label>
+            <Select value={timezone || "__auto__"} onValueChange={(v) => setTimezone(v === "__auto__" ? "" : v)}>
+              <SelectTrigger className="w-72">
+                <SelectValue />
+              </SelectTrigger>
+              <SelectContent>
+                <SelectItem value="__auto__">Auto-detect (browser)</SelectItem>
+                {TIMEZONE_OPTIONS.map((tz) => (
+                  <SelectItem key={tz} value={tz}>
+                    {tz.replace(/_/g, " ")}
+                  </SelectItem>
+                ))}
+              </SelectContent>
+            </Select>
+            <p className="text-xs text-muted-foreground">
+              When set to auto-detect, each user sees dates in their browser&apos;s timezone.
+            </p>
+          </div>
+
+          <div className="rounded-lg border p-3 text-sm text-muted-foreground">
+            <div className="flex items-center gap-2 mb-1">
+              <Clock className="h-3.5 w-3.5" />
+              <span className="font-medium text-foreground">Preview</span>
+            </div>
+            <p>Date: <span className="font-medium text-foreground">{formatDate(new Date(), dateFormat, timezone || undefined)}</span></p>
+            <p>Date & Time: <span className="font-medium text-foreground">{formatDateTime(new Date(), dateFormat, timeFormat as "12h" | "24h", timezone || undefined)}</span></p>
+          </div>
+
+          <div className="flex items-center gap-3">
+            <Button onClick={handleSave} disabled={saving}>
+              {saving ? (
+                <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+              ) : (
+                <Save className="mr-2 h-4 w-4" />
+              )}
+              Save Settings
+            </Button>
+          </div>
+        </CardContent>
+      </Card>
     </div>
   )
 }

+ 25 - 2
src/app/(authenticated)/settings/appearance/page.tsx

@@ -1,5 +1,28 @@
 import { AppearanceSettings } from "./appearance-settings";
+import { db } from "@/lib/db";
+import { getAuthContext } from "@/lib/get-auth-context";
+import { redirect } from "next/navigation";
+import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
 
-export default function AppearanceSettingsPage() {
-  return <AppearanceSettings />;
+export default async function AppearanceSettingsPage() {
+  const ctx = await getAuthContext();
+  if (!ctx) redirect("/sign-in");
+
+  const rows = await db.appSetting.findMany({
+    where: {
+      organizationId: ctx.organizationId,
+      key: {
+        in: [
+          SETTING_KEYS.DATE_FORMAT,
+          SETTING_KEYS.TIME_FORMAT,
+          SETTING_KEYS.TIMEZONE,
+        ],
+      },
+    },
+  });
+
+  const settings: Record<string, string> = {};
+  for (const r of rows) settings[r.key] = r.value;
+
+  return <AppearanceSettings settings={settings} />;
 }

+ 2 - 3
src/app/(authenticated)/settings/currency/currency-settings.tsx

@@ -64,7 +64,6 @@ export function CurrencySettings({ settings }: { settings: Record<string, string
   const [taxEnabled, setTaxEnabled] = useState(settings[SETTING_KEYS.TAX_ENABLED] !== "false");
   const [defaultTaxRate, setDefaultTaxRate] = useState(settings[SETTING_KEYS.DEFAULT_TAX_RATE] || "0");
   const [unitSystem, setUnitSystem] = useState(settings[SETTING_KEYS.UNIT_SYSTEM] || "imperial");
-
   const handleSave = async () => {
     setSaving(true);
     await setSettings({
@@ -75,7 +74,7 @@ export function CurrencySettings({ settings }: { settings: Record<string, string
     });
     setSaving(false);
     router.refresh();
-    toast.success("Currency settings saved");
+    toast.success("Settings saved");
   };
 
   return (
@@ -188,7 +187,7 @@ export function CurrencySettings({ settings }: { settings: Record<string, string
               ) : (
                 <Save className="mr-2 h-4 w-4" />
               )}
-              Save Currency Settings
+              Save Settings
             </Button>
           </div>
         </CardContent>

+ 9 - 0
src/app/(authenticated)/settings/email/page.tsx

@@ -0,0 +1,9 @@
+import { getEmailSettings } from "@/features/email/Actions/emailSettingsActions";
+import { EmailSettingsForm } from "@/features/email/Components/EmailSettingsForm";
+
+export default async function EmailSettingsPage() {
+  const result = await getEmailSettings();
+  const settings = result.success && result.data ? result.data : {};
+
+  return <EmailSettingsForm initial={settings} />;
+}

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

@@ -15,6 +15,7 @@ import {
   Key,
   Layout,
   ListPlus,
+  Mail,
   Palette,
   UserCog,
   UsersRound,
@@ -89,6 +90,13 @@ const settingsNav: SettingsNavItem[] = [
     description: "Define custom data fields",
     gate: "customFields",
   },
+  {
+    title: "Email",
+    href: "/settings/email",
+    icon: Mail,
+    description: "Email provider & sending",
+    gate: "smtp",
+  },
   {
     title: "Workshop",
     href: "/settings/workshop",

+ 5 - 1
src/app/(authenticated)/settings/team/page.tsx

@@ -1,20 +1,24 @@
 import { getOrganization } from "@/features/team/Actions/teamActions";
 import { getRoles } from "@/features/team/Actions/getRoles";
+import { getPendingInvitations } from "@/features/team/Actions/getPendingInvitations";
 import { TeamSettings } from "./team-settings";
 
 export default async function TeamPage() {
-  const [result, rolesResult] = await Promise.all([
+  const [result, rolesResult, invitationsResult] = await Promise.all([
     getOrganization(),
     getRoles(),
+    getPendingInvitations(),
   ]);
   const orgData = result.success ? result.data : null;
   const roles = rolesResult.success && rolesResult.data ? rolesResult.data : [];
+  const pendingInvitations = invitationsResult.success && invitationsResult.data ? invitationsResult.data : [];
 
   return (
     <TeamSettings
       organization={orgData?.organization || null}
       currentRole={orgData?.currentRole || null}
       roles={roles}
+      pendingInvitations={pendingInvitations}
     />
   );
 }

+ 105 - 4
src/app/(authenticated)/settings/team/team-settings.tsx

@@ -24,12 +24,14 @@ import {
   updateMemberRole,
   removeMember,
 } from "@/features/team/Actions/teamActions";
+import { sendInvitation } from "@/features/team/Actions/sendInvitation";
+import { cancelInvitation } from "@/features/team/Actions/cancelInvitation";
 import { createRole } from "@/features/team/Actions/createRole";
 import { updateRole } from "@/features/team/Actions/updateRole";
 import { deleteRole } from "@/features/team/Actions/deleteRole";
 import { assignRole } from "@/features/team/Actions/assignRole";
 import { permissionGroups } from "@/lib/permissions";
-import { Crown, Loader2, Pencil, Plus, Shield, ShieldCheck, Trash2, User, Users } from "lucide-react";
+import { Crown, Loader2, Mail, Pencil, Plus, Shield, ShieldCheck, Trash2, User, Users, X } from "lucide-react";
 
 interface Member {
   id: string;
@@ -44,6 +46,14 @@ interface Organization {
   members: Member[];
 }
 
+interface PendingInvitation {
+  id: string;
+  email: string;
+  role: string;
+  createdAt: Date;
+  expiresAt: Date;
+}
+
 interface RoleData {
   id: string;
   name: string;
@@ -68,10 +78,12 @@ export function TeamSettings({
   organization,
   currentRole,
   roles = [],
+  pendingInvitations = [],
 }: {
   organization: Organization | null;
   currentRole: string | null;
   roles?: RoleData[];
+  pendingInvitations?: PendingInvitation[];
 }) {
   const router = useRouter();
   const modal = useGlassModal();
@@ -209,15 +221,57 @@ export function TeamSettings({
     setLoading(true);
     const result = await inviteMember({ email: inviteEmail, role: inviteRole });
     if (result.success) {
-      setInviteEmail("");
-      router.refresh();
-      modal.open("success", "Invited", `Member invited successfully.`);
+      const data = result.data as { invited: boolean; userNotFound?: boolean };
+      if (data.userNotFound) {
+        // User doesn't exist — ask to send invitation email
+        const emailToInvite = inviteEmail;
+        const roleToInvite = inviteRole;
+        setLoading(false);
+        const ok = await confirm({
+          title: "User Not Found",
+          description: `No account found for "${emailToInvite}". Send them an invitation email to sign up and join your team?`,
+          confirmLabel: "Send Invitation",
+        });
+        if (ok) {
+          setLoading(true);
+          const sendResult = await sendInvitation({ email: emailToInvite, role: roleToInvite });
+          if (sendResult.success) {
+            setInviteEmail("");
+            router.refresh();
+            modal.open("success", "Invitation Sent", `An invitation email has been sent to ${emailToInvite}.`);
+          } else {
+            modal.open("error", "Error", sendResult.error || "Failed to send invitation");
+          }
+          setLoading(false);
+        }
+      } else {
+        setInviteEmail("");
+        router.refresh();
+        modal.open("success", "Invited", `Member invited successfully.`);
+      }
     } else {
       modal.open("error", "Error", result.error || "Failed to invite member");
     }
     setLoading(false);
   };
 
+  const handleCancelInvitation = async (invitation: PendingInvitation) => {
+    const ok = await confirm({
+      title: "Cancel Invitation",
+      description: `Cancel the invitation to ${invitation.email}? They will no longer be able to use this link to join.`,
+      confirmLabel: "Cancel Invitation",
+      destructive: true,
+    });
+    if (!ok) return;
+    const result = await cancelInvitation({ invitationId: invitation.id });
+    if (result.success) {
+      toast.success("Invitation cancelled");
+      router.refresh();
+    } else {
+      modal.open("error", "Error", result.error || "Failed to cancel invitation");
+    }
+  };
+
   const handleRoleChange = async (memberId: string, role: string) => {
     const result = await updateMemberRole({ memberId, role });
     if (result.success) {
@@ -402,6 +456,53 @@ export function TeamSettings({
         </CardContent>
       </Card>
 
+      {/* Pending Invitations Card */}
+      {isAdmin && pendingInvitations.length > 0 && (
+        <Card className="border-0 shadow-sm">
+          <CardHeader>
+            <CardTitle className="flex items-center gap-2 text-base">
+              <Mail className="h-4 w-4" /> Pending Invitations
+              <Badge variant="outline" className="ml-2 text-xs">
+                {pendingInvitations.length}
+              </Badge>
+            </CardTitle>
+          </CardHeader>
+          <CardContent>
+            <div className="space-y-2">
+              {pendingInvitations.map((invitation) => (
+                <div
+                  key={invitation.id}
+                  className="flex items-center gap-3 rounded-lg border p-3"
+                >
+                  <div className="flex h-9 w-9 items-center justify-center rounded-full bg-muted text-sm">
+                    <Mail className="h-4 w-4 text-muted-foreground" />
+                  </div>
+                  <div className="min-w-0 flex-1">
+                    <p className="truncate font-medium text-sm">{invitation.email}</p>
+                    <p className="text-xs text-muted-foreground">
+                      Invited as {invitation.role} &middot; Expires {new Date(invitation.expiresAt).toLocaleDateString()}
+                    </p>
+                  </div>
+                  <div className="flex items-center gap-2">
+                    <Badge variant="outline" className="text-xs bg-yellow-500/10 text-yellow-600 border-yellow-500/20">
+                      Pending
+                    </Badge>
+                    <Button
+                      variant="ghost"
+                      size="icon"
+                      className="h-8 w-8 text-muted-foreground hover:text-destructive"
+                      onClick={() => handleCancelInvitation(invitation)}
+                    >
+                      <X className="h-3.5 w-3.5" />
+                    </Button>
+                  </div>
+                </div>
+              ))}
+            </div>
+          </CardContent>
+        </Card>
+      )}
+
       {/* Custom Roles Card */}
       {isAdmin && (
         <Card className="border-0 shadow-sm">

+ 3 - 2
src/app/(authenticated)/vehicles/[id]/service-records-table.tsx

@@ -3,7 +3,7 @@
 import { useRouter, usePathname, useSearchParams } from "next/navigation";
 import { useCallback, useState, useTransition } from "react";
 import Link from "next/link";
-import { format } from "date-fns";
+import { useFormatDate } from "@/lib/use-format-date";
 import { Badge } from "@/components/ui/badge";
 import { Button } from "@/components/ui/button";
 import { Input } from "@/components/ui/input";
@@ -75,6 +75,7 @@ export function ServiceRecordsTable({
   currencyCode = "USD",
 }: ServiceRecordsTableProps) {
   const router = useRouter();
+  const { formatDate } = useFormatDate();
   const pathname = usePathname();
   const searchParams = useSearchParams();
   const [isPending, startTransition] = useTransition();
@@ -205,7 +206,7 @@ export function ServiceRecordsTable({
                     }}
                   >
                     <TableCell className="font-mono text-xs">
-                      {format(new Date(record.serviceDate), "MM/dd/yyyy")}
+                      {formatDate(new Date(record.serviceDate))}
                     </TableCell>
                     <TableCell className="max-w-0">
                       <div className="truncate">

+ 4 - 3
src/app/(authenticated)/vehicles/[id]/service/[serviceId]/service-detail-client.tsx

@@ -3,7 +3,7 @@
 import { useState, useEffect, useRef } from "react";
 import Link from "next/link";
 import { useRouter } from "next/navigation";
-import { format } from "date-fns";
+import { useFormatDate } from "@/lib/use-format-date";
 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
 import { Button } from "@/components/ui/button";
 import { Badge } from "@/components/ui/badge";
@@ -165,6 +165,7 @@ export function ServiceDetailClient({
   const cs = getCurrencySymbol(currencyCode);
   const distUnit = unitSystem === "metric" ? "km" : "mi";
   const router = useRouter();
+  const { formatDate } = useFormatDate();
   const modal = useGlassModal();
   const confirm = useConfirm();
   const [downloading, setDownloading] = useState(false);
@@ -371,7 +372,7 @@ export function ServiceDetailClient({
                 <h1 className="text-lg font-semibold leading-tight">{record.title}</h1>
               </div>
               <p className="text-xs text-muted-foreground">
-                {format(new Date(record.serviceDate), "MMMM d, yyyy")}
+                {formatDate(new Date(record.serviceDate))}
                 {record.shopName && ` · ${record.shopName}`}
                 {record.techName && ` · Tech: ${record.techName}`}
               </p>
@@ -976,7 +977,7 @@ export function ServiceDetailClient({
                 <tbody className="divide-y">
                   {record.payments.map((payment) => (
                     <tr key={payment.id}>
-                      <td className="py-2">{format(new Date(payment.date), "MMM d, yyyy")}</td>
+                      <td className="py-2">{formatDate(new Date(payment.date))}</td>
                       <td className="py-2 text-right font-medium">{formatCurrency(payment.amount, currencyCode)}</td>
                       <td className="py-2">
                         <Badge variant="outline" className="text-xs capitalize">

+ 0 - 4
src/app/(authenticated)/vehicles/[id]/service/new/service-record-form.tsx

@@ -37,9 +37,7 @@ import {
   CommandItem,
   CommandList,
 } from '@/components/ui/command'
-import { Switch } from '@/components/ui/switch'
 import {
-  Camera,
   Check,
   ChevronsUpDown,
   FileText,
@@ -50,8 +48,6 @@ import {
   Plus,
   Search,
   Trash2,
-  Upload,
-  X,
 } from 'lucide-react'
 import { formatCurrency, getCurrencySymbol } from '@/lib/format'
 

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

@@ -3,7 +3,7 @@
 import { useState } from 'react'
 import { useRouter } from 'next/navigation'
 import Link from 'next/link'
-import { format } from 'date-fns'
+import { useFormatDate } from '@/lib/use-format-date'
 import { Card, CardContent } from '@/components/ui/card'
 import { Button } from '@/components/ui/button'
 import { Badge } from '@/components/ui/badge'
@@ -148,6 +148,7 @@ export function VehicleDetailClient({
 }) {
   const distUnit = unitSystem === 'metric' ? 'km' : 'mi'
   const router = useRouter()
+  const { formatDate } = useFormatDate()
   const modal = useGlassModal()
   const [showEditForm, setShowEditForm] = useState(false)
   const [showNoteForm, setShowNoteForm] = useState(false)
@@ -332,7 +333,7 @@ export function VehicleDetailClient({
           {vehicle.purchaseDate && (
             <div className="flex items-center gap-1.5 text-muted-foreground">
               <span className="text-xs">
-                Purchased {format(new Date(vehicle.purchaseDate), 'MMM yyyy')}
+                Purchased {formatDate(new Date(vehicle.purchaseDate))}
               </span>
             </div>
           )}
@@ -414,7 +415,7 @@ export function VehicleDetailClient({
                         <p className="truncate text-sm text-muted-foreground">{n.content}</p>
                       </TableCell>
                       <TableCell className="font-mono text-xs text-muted-foreground">
-                        {format(new Date(n.createdAt), 'MMM d, yyyy')}
+                        {formatDate(new Date(n.createdAt))}
                       </TableCell>
                       <TableCell className="px-2">
                         <DropdownMenu>
@@ -541,7 +542,7 @@ export function VehicleDetailClient({
                             )}
                           </div>
                           <p className="text-xs text-muted-foreground">
-                            {r.dueDate && `Due ${format(new Date(r.dueDate), 'MMM d, yyyy')}`}
+                            {r.dueDate && `Due ${formatDate(new Date(r.dueDate))}`}
                             {r.dueMileage &&
                               `${r.dueDate ? ' · ' : 'Due at '}${r.dueMileage.toLocaleString()} ${distUnit}`}
                           </p>

+ 7 - 1
src/app/(authenticated)/work-orders/page.tsx

@@ -2,6 +2,7 @@ import { getWorkOrders } from "@/features/vehicles/Actions/serviceActions";
 import { getSettings } from "@/features/settings/Actions/settingsActions";
 import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
 import { getVehicles } from "@/features/vehicles/Actions/vehicleActions";
+import { getCustomersList } from "@/features/customers/Actions/customerActions";
 import { WorkOrdersClient } from "./work-orders-client";
 import { PageHeader } from "@/components/page-header";
 
@@ -16,7 +17,7 @@ export default async function WorkOrdersPage({
   }>;
 }) {
   const params = await searchParams;
-  const [result, settingsResult, vehiclesResult] = await Promise.all([
+  const [result, settingsResult, vehiclesResult, customersResult] = await Promise.all([
     getWorkOrders({
       page: params.page ? parseInt(params.page) : 1,
       pageSize: params.pageSize ? parseInt(params.pageSize) : 20,
@@ -25,6 +26,7 @@ export default async function WorkOrdersPage({
     }),
     getSettings([SETTING_KEYS.CURRENCY_CODE]),
     getVehicles(),
+    getCustomersList(),
   ]);
 
   if (!result.success || !result.data) {
@@ -52,6 +54,9 @@ export default async function WorkOrdersPage({
         customer: v.customer,
       }))
     : [];
+  const customers = customersResult.success && customersResult.data
+    ? customersResult.data
+    : [];
 
   return (
     <>
@@ -60,6 +65,7 @@ export default async function WorkOrdersPage({
         <WorkOrdersClient
           data={result.data}
           vehicles={vehicles}
+          customers={customers}
           currencyCode={currencyCode}
           search={params.search || ""}
           statusFilter={params.status || "all"}

+ 20 - 76
src/app/(authenticated)/work-orders/work-orders-client.tsx

@@ -1,9 +1,9 @@
 "use client";
 
-import { useState, useCallback, useTransition, useMemo } from "react";
+import { useState, useCallback, useTransition } from "react";
 import { useRouter, usePathname, useSearchParams } from "next/navigation";
 import { toast } from "sonner";
-import { format } from "date-fns";
+import { useFormatDate } from "@/lib/use-format-date";
 import { Badge } from "@/components/ui/badge";
 import { Button } from "@/components/ui/button";
 import { Input } from "@/components/ui/input";
@@ -15,12 +15,6 @@ import {
   TableHeader,
   TableRow,
 } from "@/components/ui/table";
-import {
-  Dialog,
-  DialogContent,
-  DialogHeader,
-  DialogTitle,
-} from "@/components/ui/dialog";
 import {
   DropdownMenu,
   DropdownMenuContent,
@@ -31,13 +25,13 @@ import { DataTablePagination } from "@/components/data-table-pagination";
 import { statusColors } from "@/lib/table-utils";
 import { updateServiceStatus } from "@/features/vehicles/Actions/serviceActions";
 import {
-  Car,
   ChevronDown,
   Loader2,
   Plus,
   Search,
 } from "lucide-react";
 import { formatCurrency } from "@/lib/format";
+import { VehiclePickerDialog } from "@/components/vehicle-picker-dialog";
 
 interface WorkOrder {
   id: string;
@@ -68,6 +62,12 @@ interface VehicleOption {
   customer: { id: string; name: string; company: string | null } | null;
 }
 
+interface CustomerOption {
+  id: string;
+  name: string;
+  company: string | null;
+}
+
 interface PaginatedData {
   records: WorkOrder[];
   total: number;
@@ -111,37 +111,26 @@ const statusTransitions: Record<string, { label: string; target: string }[]> = {
 export function WorkOrdersClient({
   data,
   vehicles = [],
+  customers = [],
   currencyCode = "USD",
   search,
   statusFilter,
 }: {
   data: PaginatedData;
   vehicles?: VehicleOption[];
+  customers?: CustomerOption[];
   currencyCode?: string;
   search: string;
   statusFilter: string;
 }) {
   const router = useRouter();
+  const { formatDate } = useFormatDate();
   const pathname = usePathname();
   const searchParams = useSearchParams();
   const [isPending, startTransition] = useTransition();
   const [searchInput, setSearchInput] = useState(search);
   const [navigatingId, setNavigatingId] = useState<string | null>(null);
   const [showPicker, setShowPicker] = useState(false);
-  const [vehicleSearch, setVehicleSearch] = useState("");
-
-  const filteredVehicles = useMemo(() => {
-    if (!vehicleSearch) return vehicles;
-    const q = vehicleSearch.toLowerCase();
-    return vehicles.filter(
-      (v) =>
-        v.make.toLowerCase().includes(q) ||
-        v.model.toLowerCase().includes(q) ||
-        v.licensePlate?.toLowerCase().includes(q) ||
-        v.customer?.name.toLowerCase().includes(q) ||
-        String(v.year).includes(q)
-    );
-  }, [vehicles, vehicleSearch]);
 
   const navigate = useCallback(
     (params: Record<string, string | number | undefined>) => {
@@ -288,7 +277,7 @@ export function WorkOrdersClient({
                       {r.techName || "-"}
                     </TableCell>
                     <TableCell className="font-mono text-xs">
-                      {format(new Date(r.serviceDate), "MM/dd/yy")}
+                      {formatDate(new Date(r.serviceDate))}
                     </TableCell>
                     <TableCell className="text-right font-semibold">
                       {formatCurrency(displayTotal, currencyCode)}
@@ -335,58 +324,13 @@ export function WorkOrdersClient({
         onNavigate={navigate}
       />
 
-      {/* Vehicle picker dialog */}
-      <Dialog open={showPicker} onOpenChange={setShowPicker}>
-        <DialogContent className="max-w-md">
-          <DialogHeader>
-            <DialogTitle>Select Vehicle</DialogTitle>
-          </DialogHeader>
-          <div className="space-y-3">
-            <div className="relative">
-              <Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
-              <Input
-                placeholder="Search vehicles..."
-                value={vehicleSearch}
-                onChange={(e) => setVehicleSearch(e.target.value)}
-                className="pl-9"
-                autoFocus
-              />
-            </div>
-            <div className="max-h-[300px] overflow-y-auto">
-              {filteredVehicles.length === 0 ? (
-                <p className="py-6 text-center text-sm text-muted-foreground">
-                  {vehicles.length === 0 ? "No vehicles found. Add a vehicle first." : "No vehicles match your search."}
-                </p>
-              ) : (
-                <div className="space-y-1">
-                  {filteredVehicles.map((v) => (
-                    <button
-                      key={v.id}
-                      type="button"
-                      className="flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left transition-colors hover:bg-muted"
-                      onClick={() => {
-                        setShowPicker(false);
-                        setVehicleSearch("");
-                        router.push(`/vehicles/${v.id}/service/new`);
-                      }}
-                    >
-                      <Car className="h-4 w-4 shrink-0 text-muted-foreground" />
-                      <div className="min-w-0 flex-1">
-                        <p className="text-sm font-medium">
-                          {v.year} {v.make} {v.model}
-                        </p>
-                        <p className="text-xs text-muted-foreground">
-                          {[v.licensePlate, v.customer?.name].filter(Boolean).join(" · ") || "No plate"}
-                        </p>
-                      </div>
-                    </button>
-                  ))}
-                </div>
-              )}
-            </div>
-          </div>
-        </DialogContent>
-      </Dialog>
+      <VehiclePickerDialog
+        open={showPicker}
+        onOpenChange={setShowPicker}
+        vehicles={vehicles}
+        customers={customers}
+        title="Select Vehicle"
+      />
     </div>
   );
 }

+ 18 - 8
src/app/(public)/auth/sign-up/page.tsx

@@ -4,15 +4,25 @@ import { SignUpForm } from './sign-up-form'
 
 export const dynamic = 'force-dynamic'
 
-export default async function SignUpPage() {
-  const regSetting = await db.systemSetting.findUnique({
-    where: { key: 'registration.disabled' },
-    select: { value: true },
-  })
+export default async function SignUpPage({
+  searchParams,
+}: {
+  searchParams: Promise<{ invite?: string }>
+}) {
+  const params = await searchParams
+  const inviteToken = params.invite
 
-  if (regSetting?.value === 'true') {
-    redirect('/auth/sign-in')
+  // If there's an invite token, skip the registration-disabled check
+  if (!inviteToken) {
+    const regSetting = await db.systemSetting.findUnique({
+      where: { key: 'registration.disabled' },
+      select: { value: true },
+    })
+
+    if (regSetting?.value === 'true') {
+      redirect('/auth/sign-in')
+    }
   }
 
-  return <SignUpForm />
+  return <SignUpForm inviteToken={inviteToken} />
 }

+ 17 - 3
src/app/(public)/auth/sign-up/sign-up-form.tsx

@@ -9,8 +9,9 @@ import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
 import { useGlassModal } from '@/components/glass-modal'
 import { Gauge, Loader2 } from 'lucide-react'
+import { acceptInvitation } from '@/features/team/Actions/acceptInvitation'
 
-export function SignUpForm() {
+export function SignUpForm({ inviteToken }: { inviteToken?: string }) {
   const [name, setName] = useState('')
   const [email, setEmail] = useState('')
   const [password, setPassword] = useState('')
@@ -36,6 +37,17 @@ export function SignUpForm() {
             ? 'Registration is currently disabled. Please contact your administrator.'
             : message,
         )
+      } else if (inviteToken) {
+        // Accept the invitation and redirect to dashboard
+        const acceptResult = await acceptInvitation({ token: inviteToken })
+        if (acceptResult.success) {
+          router.push('/')
+          router.refresh()
+        } else {
+          modal.open('error', 'Invitation Error', acceptResult.error || 'Failed to accept invitation')
+          router.push('/onboarding')
+          router.refresh()
+        }
       } else {
         router.push('/onboarding')
         router.refresh()
@@ -64,7 +76,9 @@ export function SignUpForm() {
           </div>
           <h1 className="text-2xl font-bold tracking-tight">Create an account</h1>
           <p className="mt-1 text-sm text-muted-foreground">
-            Set up your workshop to manage customer vehicles
+            {inviteToken
+              ? 'Create your account to join the team'
+              : 'Set up your workshop to manage customer vehicles'}
           </p>
         </div>
 
@@ -111,7 +125,7 @@ export function SignUpForm() {
 
           <Button type="submit" className="h-11 w-full" disabled={loading}>
             {loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
-            Create Account
+            {inviteToken ? 'Create Account & Join Team' : 'Create Account'}
           </Button>
         </form>
 

+ 15 - 13
src/app/(public)/share/invoice/[orgId]/[token]/invoice-view.tsx

@@ -13,7 +13,7 @@ import {
   Paperclip,
   X,
 } from 'lucide-react'
-import { formatCurrency } from '@/lib/format'
+import { formatCurrency, formatDate as fmtDate, DEFAULT_DATE_FORMAT } from '@/lib/format'
 
 interface InvoiceRecord {
   id: string
@@ -102,6 +102,8 @@ export function InvoiceView({
   showLogo = true,
   showCompanyName = true,
   showTorqvoiceBranding,
+  dateFormat,
+  timezone,
 }: {
   record: InvoiceRecord
   workshop: { name: string; address: string; phone: string; email: string }
@@ -114,6 +116,8 @@ export function InvoiceView({
   showLogo?: boolean
   showCompanyName?: boolean
   showTorqvoiceBranding?: boolean
+  dateFormat?: string
+  timezone?: string
 }) {
   const [carouselIndex, setCarouselIndex] = useState<number | null>(null)
   const [paymentAmount, setPaymentAmount] = useState('')
@@ -126,11 +130,9 @@ export function InvoiceView({
   const vehicleName = `${record.vehicle.year} ${record.vehicle.make} ${record.vehicle.model}`
   const displayTotal = record.totalAmount > 0 ? record.totalAmount : record.cost
   const invoiceNum = record.invoiceNumber || `INV-${record.id.slice(-8).toUpperCase()}`
-  const serviceDate = new Date(record.serviceDate).toLocaleDateString('en-US', {
-    year: 'numeric',
-    month: 'long',
-    day: 'numeric',
-  })
+  const df = dateFormat || DEFAULT_DATE_FORMAT
+  const tz = timezone || undefined
+  const serviceDate = fmtDate(record.serviceDate, df, tz)
   const totalPaid = record.payments.reduce((sum, p) => sum + p.amount, 0)
   const balanceDue = displayTotal - totalPaid
   const shopName = workshop.name || record.shopName || 'Torqvoice'
@@ -664,13 +666,13 @@ export function InvoiceView({
                   <div>
                     <p className="text-xs text-gray-500 dark:text-gray-400">Due Date</p>
                     <p className="font-medium">
-                      {new Date(
-                        new Date(record.serviceDate).getTime() + invoiceSettings.dueDays * 86400000
-                      ).toLocaleDateString('en-US', {
-                        year: 'numeric',
-                        month: 'long',
-                        day: 'numeric',
-                      })}
+                      {fmtDate(
+                        new Date(
+                          new Date(record.serviceDate).getTime() + invoiceSettings.dueDays * 86400000
+                        ),
+                        df,
+                        tz,
+                      )}
                     </p>
                   </div>
                 )}

+ 4 - 0
src/app/(public)/share/invoice/[orgId]/[token]/page.tsx

@@ -82,6 +82,8 @@ export default async function PublicInvoicePage({
             "invoice.showLogo",
             "invoice.showCompanyName",
             "payment.providersEnabled",
+            "workshop.dateFormat",
+            "workshop.timezone",
           ],
         },
       },
@@ -154,6 +156,8 @@ export default async function PublicInvoicePage({
       showLogo={showLogo}
       showCompanyName={showCompanyName}
       showTorqvoiceBranding={!features.brandingRemoved}
+      dateFormat={settingsMap["workshop.dateFormat"] || undefined}
+      timezone={settingsMap["workshop.timezone"] || undefined}
     />
   );
 }

+ 1 - 9
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) {
@@ -556,7 +548,7 @@ export async function POST(request: NextRequest) {
     return NextResponse.json({ error: message }, { status: 500 });
   } finally {
     if (tmpDir) {
-      rm(tmpDir, { recursive: true, force: true }).catch(() => {});
+      rm(tmpDir, { recursive: true, force: true }).catch(() => { /* ignore cleanup errors */ });
     }
   }
 }

+ 1 - 1
src/app/api/backup/import-lubelog/route.ts

@@ -430,7 +430,7 @@ export async function POST(request: NextRequest) {
   } finally {
     // Clean up temp directory
     if (tmpDir) {
-      rm(tmpDir, { recursive: true, force: true }).catch(() => {});
+      rm(tmpDir, { recursive: true, force: true }).catch(() => { /* ignore cleanup errors */ });
     }
   }
 }

+ 0 - 1
src/app/api/backup/import/route.ts

@@ -16,7 +16,6 @@ function isZipBuffer(buffer: ArrayBuffer): boolean {
 
 interface BackupData {
   version: number;
-  // biome-ignore lint: backup data shape is dynamic
   data: Record<string, any>;
 }
 

+ 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;
+}

+ 3 - 1
src/app/api/quotes/[id]/pdf/route.ts

@@ -78,13 +78,15 @@ export async function GET(
       currencyCode: settingsMap["workshop.currencyCode"] || "USD",
       logoDataUri,
       torqvoiceLogoDataUri,
+      dateFormat: settingsMap["workshop.dateFormat"] || undefined,
+      timezone: settingsMap["workshop.timezone"] || undefined,
       // eslint-disable-next-line @typescript-eslint/no-explicit-any
     }) as any;
     const buffer = await renderToBuffer(element);
 
     const quoteNum = quote.quoteNumber || `QT-${quote.id.slice(-8).toUpperCase()}`;
 
-    return new NextResponse(buffer.buffer as ArrayBuffer, {
+    return new NextResponse(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer, {
       headers: {
         "Content-Type": "application/pdf",
         "Content-Disposition": `attachment; filename="${quoteNum}.pdf"`,

+ 11 - 9
src/app/api/services/[id]/pdf/route.ts

@@ -9,6 +9,7 @@ import { PDFDocument } from "pdf-lib";
 import { resolveUploadPath } from "@/lib/resolve-upload-path";
 import { getFeatures } from "@/lib/features";
 import { getTorqvoiceLogoDataUri } from "@/lib/torqvoice-branding";
+import { formatDateForPdf } from "@/lib/format";
 
 export async function GET(
   _request: Request,
@@ -138,19 +139,20 @@ export async function GET(
       dueDays: Number(settingsMap["invoice.dueDays"]) || 0,
       currencyCode: settingsMap["workshop.currencyCode"] || "USD",
       unitSystem: settingsMap["workshop.unitSystem"] || "imperial",
+      dateFormat: settingsMap["workshop.dateFormat"] || undefined,
+      timezone: settingsMap["workshop.timezone"] || undefined,
     };
 
     // Build payment summary
+    const pdfDateFormat = settingsMap["workshop.dateFormat"] || undefined;
+    const pdfTimezone = settingsMap["workshop.timezone"] || undefined;
+
     const paymentSummary = record.payments && record.payments.length > 0
       ? {
           totalPaid: record.payments.reduce((sum: number, p: { amount: number }) => sum + p.amount, 0),
           payments: record.payments.map((p: { amount: number; date: Date; method: string }) => ({
             amount: p.amount,
-            date: new Date(p.date).toLocaleDateString("en-US", {
-              year: "numeric",
-              month: "short",
-              day: "numeric",
-            }),
+            date: formatDateForPdf(p.date, pdfDateFormat, pdfTimezone),
             method: p.method,
           })),
         }
@@ -194,7 +196,7 @@ export async function GET(
     const invoiceNum = record.invoiceNumber || `INV-${record.id.slice(-8).toUpperCase()}`;
 
     // Merge attached PDF diagnostic reports into the invoice
-    let finalBytes: ArrayBuffer;
+    let finalBuffer: ArrayBuffer;
     if (pdfAttachments.length > 0) {
       const mergedPdf = await PDFDocument.load(invoiceBuffer);
       for (const att of pdfAttachments) {
@@ -209,12 +211,12 @@ export async function GET(
         }
       }
       const saved = await mergedPdf.save();
-      finalBytes = saved.buffer as ArrayBuffer;
+      finalBuffer = saved.buffer.slice(saved.byteOffset, saved.byteOffset + saved.byteLength) as ArrayBuffer;
     } else {
-      finalBytes = invoiceBuffer.buffer as ArrayBuffer;
+      finalBuffer = invoiceBuffer.buffer.slice(invoiceBuffer.byteOffset, invoiceBuffer.byteOffset + invoiceBuffer.byteLength) as ArrayBuffer;
     }
 
-    return new NextResponse(finalBytes, {
+    return new NextResponse(finalBuffer, {
       headers: {
         "Content-Type": "application/pdf",
         "Content-Disposition": `attachment; filename="${invoiceNum}.pdf"`,

+ 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;
 

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

@@ -7,11 +7,16 @@ 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";
+import { formatDateForPdf } from "@/lib/format";
 
 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;
 
@@ -90,14 +95,19 @@ export async function GET(
       dueDays: Number(settingsMap["invoice.dueDays"]) || 0,
       currencyCode: settingsMap["workshop.currencyCode"] || "USD",
       unitSystem: settingsMap["workshop.unitSystem"] || "imperial",
+      dateFormat: settingsMap["workshop.dateFormat"] || undefined,
+      timezone: settingsMap["workshop.timezone"] || undefined,
     };
 
+    const pdfDateFormat = settingsMap["workshop.dateFormat"] || undefined;
+    const pdfTimezone = settingsMap["workshop.timezone"] || undefined;
+
     const paymentSummary = record.payments.length > 0
       ? {
           totalPaid: record.payments.reduce((sum: number, p: { amount: number }) => sum + p.amount, 0),
           payments: record.payments.map((p: { amount: number; date: Date; method: string }) => ({
             amount: p.amount,
-            date: new Date(p.date).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
+            date: formatDateForPdf(p.date, pdfDateFormat, pdfTimezone),
             method: p.method,
           })),
         }
@@ -137,7 +147,7 @@ export async function GET(
 
     const invoiceNum = record.invoiceNumber || `INV-${record.id.slice(-8).toUpperCase()}`;
 
-    return new NextResponse(buffer.buffer as ArrayBuffer, {
+    return new NextResponse(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer, {
       headers: {
         "Content-Type": "application/pdf",
         "Content-Disposition": `attachment; filename="${invoiceNum}.pdf"`,

+ 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;
 

+ 1 - 0
src/app/error.tsx

@@ -4,6 +4,7 @@ import { useEffect } from "react";
 import { Wrench } from "lucide-react";
 import { Button } from "@/components/ui/button";
 
+// biome-ignore lint/suspicious/noShadowRestrictedNames: Next.js error boundary convention
 export default function Error({
   error,
   reset,

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

@@ -32,6 +32,7 @@ import { Button } from '@/components/ui/button'
 import {
   BarChart3,
   Building2,
+  CalendarDays,
   Car,
   Check,
   ChevronsUpDown,
@@ -40,22 +41,26 @@ import {
   LayoutDashboard,
   Loader2,
   LogOut,
+  Moon,
   Package,
   Plus,
   Receipt,
   Settings,
   ShieldCheck,
+  Sun,
   Users,
 } from 'lucide-react'
 import { switchOrganization } from '@/features/team/Actions/switchOrganization'
 import { createNewOrganization } from '@/features/team/Actions/createNewOrganization'
 import type { PlanFeatures } from '@/lib/features'
+import { useTheme } from '@/components/theme-provider'
 
 const baseNavItems = [
   { title: 'Dashboard', url: '/', icon: LayoutDashboard },
   { 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 },
@@ -83,6 +88,7 @@ export function AppSidebar({
   const pathname = usePathname()
   const router = useRouter()
   const { data: session } = useSession()
+  const { theme, setTheme } = useTheme()
   const [showCreateOrg, setShowCreateOrg] = React.useState(false)
   const [newOrgName, setNewOrgName] = React.useState('')
   const [creatingOrg, setCreatingOrg] = React.useState(false)
@@ -264,6 +270,14 @@ export function AppSidebar({
                     Settings
                   </Link>
                 </DropdownMenuItem>
+                <DropdownMenuItem onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
+                  {theme === 'dark' ? (
+                    <Sun className="mr-2 size-4" />
+                  ) : (
+                    <Moon className="mr-2 size-4" />
+                  )}
+                  {theme === 'dark' ? 'Light Mode' : 'Dark Mode'}
+                </DropdownMenuItem>
                 <DropdownMenuSeparator />
                 <DropdownMenuItem onClick={handleSignOut}>
                   <LogOut className="mr-2 size-4" />

+ 46 - 0
src/components/date-settings-context.tsx

@@ -0,0 +1,46 @@
+"use client";
+
+import { createContext, useContext } from "react";
+import { DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT } from "@/lib/format";
+
+interface DateSettings {
+  dateFormat: string;
+  timeFormat: "12h" | "24h";
+  timezone: string;
+}
+
+const defaultSettings: DateSettings = {
+  dateFormat: DEFAULT_DATE_FORMAT,
+  timeFormat: DEFAULT_TIME_FORMAT,
+  timezone: "",
+};
+
+const DateSettingsContext = createContext<DateSettings>(defaultSettings);
+
+export function DateSettingsProvider({
+  dateFormat,
+  timeFormat,
+  timezone,
+  children,
+}: {
+  dateFormat?: string;
+  timeFormat?: string;
+  timezone?: string;
+  children: React.ReactNode;
+}) {
+  const value: DateSettings = {
+    dateFormat: dateFormat || DEFAULT_DATE_FORMAT,
+    timeFormat: (timeFormat === "24h" ? "24h" : "12h"),
+    timezone: timezone || "",
+  };
+
+  return (
+    <DateSettingsContext.Provider value={value}>
+      {children}
+    </DateSettingsContext.Provider>
+  );
+}
+
+export function useDateSettings() {
+  return useContext(DateSettingsContext);
+}

+ 79 - 0
src/components/new-work-order-button.tsx

@@ -0,0 +1,79 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Plus, Loader2 } from "lucide-react";
+import { VehiclePickerDialog } from "@/components/vehicle-picker-dialog";
+import { getVehicles } from "@/features/vehicles/Actions/vehicleActions";
+import { getCustomersList } from "@/features/customers/Actions/customerActions";
+
+interface VehicleOption {
+  id: string;
+  make: string;
+  model: string;
+  year: number;
+  licensePlate: string | null;
+  customer: { id: string; name: string; company: string | null } | null;
+}
+
+interface CustomerOption {
+  id: string;
+  name: string;
+  company: string | null;
+}
+
+export function NewWorkOrderButton() {
+  const [open, setOpen] = useState(false);
+  const [loading, setLoading] = useState(false);
+  const [vehicles, setVehicles] = useState<VehicleOption[]>([]);
+  const [customers, setCustomers] = useState<CustomerOption[]>([]);
+
+  const handleClick = async () => {
+    setLoading(true);
+    try {
+      const [vehiclesResult, customersResult] = await Promise.all([
+        getVehicles(),
+        getCustomersList(),
+      ]);
+      setVehicles(
+        vehiclesResult.success && vehiclesResult.data
+          ? vehiclesResult.data.map((v) => ({
+              id: v.id,
+              make: v.make,
+              model: v.model,
+              year: v.year,
+              licensePlate: v.licensePlate,
+              customer: v.customer,
+            }))
+          : []
+      );
+      setCustomers(
+        customersResult.success && customersResult.data
+          ? customersResult.data
+          : []
+      );
+      setOpen(true);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  return (
+    <>
+      <Button size="sm" onClick={handleClick} disabled={loading}>
+        {loading ? (
+          <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
+        ) : (
+          <Plus className="mr-1 h-3.5 w-3.5" />
+        )}
+        New Work Order
+      </Button>
+      <VehiclePickerDialog
+        open={open}
+        onOpenChange={setOpen}
+        vehicles={vehicles}
+        customers={customers}
+      />
+    </>
+  );
+}

+ 13 - 8
src/components/page-header.tsx

@@ -15,6 +15,7 @@ import { Separator } from '@/components/ui/separator'
 import { SidebarTrigger } from '@/components/ui/sidebar'
 import { Zap } from 'lucide-react'
 import { useShowWhiteLabelCta } from '@/components/white-label-cta-context'
+import { NewWorkOrderButton } from '@/components/new-work-order-button'
 
 const breadcrumbMap: Record<string, { parent?: string; parentHref?: string; label: string }> = {
   '/': { label: 'Dashboard' },
@@ -46,6 +47,7 @@ const breadcrumbMap: Record<string, { parent?: string; parentHref?: string; labe
   '/settings/currency': { parent: 'Settings', parentHref: '/settings', label: 'Currency' },
   '/settings/workshop': { parent: 'Settings', parentHref: '/settings', label: 'Workshop' },
   '/settings/appearance': { parent: 'Settings', parentHref: '/settings', label: 'Appearance' },
+  '/settings/email': { parent: 'Settings', parentHref: '/settings', label: 'Email' },
   '/settings/about': { parent: 'Settings', parentHref: '/settings', label: 'About' },
 }
 
@@ -104,14 +106,17 @@ export function PageHeader() {
           </BreadcrumbItem>
         </BreadcrumbList>
       </Breadcrumb>
-      {showWhiteLabelCta && (
-        <Button asChild variant="outline" size="sm" className="ml-auto">
-          <Link href="/settings/license">
-            <Zap className="mr-2 h-3 w-3" />
-            Purchase White-Label
-          </Link>
-        </Button>
-      )}
+      <div className="ml-auto flex items-center gap-2">
+        <NewWorkOrderButton />
+        {showWhiteLabelCta && (
+          <Button asChild variant="outline" size="sm">
+            <Link href="/settings/license">
+              <Zap className="mr-2 h-3 w-3" />
+              Purchase White-Label
+            </Link>
+          </Button>
+        )}
+      </div>
     </header>
   )
 }

+ 382 - 0
src/components/vehicle-picker-dialog.tsx

@@ -0,0 +1,382 @@
+"use client";
+
+import { useState, useMemo } from "react";
+import { useRouter } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import {
+  Dialog,
+  DialogContent,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import {
+  Loader2,
+  Plus,
+  Car,
+  Search,
+  ArrowLeft,
+  UserPlus,
+} from "lucide-react";
+import { createVehicle } from "@/features/vehicles/Actions/vehicleActions";
+import { createCustomer } from "@/features/customers/Actions/customerActions";
+import { toast } from "sonner";
+
+interface Vehicle {
+  id: string;
+  make: string;
+  model: string;
+  year: number;
+  licensePlate: string | null;
+  customer: { id: string; name: string; company: string | null } | null;
+}
+
+interface Customer {
+  id: string;
+  name: string;
+  company: string | null;
+}
+
+interface VehiclePickerDialogProps {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  vehicles: Vehicle[];
+  customers: Customer[];
+  title?: string;
+}
+
+export function VehiclePickerDialog({
+  open,
+  onOpenChange,
+  vehicles,
+  customers,
+  title = "Select Vehicle for Work Order",
+}: VehiclePickerDialogProps) {
+  const router = useRouter();
+
+  const [vehicleSearch, setVehicleSearch] = useState("");
+  const [pickerStep, setPickerStep] = useState<"select" | "create">("select");
+
+  const [newMake, setNewMake] = useState("");
+  const [newModel, setNewModel] = useState("");
+  const [newYear, setNewYear] = useState("");
+  const [newPlate, setNewPlate] = useState("");
+  const [selectedCustomerId, setSelectedCustomerId] = useState("");
+  const [showNewCustomer, setShowNewCustomer] = useState(false);
+  const [newCustomerName, setNewCustomerName] = useState("");
+  const [newCustomerPhone, setNewCustomerPhone] = useState("");
+  const [creating, setCreating] = useState(false);
+
+  const filteredVehicles = useMemo(() => {
+    if (!vehicleSearch.trim()) return vehicles;
+    const q = vehicleSearch.toLowerCase();
+    return vehicles.filter(
+      (v) =>
+        `${v.year} ${v.make} ${v.model}`.toLowerCase().includes(q) ||
+        v.licensePlate?.toLowerCase().includes(q) ||
+        v.customer?.name.toLowerCase().includes(q)
+    );
+  }, [vehicles, vehicleSearch]);
+
+  const resetState = () => {
+    setPickerStep("select");
+    setVehicleSearch("");
+    setNewMake("");
+    setNewModel("");
+    setNewYear("");
+    setNewPlate("");
+    setSelectedCustomerId("");
+    setShowNewCustomer(false);
+    setNewCustomerName("");
+    setNewCustomerPhone("");
+    setCreating(false);
+  };
+
+  const handleSelect = (vehicleId: string) => {
+    onOpenChange(false);
+    resetState();
+    router.push(`/vehicles/${vehicleId}/service/new`);
+  };
+
+  const handleOpenChange = (nextOpen: boolean) => {
+    onOpenChange(nextOpen);
+    if (!nextOpen) resetState();
+  };
+
+  const handleQuickCreate = async () => {
+    if (!newMake.trim() || !newModel.trim() || !newYear.trim()) return;
+    const yearNum = parseInt(newYear, 10);
+    if (isNaN(yearNum) || yearNum < 1900 || yearNum > new Date().getFullYear() + 2) {
+      toast.error("Please enter a valid year");
+      return;
+    }
+    if (showNewCustomer && !newCustomerName.trim()) {
+      toast.error("Customer name is required");
+      return;
+    }
+
+    setCreating(true);
+    try {
+      let customerId = selectedCustomerId || undefined;
+
+      if (showNewCustomer && newCustomerName.trim()) {
+        const customerResult = await createCustomer({
+          name: newCustomerName.trim(),
+          phone: newCustomerPhone.trim() || undefined,
+        });
+        if (!customerResult.success || !customerResult.data) {
+          toast.error(customerResult.error || "Failed to create customer");
+          setCreating(false);
+          return;
+        }
+        customerId = customerResult.data.id;
+      }
+
+      const vehicleResult = await createVehicle({
+        make: newMake.trim(),
+        model: newModel.trim(),
+        year: yearNum,
+        licensePlate: newPlate.trim() || undefined,
+        customerId,
+      });
+      if (!vehicleResult.success || !vehicleResult.data) {
+        toast.error(vehicleResult.error || "Failed to create vehicle");
+        setCreating(false);
+        return;
+      }
+
+      handleSelect(vehicleResult.data.id);
+    } catch {
+      toast.error("Something went wrong");
+      setCreating(false);
+    }
+  };
+
+  return (
+    <Dialog open={open} onOpenChange={handleOpenChange}>
+      <DialogContent className="max-w-md">
+        {pickerStep === "select" ? (
+          <>
+            <DialogHeader>
+              <DialogTitle>{title}</DialogTitle>
+            </DialogHeader>
+            <div className="space-y-3">
+              <div className="relative">
+                <Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+                <Input
+                  placeholder="Search vehicles..."
+                  value={vehicleSearch}
+                  onChange={(e) => setVehicleSearch(e.target.value)}
+                  className="pl-9"
+                  autoFocus
+                />
+              </div>
+              <div className="max-h-[300px] overflow-y-auto">
+                {filteredVehicles.length === 0 ? (
+                  <div className="py-6 text-center space-y-3">
+                    <p className="text-sm text-muted-foreground">
+                      {vehicles.length === 0
+                        ? "No vehicles found."
+                        : "No vehicles match your search."}
+                    </p>
+                    <Button
+                      variant="outline"
+                      onClick={() => setPickerStep("create")}
+                    >
+                      <Plus className="h-4 w-4 mr-1" />
+                      Add New Vehicle
+                    </Button>
+                  </div>
+                ) : (
+                  <div className="space-y-1">
+                    {filteredVehicles.map((v) => (
+                      <button
+                        key={v.id}
+                        type="button"
+                        className="flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left transition-colors hover:bg-muted"
+                        onClick={() => handleSelect(v.id)}
+                      >
+                        <Car className="h-4 w-4 shrink-0 text-muted-foreground" />
+                        <div className="min-w-0 flex-1">
+                          <p className="text-sm font-medium">
+                            {v.year} {v.make} {v.model}
+                          </p>
+                          <p className="text-xs text-muted-foreground">
+                            {[v.licensePlate, v.customer?.name]
+                              .filter(Boolean)
+                              .join(" · ") || "No plate"}
+                          </p>
+                        </div>
+                      </button>
+                    ))}
+                  </div>
+                )}
+              </div>
+              <Button
+                variant="ghost"
+                className="w-full"
+                onClick={() => setPickerStep("create")}
+              >
+                <Plus className="h-4 w-4 mr-1" />
+                Add New Vehicle
+              </Button>
+            </div>
+          </>
+        ) : (
+          <>
+            <DialogHeader>
+              <div className="flex items-center gap-2">
+                <Button
+                  variant="ghost"
+                  size="icon"
+                  className="h-7 w-7 shrink-0"
+                  onClick={() => setPickerStep("select")}
+                >
+                  <ArrowLeft className="h-4 w-4" />
+                </Button>
+                <DialogTitle>Add New Vehicle</DialogTitle>
+              </div>
+            </DialogHeader>
+            <div className="space-y-4">
+              <div className="grid grid-cols-2 gap-3">
+                <div className="space-y-1.5">
+                  <Label htmlFor="new-make">Make *</Label>
+                  <Input
+                    id="new-make"
+                    placeholder="e.g. Toyota"
+                    value={newMake}
+                    onChange={(e) => setNewMake(e.target.value)}
+                    autoFocus
+                  />
+                </div>
+                <div className="space-y-1.5">
+                  <Label htmlFor="new-model">Model *</Label>
+                  <Input
+                    id="new-model"
+                    placeholder="e.g. Camry"
+                    value={newModel}
+                    onChange={(e) => setNewModel(e.target.value)}
+                  />
+                </div>
+              </div>
+              <div className="grid grid-cols-2 gap-3">
+                <div className="space-y-1.5">
+                  <Label htmlFor="new-year">Year *</Label>
+                  <Input
+                    id="new-year"
+                    type="number"
+                    placeholder={`e.g. ${new Date().getFullYear()}`}
+                    value={newYear}
+                    onChange={(e) => setNewYear(e.target.value)}
+                  />
+                </div>
+                <div className="space-y-1.5">
+                  <Label htmlFor="new-plate">License Plate</Label>
+                  <Input
+                    id="new-plate"
+                    placeholder="e.g. ABC-1234"
+                    value={newPlate}
+                    onChange={(e) => setNewPlate(e.target.value)}
+                  />
+                </div>
+              </div>
+              <div className="space-y-1.5">
+                <Label>Customer</Label>
+                {!showNewCustomer ? (
+                  <Select
+                    value={selectedCustomerId}
+                    onValueChange={(val) => {
+                      if (val === "__new__") {
+                        setShowNewCustomer(true);
+                        setSelectedCustomerId("");
+                      } else {
+                        setSelectedCustomerId(val);
+                      }
+                    }}
+                  >
+                    <SelectTrigger className="w-full">
+                      <SelectValue placeholder="Select a customer (optional)" />
+                    </SelectTrigger>
+                    <SelectContent>
+                      <SelectItem value="__new__">
+                        <span className="flex items-center gap-1.5">
+                          <UserPlus className="h-3.5 w-3.5" />
+                          Create new customer
+                        </span>
+                      </SelectItem>
+                      {customers.map((c) => (
+                        <SelectItem key={c.id} value={c.id}>
+                          {c.name}{c.company ? ` (${c.company})` : ""}
+                        </SelectItem>
+                      ))}
+                    </SelectContent>
+                  </Select>
+                ) : (
+                  <div className="space-y-3 rounded-md border p-3">
+                    <div className="flex items-center justify-between">
+                      <p className="text-sm font-medium">New Customer</p>
+                      <Button
+                        variant="ghost"
+                        size="sm"
+                        className="h-7 text-xs"
+                        onClick={() => {
+                          setShowNewCustomer(false);
+                          setNewCustomerName("");
+                          setNewCustomerPhone("");
+                        }}
+                      >
+                        Cancel
+                      </Button>
+                    </div>
+                    <div className="grid grid-cols-2 gap-3">
+                      <div className="space-y-1.5">
+                        <Label htmlFor="new-customer-name">Name *</Label>
+                        <Input
+                          id="new-customer-name"
+                          placeholder="Customer name"
+                          value={newCustomerName}
+                          onChange={(e) => setNewCustomerName(e.target.value)}
+                        />
+                      </div>
+                      <div className="space-y-1.5">
+                        <Label htmlFor="new-customer-phone">Phone</Label>
+                        <Input
+                          id="new-customer-phone"
+                          placeholder="Phone number"
+                          value={newCustomerPhone}
+                          onChange={(e) => setNewCustomerPhone(e.target.value)}
+                        />
+                      </div>
+                    </div>
+                  </div>
+                )}
+              </div>
+              <Button
+                className="w-full"
+                disabled={creating || !newMake.trim() || !newModel.trim() || !newYear.trim() || (showNewCustomer && !newCustomerName.trim())}
+                onClick={handleQuickCreate}
+              >
+                {creating ? (
+                  <>
+                    <Loader2 className="h-4 w-4 mr-1 animate-spin" />
+                    Creating...
+                  </>
+                ) : (
+                  "Create & Continue"
+                )}
+              </Button>
+            </div>
+          </>
+        )}
+      </DialogContent>
+    </Dialog>
+  );
+}

+ 3 - 28
src/features/admin/Actions/testEmailConnection.ts

@@ -2,7 +2,7 @@
 
 import { withSuperAdmin } from "@/lib/with-super-admin";
 import { db } from "@/lib/db";
-import { sendMail } from "@/lib/email";
+import { sendMail, getFromAddress } from "@/lib/email";
 import { SYSTEM_SETTING_KEYS } from "../Schema/systemSettingsSchema";
 
 export async function testEmailConnection() {
@@ -16,36 +16,11 @@ export async function testEmailConnection() {
       throw new Error("Could not find your email address");
     }
 
-    // Determine provider
     const providerSetting = await db.systemSetting.findUnique({
       where: { key: SYSTEM_SETTING_KEYS.EMAIL_PROVIDER },
     });
-    const provider = providerSetting?.value === "resend" ? "resend" : "smtp";
-
-    // Get from address based on provider
-    let from: string;
-    if (provider === "resend") {
-      const fromSetting = await db.systemSetting.findUnique({
-        where: { key: SYSTEM_SETTING_KEYS.RESEND_FROM_EMAIL },
-      });
-      const fromName = await db.systemSetting.findUnique({
-        where: { key: SYSTEM_SETTING_KEYS.RESEND_FROM_NAME },
-      });
-      const fromEmail = fromSetting?.value || user.email;
-      const senderName = fromName?.value || "Torqvoice";
-      from = `${senderName} <${fromEmail}>`;
-    } else {
-      const fromSetting = await db.systemSetting.findUnique({
-        where: { key: SYSTEM_SETTING_KEYS.SMTP_FROM_EMAIL },
-      });
-      const fromName = await db.systemSetting.findUnique({
-        where: { key: SYSTEM_SETTING_KEYS.SMTP_FROM_NAME },
-      });
-      const fromEmail =
-        fromSetting?.value || process.env.SMTP_FROM_EMAIL || user.email;
-      const senderName = fromName?.value || "Torqvoice";
-      from = `${senderName} <${fromEmail}>`;
-    }
+    const provider = providerSetting?.value || "smtp";
+    const from = await getFromAddress();
 
     await sendMail({
       from,

+ 500 - 182
src/features/admin/Components/admin-settings.tsx

@@ -14,6 +14,8 @@ import type { SystemSettingsMap } from '../Schema/systemSettingsSchema'
 import { setSystemSettings } from '../Actions/setSystemSettings'
 import { testEmailConnection } from '../Actions/testEmailConnection'
 
+type EmailProviderType = 'smtp' | 'resend' | 'postmark' | 'mailgun' | 'sendgrid' | 'ses'
+
 export function AdminSettings({
   initial,
 }: {
@@ -30,8 +32,8 @@ export function AdminSettings({
   )
 
   // Email provider
-  const [emailProvider, setEmailProvider] = useState<'smtp' | 'resend'>(
-    (initial[SYSTEM_SETTING_KEYS.EMAIL_PROVIDER] as 'smtp' | 'resend') || 'smtp'
+  const [emailProvider, setEmailProvider] = useState<EmailProviderType>(
+    (initial[SYSTEM_SETTING_KEYS.EMAIL_PROVIDER] as EmailProviderType) || 'smtp'
   )
 
   // SMTP settings
@@ -64,11 +66,68 @@ export function AdminSettings({
     initial[SYSTEM_SETTING_KEYS.RESEND_FROM_NAME] || ''
   )
 
+  // Postmark settings
+  const [postmarkApiKey, setPostmarkApiKey] = useState(
+    initial[SYSTEM_SETTING_KEYS.POSTMARK_API_KEY] || ''
+  )
+  const [postmarkFromEmail, setPostmarkFromEmail] = useState(
+    initial[SYSTEM_SETTING_KEYS.POSTMARK_FROM_EMAIL] || ''
+  )
+  const [postmarkFromName, setPostmarkFromName] = useState(
+    initial[SYSTEM_SETTING_KEYS.POSTMARK_FROM_NAME] || ''
+  )
+
+  // Mailgun settings
+  const [mailgunApiKey, setMailgunApiKey] = useState(
+    initial[SYSTEM_SETTING_KEYS.MAILGUN_API_KEY] || ''
+  )
+  const [mailgunDomain, setMailgunDomain] = useState(
+    initial[SYSTEM_SETTING_KEYS.MAILGUN_DOMAIN] || ''
+  )
+  const [mailgunRegion, setMailgunRegion] = useState(
+    initial[SYSTEM_SETTING_KEYS.MAILGUN_REGION] || 'us'
+  )
+  const [mailgunFromEmail, setMailgunFromEmail] = useState(
+    initial[SYSTEM_SETTING_KEYS.MAILGUN_FROM_EMAIL] || ''
+  )
+  const [mailgunFromName, setMailgunFromName] = useState(
+    initial[SYSTEM_SETTING_KEYS.MAILGUN_FROM_NAME] || ''
+  )
+
+  // SendGrid settings
+  const [sendgridApiKey, setSendgridApiKey] = useState(
+    initial[SYSTEM_SETTING_KEYS.SENDGRID_API_KEY] || ''
+  )
+  const [sendgridFromEmail, setSendgridFromEmail] = useState(
+    initial[SYSTEM_SETTING_KEYS.SENDGRID_FROM_EMAIL] || ''
+  )
+  const [sendgridFromName, setSendgridFromName] = useState(
+    initial[SYSTEM_SETTING_KEYS.SENDGRID_FROM_NAME] || ''
+  )
+
+  // Amazon SES settings
+  const [sesAccessKeyId, setSesAccessKeyId] = useState(
+    initial[SYSTEM_SETTING_KEYS.SES_ACCESS_KEY_ID] || ''
+  )
+  const [sesSecretAccessKey, setSesSecretAccessKey] = useState(
+    initial[SYSTEM_SETTING_KEYS.SES_SECRET_ACCESS_KEY] || ''
+  )
+  const [sesRegion, setSesRegion] = useState(
+    initial[SYSTEM_SETTING_KEYS.SES_REGION] || 'us-east-1'
+  )
+  const [sesFromEmail, setSesFromEmail] = useState(
+    initial[SYSTEM_SETTING_KEYS.SES_FROM_EMAIL] || ''
+  )
+  const [sesFromName, setSesFromName] = useState(
+    initial[SYSTEM_SETTING_KEYS.SES_FROM_NAME] || ''
+  )
+
   const handleSave = () => {
     startTransition(async () => {
       const data: Record<string, string> = {
         [SYSTEM_SETTING_KEYS.REGISTRATION_DISABLED]: String(registrationDisabled),
         [SYSTEM_SETTING_KEYS.EMAIL_PROVIDER]: emailProvider,
+        // SMTP
         [SYSTEM_SETTING_KEYS.SMTP_HOST]: smtpHost,
         [SYSTEM_SETTING_KEYS.SMTP_PORT]: smtpPort,
         [SYSTEM_SETTING_KEYS.SMTP_USER]: smtpUser,
@@ -78,9 +137,30 @@ export function AdminSettings({
         [SYSTEM_SETTING_KEYS.SMTP_FROM_NAME]: smtpFromName,
         [SYSTEM_SETTING_KEYS.SMTP_REJECT_UNAUTHORIZED]: String(smtpRejectUnauthorized),
         [SYSTEM_SETTING_KEYS.SMTP_REQUIRE_TLS]: String(smtpRequireTls),
+        // Resend
         [SYSTEM_SETTING_KEYS.RESEND_API_KEY]: resendApiKey,
         [SYSTEM_SETTING_KEYS.RESEND_FROM_EMAIL]: resendFromEmail,
         [SYSTEM_SETTING_KEYS.RESEND_FROM_NAME]: resendFromName,
+        // Postmark
+        [SYSTEM_SETTING_KEYS.POSTMARK_API_KEY]: postmarkApiKey,
+        [SYSTEM_SETTING_KEYS.POSTMARK_FROM_EMAIL]: postmarkFromEmail,
+        [SYSTEM_SETTING_KEYS.POSTMARK_FROM_NAME]: postmarkFromName,
+        // Mailgun
+        [SYSTEM_SETTING_KEYS.MAILGUN_API_KEY]: mailgunApiKey,
+        [SYSTEM_SETTING_KEYS.MAILGUN_DOMAIN]: mailgunDomain,
+        [SYSTEM_SETTING_KEYS.MAILGUN_REGION]: mailgunRegion,
+        [SYSTEM_SETTING_KEYS.MAILGUN_FROM_EMAIL]: mailgunFromEmail,
+        [SYSTEM_SETTING_KEYS.MAILGUN_FROM_NAME]: mailgunFromName,
+        // SendGrid
+        [SYSTEM_SETTING_KEYS.SENDGRID_API_KEY]: sendgridApiKey,
+        [SYSTEM_SETTING_KEYS.SENDGRID_FROM_EMAIL]: sendgridFromEmail,
+        [SYSTEM_SETTING_KEYS.SENDGRID_FROM_NAME]: sendgridFromName,
+        // Amazon SES
+        [SYSTEM_SETTING_KEYS.SES_ACCESS_KEY_ID]: sesAccessKeyId,
+        [SYSTEM_SETTING_KEYS.SES_SECRET_ACCESS_KEY]: sesSecretAccessKey,
+        [SYSTEM_SETTING_KEYS.SES_REGION]: sesRegion,
+        [SYSTEM_SETTING_KEYS.SES_FROM_EMAIL]: sesFromEmail,
+        [SYSTEM_SETTING_KEYS.SES_FROM_NAME]: sesFromName,
       }
 
       const result = await setSystemSettings(data)
@@ -110,7 +190,11 @@ export function AdminSettings({
   const isTestDisabled =
     isTesting ||
     (emailProvider === 'smtp' && !smtpHost) ||
-    (emailProvider === 'resend' && !resendApiKey)
+    (emailProvider === 'resend' && !resendApiKey) ||
+    (emailProvider === 'postmark' && !postmarkApiKey) ||
+    (emailProvider === 'mailgun' && !mailgunApiKey) ||
+    (emailProvider === 'sendgrid' && !sendgridApiKey) ||
+    (emailProvider === 'ses' && !sesAccessKeyId)
 
   return (
     <div className="space-y-6">
@@ -137,16 +221,16 @@ export function AdminSettings({
         </CardContent>
       </Card>
 
-      {/* Email Provider Selection */}
+      {/* Email Settings */}
       <Card>
         <CardHeader>
-          <CardTitle>Email Provider</CardTitle>
+          <CardTitle>Email Settings</CardTitle>
           <CardDescription>
-            Choose how the platform sends emails (password resets, invoices, notifications)
+            Platform Email Provider — Used for system-level emails such as password resets and team invitations. This is also the default email provider for all organizations. Organizations can optionally configure their own email provider in Settings &gt; Email.
           </CardDescription>
         </CardHeader>
-        <CardContent>
-          <div className="flex gap-2">
+        <CardContent className="space-y-6">
+          <div className="flex flex-wrap gap-2">
             <Button
               type="button"
               variant={emailProvider === 'smtp' ? 'default' : 'outline'}
@@ -163,218 +247,452 @@ export function AdminSettings({
             >
               Resend
             </Button>
+            <Button
+              type="button"
+              variant={emailProvider === 'postmark' ? 'default' : 'outline'}
+              onClick={() => setEmailProvider('postmark')}
+              className="flex-1"
+            >
+              Postmark
+            </Button>
+            <Button
+              type="button"
+              variant={emailProvider === 'mailgun' ? 'default' : 'outline'}
+              onClick={() => setEmailProvider('mailgun')}
+              className="flex-1"
+            >
+              Mailgun
+            </Button>
+            <Button
+              type="button"
+              variant={emailProvider === 'sendgrid' ? 'default' : 'outline'}
+              onClick={() => setEmailProvider('sendgrid')}
+              className="flex-1"
+            >
+              SendGrid
+            </Button>
+            <Button
+              type="button"
+              variant={emailProvider === 'ses' ? 'default' : 'outline'}
+              onClick={() => setEmailProvider('ses')}
+              className="flex-1"
+            >
+              Amazon SES
+            </Button>
           </div>
-        </CardContent>
-      </Card>
 
-      {/* SMTP Settings */}
-      {emailProvider === 'smtp' && (
-        <Card>
-          <CardHeader>
-            <CardTitle>SMTP Email Settings</CardTitle>
-            <CardDescription>
-              Configure email delivery for the platform. These settings override environment
-              variables when set.
-            </CardDescription>
-          </CardHeader>
-          <CardContent className="space-y-6">
-            <div className="grid gap-4 sm:grid-cols-2">
-              <div className="space-y-2">
-                <Label htmlFor="smtp-host">SMTP Host</Label>
-                <Input
-                  id="smtp-host"
-                  placeholder="smtp.example.com"
-                  value={smtpHost}
-                  onChange={(e) => setSmtpHost(e.target.value)}
-                />
+          {emailProvider === 'smtp' && (
+            <>
+              <div className="grid gap-4 sm:grid-cols-2">
+                <div className="space-y-2">
+                  <Label htmlFor="smtp-host">SMTP Host</Label>
+                  <Input
+                    id="smtp-host"
+                    placeholder="smtp.example.com"
+                    value={smtpHost}
+                    onChange={(e) => setSmtpHost(e.target.value)}
+                  />
+                </div>
+                <div className="space-y-2">
+                  <Label htmlFor="smtp-port">SMTP Port</Label>
+                  <Input
+                    id="smtp-port"
+                    placeholder="587"
+                    value={smtpPort}
+                    onChange={(e) => setSmtpPort(e.target.value)}
+                  />
+                </div>
               </div>
-              <div className="space-y-2">
-                <Label htmlFor="smtp-port">SMTP Port</Label>
-                <Input
-                  id="smtp-port"
-                  placeholder="587"
-                  value={smtpPort}
-                  onChange={(e) => setSmtpPort(e.target.value)}
-                />
+
+              <div className="grid gap-4 sm:grid-cols-2">
+                <div className="space-y-2">
+                  <Label htmlFor="smtp-user">Username</Label>
+                  <Input
+                    id="smtp-user"
+                    placeholder="user@example.com"
+                    value={smtpUser}
+                    onChange={(e) => setSmtpUser(e.target.value)}
+                  />
+                </div>
+                <div className="space-y-2">
+                  <Label htmlFor="smtp-pass">Password</Label>
+                  <Input
+                    id="smtp-pass"
+                    type="password"
+                    placeholder="••••••••"
+                    value={smtpPass}
+                    onChange={(e) => setSmtpPass(e.target.value)}
+                  />
+                </div>
               </div>
-            </div>
 
-            <div className="grid gap-4 sm:grid-cols-2">
-              <div className="space-y-2">
-                <Label htmlFor="smtp-user">Username</Label>
-                <Input
-                  id="smtp-user"
-                  placeholder="user@example.com"
-                  value={smtpUser}
-                  onChange={(e) => setSmtpUser(e.target.value)}
-                />
+              <div className="grid gap-4 sm:grid-cols-2">
+                <div className="space-y-2">
+                  <Label htmlFor="smtp-from-email">From Email</Label>
+                  <Input
+                    id="smtp-from-email"
+                    placeholder="noreply@example.com"
+                    value={smtpFromEmail}
+                    onChange={(e) => setSmtpFromEmail(e.target.value)}
+                  />
+                </div>
+                <div className="space-y-2">
+                  <Label htmlFor="smtp-from-name">From Name</Label>
+                  <Input
+                    id="smtp-from-name"
+                    placeholder="Torqvoice"
+                    value={smtpFromName}
+                    onChange={(e) => setSmtpFromName(e.target.value)}
+                  />
+                </div>
               </div>
+
+              <div className="space-y-4">
+                <div className="flex items-center justify-between">
+                  <div className="space-y-0.5">
+                    <Label htmlFor="smtp-secure">TLS Connection (Port 465)</Label>
+                    <p className="text-xs text-muted-foreground">
+                      Enable for implicit TLS. Disable for STARTTLS (port 587/25).
+                    </p>
+                  </div>
+                  <Switch id="smtp-secure" checked={smtpSecure} onCheckedChange={setSmtpSecure} />
+                </div>
+
+                <div className="flex items-center justify-between">
+                  <div className="space-y-0.5">
+                    <Label htmlFor="smtp-reject-unauthorized">Verify TLS Certificates</Label>
+                    <p className="text-xs text-muted-foreground">
+                      Disable to allow self-signed certificates (not recommended for production)
+                    </p>
+                  </div>
+                  <Switch
+                    id="smtp-reject-unauthorized"
+                    checked={smtpRejectUnauthorized}
+                    onCheckedChange={setSmtpRejectUnauthorized}
+                  />
+                </div>
+
+                <div className="flex items-center justify-between">
+                  <div className="space-y-0.5">
+                    <Label htmlFor="smtp-require-tls">Require TLS Upgrade</Label>
+                    <p className="text-xs text-muted-foreground">
+                      Force TLS upgrade on STARTTLS connections
+                    </p>
+                  </div>
+                  <Switch
+                    id="smtp-require-tls"
+                    checked={smtpRequireTls}
+                    onCheckedChange={setSmtpRequireTls}
+                  />
+                </div>
+              </div>
+            </>
+          )}
+
+          {emailProvider === 'resend' && (
+            <>
               <div className="space-y-2">
-                <Label htmlFor="smtp-pass">Password</Label>
+                <Label htmlFor="resend-api-key">API Key</Label>
                 <Input
-                  id="smtp-pass"
+                  id="resend-api-key"
                   type="password"
-                  placeholder="••••••••"
-                  value={smtpPass}
-                  onChange={(e) => setSmtpPass(e.target.value)}
+                  placeholder="re_••••••••"
+                  value={resendApiKey}
+                  onChange={(e) => setResendApiKey(e.target.value)}
                 />
+                <p className="text-xs text-muted-foreground">
+                  Get your API key from{' '}
+                  <a
+                    href="https://resend.com"
+                    target="_blank"
+                    rel="noopener noreferrer"
+                    className="underline"
+                  >
+                    resend.com
+                  </a>
+                </p>
               </div>
-            </div>
 
-            <div className="grid gap-4 sm:grid-cols-2">
+              <div className="grid gap-4 sm:grid-cols-2">
+                <div className="space-y-2">
+                  <Label htmlFor="resend-from-email">From Email</Label>
+                  <Input
+                    id="resend-from-email"
+                    placeholder="noreply@yourdomain.com"
+                    value={resendFromEmail}
+                    onChange={(e) => setResendFromEmail(e.target.value)}
+                  />
+                </div>
+                <div className="space-y-2">
+                  <Label htmlFor="resend-from-name">From Name</Label>
+                  <Input
+                    id="resend-from-name"
+                    placeholder="Torqvoice"
+                    value={resendFromName}
+                    onChange={(e) => setResendFromName(e.target.value)}
+                  />
+                </div>
+              </div>
+            </>
+          )}
+
+          {emailProvider === 'postmark' && (
+            <>
               <div className="space-y-2">
-                <Label htmlFor="smtp-from-email">From Email</Label>
+                <Label htmlFor="postmark-api-key">Server Token</Label>
                 <Input
-                  id="smtp-from-email"
-                  placeholder="noreply@example.com"
-                  value={smtpFromEmail}
-                  onChange={(e) => setSmtpFromEmail(e.target.value)}
+                  id="postmark-api-key"
+                  type="password"
+                  placeholder="••••••••-••••-••••-••••-••••••••••••"
+                  value={postmarkApiKey}
+                  onChange={(e) => setPostmarkApiKey(e.target.value)}
                 />
+                <p className="text-xs text-muted-foreground">
+                  Get your Server Token from{' '}
+                  <a
+                    href="https://postmarkapp.com"
+                    target="_blank"
+                    rel="noopener noreferrer"
+                    className="underline"
+                  >
+                    postmarkapp.com
+                  </a>
+                </p>
               </div>
+
+              <div className="grid gap-4 sm:grid-cols-2">
+                <div className="space-y-2">
+                  <Label htmlFor="postmark-from-email">From Email</Label>
+                  <Input
+                    id="postmark-from-email"
+                    placeholder="noreply@yourdomain.com"
+                    value={postmarkFromEmail}
+                    onChange={(e) => setPostmarkFromEmail(e.target.value)}
+                  />
+                </div>
+                <div className="space-y-2">
+                  <Label htmlFor="postmark-from-name">From Name</Label>
+                  <Input
+                    id="postmark-from-name"
+                    placeholder="Torqvoice"
+                    value={postmarkFromName}
+                    onChange={(e) => setPostmarkFromName(e.target.value)}
+                  />
+                </div>
+              </div>
+            </>
+          )}
+
+          {emailProvider === 'mailgun' && (
+            <>
               <div className="space-y-2">
-                <Label htmlFor="smtp-from-name">From Name</Label>
+                <Label htmlFor="mailgun-api-key">API Key</Label>
                 <Input
-                  id="smtp-from-name"
-                  placeholder="Torqvoice"
-                  value={smtpFromName}
-                  onChange={(e) => setSmtpFromName(e.target.value)}
+                  id="mailgun-api-key"
+                  type="password"
+                  placeholder="key-••••••••••••••••••••••••••••••••"
+                  value={mailgunApiKey}
+                  onChange={(e) => setMailgunApiKey(e.target.value)}
                 />
+                <p className="text-xs text-muted-foreground">
+                  Get your API key from{' '}
+                  <a
+                    href="https://app.mailgun.com"
+                    target="_blank"
+                    rel="noopener noreferrer"
+                    className="underline"
+                  >
+                    app.mailgun.com
+                  </a>
+                </p>
               </div>
-            </div>
 
-            <div className="space-y-4">
-              <div className="flex items-center justify-between">
-                <div className="space-y-0.5">
-                  <Label htmlFor="smtp-secure">TLS Connection (Port 465)</Label>
-                  <p className="text-xs text-muted-foreground">
-                    Enable for implicit TLS. Disable for STARTTLS (port 587/25).
-                  </p>
+              <div className="grid gap-4 sm:grid-cols-2">
+                <div className="space-y-2">
+                  <Label htmlFor="mailgun-domain">Domain</Label>
+                  <Input
+                    id="mailgun-domain"
+                    placeholder="mg.yourdomain.com"
+                    value={mailgunDomain}
+                    onChange={(e) => setMailgunDomain(e.target.value)}
+                  />
+                </div>
+                <div className="space-y-2">
+                  <Label htmlFor="mailgun-region">Region</Label>
+                  <div className="flex gap-2">
+                    <Button
+                      type="button"
+                      variant={mailgunRegion === 'us' ? 'default' : 'outline'}
+                      onClick={() => setMailgunRegion('us')}
+                      className="flex-1"
+                      size="sm"
+                    >
+                      US
+                    </Button>
+                    <Button
+                      type="button"
+                      variant={mailgunRegion === 'eu' ? 'default' : 'outline'}
+                      onClick={() => setMailgunRegion('eu')}
+                      className="flex-1"
+                      size="sm"
+                    >
+                      EU
+                    </Button>
+                  </div>
                 </div>
-                <Switch id="smtp-secure" checked={smtpSecure} onCheckedChange={setSmtpSecure} />
               </div>
 
-              <div className="flex items-center justify-between">
-                <div className="space-y-0.5">
-                  <Label htmlFor="smtp-reject-unauthorized">Verify TLS Certificates</Label>
-                  <p className="text-xs text-muted-foreground">
-                    Disable to allow self-signed certificates (not recommended for production)
-                  </p>
+              <div className="grid gap-4 sm:grid-cols-2">
+                <div className="space-y-2">
+                  <Label htmlFor="mailgun-from-email">From Email</Label>
+                  <Input
+                    id="mailgun-from-email"
+                    placeholder="noreply@yourdomain.com"
+                    value={mailgunFromEmail}
+                    onChange={(e) => setMailgunFromEmail(e.target.value)}
+                  />
+                </div>
+                <div className="space-y-2">
+                  <Label htmlFor="mailgun-from-name">From Name</Label>
+                  <Input
+                    id="mailgun-from-name"
+                    placeholder="Torqvoice"
+                    value={mailgunFromName}
+                    onChange={(e) => setMailgunFromName(e.target.value)}
+                  />
                 </div>
-                <Switch
-                  id="smtp-reject-unauthorized"
-                  checked={smtpRejectUnauthorized}
-                  onCheckedChange={setSmtpRejectUnauthorized}
+              </div>
+            </>
+          )}
+
+          {emailProvider === 'sendgrid' && (
+            <>
+              <div className="space-y-2">
+                <Label htmlFor="sendgrid-api-key">API Key</Label>
+                <Input
+                  id="sendgrid-api-key"
+                  type="password"
+                  placeholder="SG.••••••••••••••••••••••••••••••••"
+                  value={sendgridApiKey}
+                  onChange={(e) => setSendgridApiKey(e.target.value)}
                 />
+                <p className="text-xs text-muted-foreground">
+                  Get your API key from{' '}
+                  <a
+                    href="https://app.sendgrid.com"
+                    target="_blank"
+                    rel="noopener noreferrer"
+                    className="underline"
+                  >
+                    app.sendgrid.com
+                  </a>
+                </p>
               </div>
 
-              <div className="flex items-center justify-between">
-                <div className="space-y-0.5">
-                  <Label htmlFor="smtp-require-tls">Require TLS Upgrade</Label>
-                  <p className="text-xs text-muted-foreground">
-                    Force TLS upgrade on STARTTLS connections
-                  </p>
+              <div className="grid gap-4 sm:grid-cols-2">
+                <div className="space-y-2">
+                  <Label htmlFor="sendgrid-from-email">From Email</Label>
+                  <Input
+                    id="sendgrid-from-email"
+                    placeholder="noreply@yourdomain.com"
+                    value={sendgridFromEmail}
+                    onChange={(e) => setSendgridFromEmail(e.target.value)}
+                  />
+                </div>
+                <div className="space-y-2">
+                  <Label htmlFor="sendgrid-from-name">From Name</Label>
+                  <Input
+                    id="sendgrid-from-name"
+                    placeholder="Torqvoice"
+                    value={sendgridFromName}
+                    onChange={(e) => setSendgridFromName(e.target.value)}
+                  />
                 </div>
-                <Switch
-                  id="smtp-require-tls"
-                  checked={smtpRequireTls}
-                  onCheckedChange={setSmtpRequireTls}
-                />
               </div>
-            </div>
+            </>
+          )}
 
-            <div className="flex items-center gap-2 pt-2">
-              <Button
-                type="button"
-                variant="outline"
-                onClick={handleTestEmail}
-                disabled={isTestDisabled}
-              >
-                {isTesting ? (
-                  <Loader2 className="mr-2 h-4 w-4 animate-spin" />
-                ) : (
-                  <Send className="mr-2 h-4 w-4" />
-                )}
-                Send Test Email
-              </Button>
-              <p className="text-xs text-muted-foreground">
-                Save settings first, then send a test email to your account
-              </p>
-            </div>
-          </CardContent>
-        </Card>
-      )}
-
-      {/* Resend Settings */}
-      {emailProvider === 'resend' && (
-        <Card>
-          <CardHeader>
-            <CardTitle>Resend Email Settings</CardTitle>
-            <CardDescription>
-              Configure email delivery using Resend. Get your API key from{' '}
-              <a
-                href="https://resend.com"
-                target="_blank"
-                rel="noopener noreferrer"
-                className="underline"
-              >
-                resend.com
-              </a>
-            </CardDescription>
-          </CardHeader>
-          <CardContent className="space-y-6">
-            <div className="space-y-2">
-              <Label htmlFor="resend-api-key">API Key</Label>
-              <Input
-                id="resend-api-key"
-                type="password"
-                placeholder="re_••••••••"
-                value={resendApiKey}
-                onChange={(e) => setResendApiKey(e.target.value)}
-              />
-            </div>
+          {emailProvider === 'ses' && (
+            <>
+              <div className="grid gap-4 sm:grid-cols-2">
+                <div className="space-y-2">
+                  <Label htmlFor="ses-access-key">Access Key ID</Label>
+                  <Input
+                    id="ses-access-key"
+                    type="password"
+                    placeholder="AKIA••••••••••••••••"
+                    value={sesAccessKeyId}
+                    onChange={(e) => setSesAccessKeyId(e.target.value)}
+                  />
+                </div>
+                <div className="space-y-2">
+                  <Label htmlFor="ses-secret-key">Secret Access Key</Label>
+                  <Input
+                    id="ses-secret-key"
+                    type="password"
+                    placeholder="••••••••••••••••••••••••••••••••••••••••"
+                    value={sesSecretAccessKey}
+                    onChange={(e) => setSesSecretAccessKey(e.target.value)}
+                  />
+                </div>
+              </div>
 
-            <div className="grid gap-4 sm:grid-cols-2">
               <div className="space-y-2">
-                <Label htmlFor="resend-from-email">From Email</Label>
+                <Label htmlFor="ses-region">AWS Region</Label>
                 <Input
-                  id="resend-from-email"
-                  placeholder="noreply@yourdomain.com"
-                  value={resendFromEmail}
-                  onChange={(e) => setResendFromEmail(e.target.value)}
+                  id="ses-region"
+                  placeholder="us-east-1"
+                  value={sesRegion}
+                  onChange={(e) => setSesRegion(e.target.value)}
                 />
+                <p className="text-xs text-muted-foreground">
+                  The AWS region where SES is configured (e.g. us-east-1, eu-west-1)
+                </p>
               </div>
-              <div className="space-y-2">
-                <Label htmlFor="resend-from-name">From Name</Label>
-                <Input
-                  id="resend-from-name"
-                  placeholder="Torqvoice"
-                  value={resendFromName}
-                  onChange={(e) => setResendFromName(e.target.value)}
-                />
+
+              <div className="grid gap-4 sm:grid-cols-2">
+                <div className="space-y-2">
+                  <Label htmlFor="ses-from-email">From Email</Label>
+                  <Input
+                    id="ses-from-email"
+                    placeholder="noreply@yourdomain.com"
+                    value={sesFromEmail}
+                    onChange={(e) => setSesFromEmail(e.target.value)}
+                  />
+                </div>
+                <div className="space-y-2">
+                  <Label htmlFor="ses-from-name">From Name</Label>
+                  <Input
+                    id="ses-from-name"
+                    placeholder="Torqvoice"
+                    value={sesFromName}
+                    onChange={(e) => setSesFromName(e.target.value)}
+                  />
+                </div>
               </div>
-            </div>
+            </>
+          )}
 
-            <div className="flex items-center gap-2 pt-2">
-              <Button
-                type="button"
-                variant="outline"
-                onClick={handleTestEmail}
-                disabled={isTestDisabled}
-              >
-                {isTesting ? (
-                  <Loader2 className="mr-2 h-4 w-4 animate-spin" />
-                ) : (
-                  <Send className="mr-2 h-4 w-4" />
-                )}
-                Send Test Email
-              </Button>
-              <p className="text-xs text-muted-foreground">
-                Save settings first, then send a test email to your account
-              </p>
-            </div>
-          </CardContent>
-        </Card>
-      )}
+          <div className="flex items-center gap-2 pt-2">
+            <Button
+              type="button"
+              variant="outline"
+              onClick={handleTestEmail}
+              disabled={isTestDisabled}
+            >
+              {isTesting ? (
+                <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+              ) : (
+                <Send className="mr-2 h-4 w-4" />
+              )}
+              Send Test Email
+            </Button>
+            <p className="text-xs text-muted-foreground">
+              Save settings first, then send a test email to your account
+            </p>
+          </div>
+        </CardContent>
+      </Card>
 
       {/* Save Button */}
       <div className="flex justify-end">

+ 24 - 0
src/features/admin/Schema/systemSettingsSchema.ts

@@ -22,6 +22,30 @@ export const SYSTEM_SETTING_KEYS = {
   RESEND_API_KEY: "resend.apiKey",
   RESEND_FROM_EMAIL: "resend.fromEmail",
   RESEND_FROM_NAME: "resend.fromName",
+
+  // Postmark
+  POSTMARK_API_KEY: "postmark.apiKey",
+  POSTMARK_FROM_EMAIL: "postmark.fromEmail",
+  POSTMARK_FROM_NAME: "postmark.fromName",
+
+  // Mailgun
+  MAILGUN_API_KEY: "mailgun.apiKey",
+  MAILGUN_DOMAIN: "mailgun.domain",
+  MAILGUN_REGION: "mailgun.region",
+  MAILGUN_FROM_EMAIL: "mailgun.fromEmail",
+  MAILGUN_FROM_NAME: "mailgun.fromName",
+
+  // SendGrid
+  SENDGRID_API_KEY: "sendgrid.apiKey",
+  SENDGRID_FROM_EMAIL: "sendgrid.fromEmail",
+  SENDGRID_FROM_NAME: "sendgrid.fromName",
+
+  // Amazon SES
+  SES_ACCESS_KEY_ID: "ses.accessKeyId",
+  SES_SECRET_ACCESS_KEY: "ses.secretAccessKey",
+  SES_REGION: "ses.region",
+  SES_FROM_EMAIL: "ses.fromEmail",
+  SES_FROM_NAME: "ses.fromName",
 } as const;
 
 export type SystemSettingKey =

+ 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 }] });
+}

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

@@ -0,0 +1,546 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import Link from "next/link";
+import { useFormatDate } from "@/lib/use-format-date";
+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 { formatDate } = useFormatDate();
+  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">
+                        {formatDate(new Date(inv.nextRunDate))}
+                      </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>;

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

@@ -0,0 +1,166 @@
+"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; // YYYY-MM-DD local date
+  time: string | null; // HH:MM or null
+  type: "service" | "reminder" | "quote";
+  status: string;
+  vehicleId: string;
+  vehicleLabel: string;
+  customerName: string | null;
+  invoiceNumber: string | null;
+  amount: number | null;
+};
+
+/** Format a Date as YYYY-MM-DD in local time (avoids UTC shift from toISOString) */
+function toLocalDateStr(d: Date): string {
+  const y = d.getFullYear();
+  const m = String(d.getMonth() + 1).padStart(2, "0");
+  const day = String(d.getDate()).padStart(2, "0");
+  return `${y}-${m}-${day}`;
+}
+
+function toTimeStr(d: Date): string {
+  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
+}
+
+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, quotes] = await Promise.all([
+      db.serviceRecord.findMany({
+        where: {
+          vehicle: { organizationId },
+          serviceDate: { gte: start, lte: end },
+        },
+        select: {
+          id: true,
+          title: true,
+          serviceDate: true,
+          status: true,
+          invoiceNumber: true,
+          totalAmount: true,
+          cost: 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" },
+      }),
+      db.quote.findMany({
+        where: {
+          organizationId,
+          validUntil: { gte: start, lte: end },
+          status: { notIn: ["converted", "rejected"] },
+        },
+        select: {
+          id: true,
+          title: true,
+          quoteNumber: true,
+          validUntil: true,
+          status: true,
+          totalAmount: true,
+          vehicleId: true,
+          vehicle: {
+            select: {
+              make: true,
+              model: true,
+              year: true,
+              customer: { select: { name: true } },
+            },
+          },
+          customer: { select: { name: true } },
+        },
+        orderBy: { validUntil: "asc" },
+      }),
+    ]);
+
+    const events: CalendarEvent[] = [
+      ...services.map((s) => ({
+        id: s.id,
+        title: s.title,
+        date: toLocalDateStr(s.serviceDate),
+        time: toTimeStr(s.serviceDate),
+        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,
+        invoiceNumber: s.invoiceNumber,
+        amount: s.totalAmount > 0 ? s.totalAmount : s.cost > 0 ? s.cost : null,
+      })),
+      ...reminders
+        .filter((r) => r.dueDate !== null)
+        .map((r) => ({
+          id: r.id,
+          title: r.title,
+          date: toLocalDateStr(r.dueDate!),
+          time: null,
+          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,
+          invoiceNumber: null,
+          amount: null,
+        })),
+      ...quotes
+        .filter((q) => q.validUntil !== null && q.vehicleId !== null)
+        .map((q) => ({
+          id: q.id,
+          title: q.title,
+          date: toLocalDateStr(q.validUntil!),
+          time: null,
+          type: "quote" as const,
+          status: q.status,
+          vehicleId: q.vehicleId!,
+          vehicleLabel: q.vehicle ? `${q.vehicle.year} ${q.vehicle.make} ${q.vehicle.model}` : "",
+          customerName: q.customer?.name ?? q.vehicle?.customer?.name ?? null,
+          invoiceNumber: q.quoteNumber,
+          amount: q.totalAmount > 0 ? q.totalAmount : null,
+        })),
+    ];
+
+    return events;
+  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SERVICES }] });
+}

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

@@ -0,0 +1,352 @@
+"use client";
+
+import { useState, useCallback, useMemo } from "react";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+  ChevronLeft,
+  ChevronRight,
+  Loader2,
+  Plus,
+  Wrench,
+  FileText,
+  AlertTriangle,
+} from "lucide-react";
+import { CalendarDayCell } from "./CalendarDayCell";
+import { CalendarEventList } from "./CalendarEventList";
+import { toLocalDateStr } from "./calendar-utils";
+import { getCalendarEvents } from "../Actions/calendarActions";
+import type { CalendarEvent } from "../Actions/calendarActions";
+import { VehiclePickerDialog } from "@/components/vehicle-picker-dialog";
+
+interface Vehicle {
+  id: string;
+  make: string;
+  model: string;
+  year: number;
+  licensePlate: string | null;
+  customer: { id: string; name: string; company: string | null } | null;
+}
+
+interface Customer {
+  id: string;
+  name: string;
+  company: string | null;
+}
+
+interface CalendarClientProps {
+  initialEvents: CalendarEvent[];
+  initialMonth: number;
+  initialYear: number;
+  initialDay: number;
+  todayStr: string; // YYYY-MM-DD computed on server to avoid hydration mismatch
+  vehicles: Vehicle[];
+  customers: Customer[];
+  currencyCode: string;
+}
+
+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 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,
+  initialDay,
+  todayStr,
+  vehicles,
+  customers,
+  currencyCode,
+}: 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(initialYear, initialMonth, initialDay)
+  );
+
+  // Filters
+  const [showServices, setShowServices] = useState(true);
+  const [showReminders, setShowReminders] = useState(true);
+  const [showQuotes, setShowQuotes] = useState(true);
+
+  // Vehicle picker
+  const [showPicker, setShowPicker] = useState(false);
+
+  const selectedDateStr = toLocalDateStr(selectedDate);
+
+  const filteredEvents = useMemo(() => {
+    return events.filter((e) => {
+      if (e.type === "service" && !showServices) return false;
+      if (e.type === "reminder" && !showReminders) return false;
+      if (e.type === "quote" && !showQuotes) return false;
+      return true;
+    });
+  }, [events, showServices, showReminders, showQuotes]);
+
+  // Month summary stats
+  const stats = useMemo(() => {
+    const serviceCount = events.filter((e) => e.type === "service").length;
+    const overdueReminders = events.filter(
+      (e) => e.type === "reminder" && e.status === "overdue"
+    ).length;
+    const pendingQuotes = events.filter(
+      (e) => e.type === "quote" && e.status !== "approved"
+    ).length;
+    const totalRevenue = events
+      .filter((e) => e.type === "service" && e.amount != null)
+      .reduce((sum, e) => sum + (e.amount ?? 0), 0);
+    return { serviceCount, overdueReminders, pendingQuotes, totalRevenue };
+  }, [events]);
+
+  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: toLocalDateStr(start),
+      end: toLocalDateStr(end),
+    });
+    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);
+    setSelectedDate(new Date(newYear, newMonth, 1));
+    fetchEvents(newYear, newMonth);
+  };
+
+  const goToNext = () => {
+    const newMonth = month === 11 ? 0 : month + 1;
+    const newYear = month === 11 ? year + 1 : year;
+    setMonth(newMonth);
+    setYear(newYear);
+    setSelectedDate(new Date(newYear, newMonth, 1));
+    fetchEvents(newYear, newMonth);
+  };
+
+  const goToToday = () => {
+    const t = new Date();
+    const needsFetch = t.getMonth() !== month || t.getFullYear() !== year;
+    setMonth(t.getMonth());
+    setYear(t.getFullYear());
+    setSelectedDate(t);
+    if (needsFetch) {
+      fetchEvents(t.getFullYear(), t.getMonth());
+    }
+  };
+
+  return (
+    <div className="space-y-4">
+      {/* Summary stats */}
+      <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
+        <Card className="border-0 shadow-sm">
+          <CardContent className="p-3 flex items-center gap-3">
+            <div className="flex h-9 w-9 items-center justify-center rounded-lg bg-blue-500/10">
+              <Wrench className="h-4 w-4 text-blue-600" />
+            </div>
+            <div>
+              <p className="text-2xl font-bold leading-none">{stats.serviceCount}</p>
+              <p className="text-xs text-muted-foreground mt-0.5">Services</p>
+            </div>
+          </CardContent>
+        </Card>
+        <Card className="border-0 shadow-sm">
+          <CardContent className="p-3 flex items-center gap-3">
+            <div className="flex h-9 w-9 items-center justify-center rounded-lg bg-red-500/10">
+              <AlertTriangle className="h-4 w-4 text-red-600" />
+            </div>
+            <div>
+              <p className="text-2xl font-bold leading-none">{stats.overdueReminders}</p>
+              <p className="text-xs text-muted-foreground mt-0.5">Overdue</p>
+            </div>
+          </CardContent>
+        </Card>
+        <Card className="border-0 shadow-sm">
+          <CardContent className="p-3 flex items-center gap-3">
+            <div className="flex h-9 w-9 items-center justify-center rounded-lg bg-violet-500/10">
+              <FileText className="h-4 w-4 text-violet-600" />
+            </div>
+            <div>
+              <p className="text-2xl font-bold leading-none">{stats.pendingQuotes}</p>
+              <p className="text-xs text-muted-foreground mt-0.5">Pending Quotes</p>
+            </div>
+          </CardContent>
+        </Card>
+        <Card className="border-0 shadow-sm">
+          <CardContent className="p-3 flex items-center gap-3">
+            <div className="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-500/10">
+              <span className="text-sm font-bold text-emerald-600">$</span>
+            </div>
+            <div>
+              <p className="text-2xl font-bold leading-none tabular-nums">
+                {stats.totalRevenue > 0
+                  ? new Intl.NumberFormat(undefined, {
+                      notation: "compact",
+                      maximumFractionDigits: 1,
+                    }).format(stats.totalRevenue)
+                  : "0"}
+              </p>
+              <p className="text-xs text-muted-foreground mt-0.5">Revenue</p>
+            </div>
+          </CardContent>
+        </Card>
+      </div>
+
+      <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>
+                <div className="flex items-center gap-2">
+                  <Button size="sm" variant="outline" onClick={goToToday}>
+                    Today
+                  </Button>
+                  <Button
+                    size="sm"
+                    onClick={() => setShowPicker(true)}
+                  >
+                    <Plus className="h-4 w-4 mr-1" />
+                    Work Order
+                  </Button>
+                </div>
+              </div>
+
+              {/* Filters */}
+              <div className="flex items-center gap-4 mb-3 text-sm">
+                <label className="flex items-center gap-1.5 cursor-pointer">
+                  <Checkbox
+                    checked={showServices}
+                    onCheckedChange={(v) => setShowServices(!!v)}
+                  />
+                  <div className="h-2 w-2 rounded-full bg-blue-500" />
+                  <span className="text-muted-foreground">Services</span>
+                </label>
+                <label className="flex items-center gap-1.5 cursor-pointer">
+                  <Checkbox
+                    checked={showReminders}
+                    onCheckedChange={(v) => setShowReminders(!!v)}
+                  />
+                  <div className="h-2 w-2 rounded-full bg-amber-500" />
+                  <span className="text-muted-foreground">Reminders</span>
+                </label>
+                <label className="flex items-center gap-1.5 cursor-pointer">
+                  <Checkbox
+                    checked={showQuotes}
+                    onCheckedChange={(v) => setShowQuotes(!!v)}
+                  />
+                  <div className="h-2 w-2 rounded-full bg-violet-500" />
+                  <span className="text-muted-foreground">Quotes</span>
+                </label>
+              </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 = toLocalDateStr(date);
+                  return (
+                    <CalendarDayCell
+                      key={dateStr}
+                      date={date}
+                      events={filteredEvents}
+                      isCurrentMonth={date.getMonth() === month}
+                      isToday={dateStr === todayStr}
+                      isSelected={dateStr === selectedDateStr}
+                      onClick={() => setSelectedDate(date)}
+                    />
+                  );
+                })}
+              </div>
+            </CardContent>
+          </Card>
+        </div>
+
+        {/* Event list sidebar */}
+        <div>
+          <Card className="border-0 shadow-sm">
+            <CardContent className="p-4">
+              <CalendarEventList
+                events={filteredEvents}
+                dateStr={selectedDateStr}
+                selectedDate={selectedDate}
+                currencyCode={currencyCode}
+              />
+            </CardContent>
+          </Card>
+        </div>
+      </div>
+
+      <VehiclePickerDialog
+        open={showPicker}
+        onOpenChange={setShowPicker}
+        vehicles={vehicles}
+        customers={customers}
+        title="Select Vehicle for Work Order"
+      />
+    </div>
+  );
+}

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

@@ -0,0 +1,104 @@
+"use client";
+
+import { toLocalDateStr } from "./calendar-utils";
+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": case "in-progress": return "bg-blue-500";
+      case "waiting-parts": return "bg-orange-500";
+      default: return "bg-amber-500";
+    }
+  }
+  if (event.type === "quote") {
+    return event.status === "sent" ? "bg-violet-500" : "bg-violet-300";
+  }
+  // reminder
+  switch (event.status) {
+    case "completed": return "bg-emerald-500";
+    case "overdue": return "bg-red-500";
+    default: return "bg-slate-400";
+  }
+}
+
+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 = toLocalDateStr(date);
+  const dayEvents = events.filter((e) => e.date === dateStr);
+  const serviceCount = dayEvents.filter((e) => e.type === "service").length;
+  const reminderCount = dayEvents.filter((e) => e.type === "reminder").length;
+  const quoteCount = dayEvents.filter((e) => e.type === "quote").length;
+
+  return (
+    <button
+      type="button"
+      onClick={onClick}
+      className={`
+        relative flex flex-col items-center justify-start p-1 min-h-[80px] w-full rounded-md text-sm transition-colors
+        ${isCurrentMonth ? "" : "text-muted-foreground/40"}
+        ${isToday && !isSelected ? "bg-primary/10" : ""}
+        ${isSelected ? "ring-2 ring-primary bg-primary/5" : "hover:bg-muted/50"}
+      `}
+    >
+      <span
+        className={`
+          flex h-6 w-6 items-center justify-center rounded-full text-xs
+          ${isToday ? "bg-primary text-primary-foreground font-bold" : ""}
+        `}
+      >
+        {date.getDate()}
+      </span>
+      {dayEvents.length > 0 && (
+        <div className="flex flex-col gap-0.5 mt-0.5 w-full px-0.5">
+          {/* Show up to 3 mini event labels on larger screens */}
+          {dayEvents.slice(0, 2).map((event) => (
+            <div
+              key={`${event.type}-${event.id}`}
+              className={`hidden sm:flex items-center gap-1 rounded px-1 py-0 text-[10px] leading-tight truncate ${getEventBgColor(event)}`}
+            >
+              <div className={`h-1 w-1 shrink-0 rounded-full ${getEventDotColor(event)}`} />
+              <span className="truncate">{event.title}</span>
+            </div>
+          ))}
+          {dayEvents.length > 2 && (
+            <span className="hidden sm:block text-[9px] text-muted-foreground px-1">+{dayEvents.length - 2} more</span>
+          )}
+          {/* Mobile: show dots */}
+          <div className="flex sm:hidden gap-0.5 justify-center flex-wrap">
+            {serviceCount > 0 && <div className="flex items-center gap-0.5"><div className="h-1.5 w-1.5 rounded-full bg-blue-500" />{serviceCount > 1 && <span className="text-[8px] text-muted-foreground">{serviceCount}</span>}</div>}
+            {reminderCount > 0 && <div className="flex items-center gap-0.5"><div className="h-1.5 w-1.5 rounded-full bg-amber-500" />{reminderCount > 1 && <span className="text-[8px] text-muted-foreground">{reminderCount}</span>}</div>}
+            {quoteCount > 0 && <div className="flex items-center gap-0.5"><div className="h-1.5 w-1.5 rounded-full bg-violet-500" />{quoteCount > 1 && <span className="text-[8px] text-muted-foreground">{quoteCount}</span>}</div>}
+          </div>
+        </div>
+      )}
+    </button>
+  );
+}
+
+function getEventBgColor(event: CalendarEvent) {
+  if (event.type === "service") {
+    switch (event.status) {
+      case "completed": return "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400";
+      case "in_progress": case "in-progress": return "bg-blue-500/10 text-blue-700 dark:text-blue-400";
+      case "waiting-parts": return "bg-orange-500/10 text-orange-700 dark:text-orange-400";
+      default: return "bg-amber-500/10 text-amber-700 dark:text-amber-400";
+    }
+  }
+  if (event.type === "quote") {
+    return "bg-violet-500/10 text-violet-700 dark:text-violet-400";
+  }
+  switch (event.status) {
+    case "overdue": return "bg-red-500/10 text-red-700 dark:text-red-400";
+    default: return "bg-slate-500/10 text-slate-600 dark:text-slate-400";
+  }
+}

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

@@ -0,0 +1,129 @@
+"use client";
+
+import Link from "next/link";
+import { Badge } from "@/components/ui/badge";
+import { formatDateHeader } from "./calendar-utils";
+import { formatCurrency } from "@/lib/format";
+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": case "in-progress": return "bg-blue-500";
+      case "waiting-parts": return "bg-orange-500";
+      default: return "bg-amber-500";
+    }
+  }
+  if (event.type === "quote") {
+    return event.status === "sent" ? "bg-violet-500" : "bg-violet-300";
+  }
+  switch (event.status) {
+    case "completed": return "bg-emerald-500";
+    case "overdue": return "bg-red-500";
+    default: return "bg-slate-400";
+  }
+}
+
+function getStatusBadge(event: CalendarEvent) {
+  if (event.type === "service") {
+    switch (event.status) {
+      case "completed": return <Badge variant="outline" className="text-emerald-600 border-emerald-300 text-[10px] px-1.5 py-0">Completed</Badge>;
+      case "in_progress": case "in-progress": return <Badge variant="outline" className="text-blue-600 border-blue-300 text-[10px] px-1.5 py-0">In Progress</Badge>;
+      case "waiting-parts": return <Badge variant="outline" className="text-orange-600 border-orange-300 text-[10px] px-1.5 py-0">Waiting Parts</Badge>;
+      case "ready": return <Badge variant="outline" className="text-cyan-600 border-cyan-300 text-[10px] px-1.5 py-0">Ready</Badge>;
+      default: return <Badge variant="outline" className="text-amber-600 border-amber-300 text-[10px] px-1.5 py-0">Pending</Badge>;
+    }
+  }
+  if (event.type === "quote") {
+    switch (event.status) {
+      case "sent": return <Badge variant="outline" className="text-violet-600 border-violet-300 text-[10px] px-1.5 py-0">Sent</Badge>;
+      case "approved": return <Badge variant="outline" className="text-emerald-600 border-emerald-300 text-[10px] px-1.5 py-0">Approved</Badge>;
+      default: return <Badge variant="outline" className="text-violet-600 border-violet-300 text-[10px] px-1.5 py-0">Draft</Badge>;
+    }
+  }
+  switch (event.status) {
+    case "completed": return <Badge variant="outline" className="text-emerald-600 border-emerald-300 text-[10px] px-1.5 py-0">Done</Badge>;
+    case "overdue": return <Badge variant="destructive" className="text-[10px] px-1.5 py-0">Overdue</Badge>;
+    default: return <Badge variant="outline" className="text-[10px] px-1.5 py-0">Upcoming</Badge>;
+  }
+}
+
+function getTypeBadge(type: CalendarEvent["type"]) {
+  switch (type) {
+    case "service": return <Badge variant="secondary" className="shrink-0 text-[10px] px-1.5 py-0 bg-blue-500/10 text-blue-700 dark:text-blue-400">Service</Badge>;
+    case "reminder": return <Badge variant="secondary" className="shrink-0 text-[10px] px-1.5 py-0 bg-amber-500/10 text-amber-700 dark:text-amber-400">Reminder</Badge>;
+    case "quote": return <Badge variant="secondary" className="shrink-0 text-[10px] px-1.5 py-0 bg-violet-500/10 text-violet-700 dark:text-violet-400">Quote</Badge>;
+  }
+}
+
+function getEventLink(event: CalendarEvent) {
+  if (event.type === "quote") return `/quotes/${event.id}`;
+  return `/vehicles/${event.vehicleId}`;
+}
+
+interface CalendarEventListProps {
+  events: CalendarEvent[];
+  dateStr: string; // YYYY-MM-DD
+  selectedDate: Date;
+  currencyCode: string;
+}
+
+export function CalendarEventList({ events, dateStr, selectedDate, currencyCode }: CalendarEventListProps) {
+  const dayEvents = events.filter((e) => e.date === dateStr);
+
+  return (
+    <div className="space-y-3">
+      <p className="text-sm font-semibold">
+        {formatDateHeader(selectedDate)}
+      </p>
+
+      {dayEvents.length === 0 ? (
+        <div className="text-center py-8">
+          <p className="text-sm text-muted-foreground mb-1">No events</p>
+          <p className="text-xs text-muted-foreground">
+            Select a day with events or create a new work order.
+          </p>
+        </div>
+      ) : (
+        <div className="space-y-2">
+          {dayEvents.map((event) => (
+            <Link
+              key={`${event.type}-${event.id}`}
+              href={getEventLink(event)}
+              className="flex items-start gap-2.5 rounded-lg border p-2.5 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-1.5 flex-wrap">
+                  <p className="text-sm font-medium truncate">{event.title}</p>
+                  {getStatusBadge(event)}
+                </div>
+                <div className="flex items-center gap-2 mt-0.5">
+                  <p className="text-xs text-muted-foreground truncate">{event.vehicleLabel}</p>
+                  {event.time && (
+                    <span className="text-xs text-muted-foreground shrink-0">{event.time}</span>
+                  )}
+                </div>
+                {event.customerName && (
+                  <p className="text-xs text-muted-foreground">{event.customerName}</p>
+                )}
+                <div className="flex items-center gap-2 mt-1">
+                  {getTypeBadge(event.type)}
+                  {event.invoiceNumber && (
+                    <span className="text-[10px] text-muted-foreground">#{event.invoiceNumber}</span>
+                  )}
+                  {event.amount != null && (
+                    <span className="text-[10px] font-medium ml-auto">
+                      {formatCurrency(event.amount, currencyCode)}
+                    </span>
+                  )}
+                </div>
+              </div>
+            </Link>
+          ))}
+        </div>
+      )}
+    </div>
+  );
+}

+ 16 - 0
src/features/calendar/Components/calendar-utils.ts

@@ -0,0 +1,16 @@
+/** Format a Date as YYYY-MM-DD in local time (avoids UTC shift from toISOString) */
+export function toLocalDateStr(d: Date): string {
+  const y = d.getFullYear();
+  const m = String(d.getMonth() + 1).padStart(2, "0");
+  const day = String(d.getDate()).padStart(2, "0");
+  return `${y}-${m}-${day}`;
+}
+
+export function formatDateHeader(d: Date): string {
+  return d.toLocaleDateString("en-US", {
+    weekday: "long",
+    month: "long",
+    day: "numeric",
+    year: "numeric",
+  });
+}

+ 7 - 7
src/features/email/Actions/emailActions.ts

@@ -1,7 +1,7 @@
 "use server";
 
 import { db } from "@/lib/db";
-import { sendMail } from "@/lib/email";
+import { sendOrgMail, getOrgFromAddress } from "@/lib/email";
 import { withAuth } from "@/lib/with-auth";
 import { renderToBuffer } from "@react-pdf/renderer";
 import React from "react";
@@ -106,10 +106,10 @@ export async function sendQuoteEmail(input: {
     const pdfBuffer = await renderToBuffer(element);
     const quoteNum = quote.quoteNumber || `QT-${quote.id.slice(-8).toUpperCase()}`;
 
-    const fromEmail = process.env.SMTP_FROM_EMAIL || settings["workshop.email"] || "noreply@localhost";
+    const from = await getOrgFromAddress(organizationId);
 
-    await sendMail({
-      from: `${fromName} <${fromEmail}>`,
+    await sendOrgMail(organizationId, {
+      from,
       to: recipientEmail,
       subject: `Quote ${quoteNum} - ${quote.title}`,
       html: `
@@ -196,10 +196,10 @@ export async function sendInvoiceEmail(input: {
       ? `${process.env.NEXT_PUBLIC_APP_URL || process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : "http://localhost:3000"}/share/invoice/${organizationId}/${record.publicToken}`
       : null;
 
-    const fromEmail = process.env.SMTP_FROM_EMAIL || settings["workshop.email"] || "noreply@localhost";
+    const from = await getOrgFromAddress(organizationId);
 
-    await sendMail({
-      from: `${fromName} <${fromEmail}>`,
+    await sendOrgMail(organizationId, {
+      from,
       to: recipientEmail,
       subject: `Invoice ${invoiceNum} - ${record.title}`,
       html: `

+ 99 - 0
src/features/email/Actions/emailSettingsActions.ts

@@ -0,0 +1,99 @@
+"use server";
+
+import { db } from "@/lib/db";
+import { withAuth } from "@/lib/with-auth";
+import { revalidatePath } from "next/cache";
+import { ALL_ORG_EMAIL_KEYS } from "../Schema/emailSettingsSchema";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { sendOrgMail, getOrgFromAddress } from "@/lib/email";
+
+export async function getEmailSettings() {
+  return withAuth(
+    async ({ organizationId }) => {
+      const settings = await db.appSetting.findMany({
+        where: { organizationId, key: { in: ALL_ORG_EMAIL_KEYS } },
+      });
+      const map: Record<string, string> = {};
+      for (const s of settings) {
+        map[s.key] = s.value;
+      }
+      return map;
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.SETTINGS },
+      ],
+    },
+  );
+}
+
+export async function setEmailSettings(entries: Record<string, string>) {
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      await db.$transaction(
+        Object.entries(entries).map(([key, value]) =>
+          db.appSetting.upsert({
+            where: { organizationId_key: { organizationId, key } },
+            update: { value },
+            create: { userId, organizationId, key, value },
+          }),
+        ),
+      );
+      revalidatePath("/settings/email");
+      return true;
+    },
+    {
+      requiredPermissions: [
+        {
+          action: PermissionAction.UPDATE,
+          subject: PermissionSubject.SETTINGS,
+        },
+      ],
+    },
+  );
+}
+
+export async function testOrgEmailConnection() {
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      const user = await db.user.findUnique({
+        where: { id: userId },
+        select: { email: true },
+      });
+
+      if (!user?.email) {
+        throw new Error("Could not find your email address");
+      }
+
+      const from = await getOrgFromAddress(organizationId);
+
+      await sendOrgMail(organizationId, {
+        from,
+        to: user.email,
+        subject: "Email Test - Torqvoice",
+        html: `
+          <div style="font-family: sans-serif; max-width: 480px; margin: 0 auto;">
+            <h2>Email Configuration Test</h2>
+            <p>This is a test email from your organization's email settings.</p>
+            <p>If you're reading this, your email provider is configured correctly.</p>
+            <hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
+            <p style="color: #6b7280; font-size: 12px;">
+              Sent to: ${user.email}<br/>
+              Time: ${new Date().toISOString()}
+            </p>
+          </div>
+        `,
+      });
+
+      return { sentTo: user.email };
+    },
+    {
+      requiredPermissions: [
+        {
+          action: PermissionAction.UPDATE,
+          subject: PermissionSubject.SETTINGS,
+        },
+      ],
+    },
+  );
+}

+ 780 - 0
src/features/email/Components/EmailSettingsForm.tsx

@@ -0,0 +1,780 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import { toast } from "sonner";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import {
+  Card,
+  CardContent,
+  CardDescription,
+  CardHeader,
+  CardTitle,
+} from "@/components/ui/card";
+import { Loader2, Send, Info } from "lucide-react";
+import { ORG_EMAIL_KEYS } from "../Schema/emailSettingsSchema";
+import {
+  setEmailSettings,
+  testOrgEmailConnection,
+} from "../Actions/emailSettingsActions";
+
+type EmailProviderType =
+  | "smtp"
+  | "resend"
+  | "postmark"
+  | "mailgun"
+  | "sendgrid"
+  | "ses";
+
+export function EmailSettingsForm({
+  initial,
+}: {
+  initial: Record<string, string>;
+}) {
+  const router = useRouter();
+  const [isPending, startTransition] = useTransition();
+  const [isTesting, setIsTesting] = useState(false);
+
+  const hasCustomProvider = !!initial[ORG_EMAIL_KEYS.EMAIL_PROVIDER];
+  const [useCustom, setUseCustom] = useState(hasCustomProvider);
+
+  // Provider
+  const [emailProvider, setEmailProvider] = useState<EmailProviderType>(
+    (initial[ORG_EMAIL_KEYS.EMAIL_PROVIDER] as EmailProviderType) || "smtp",
+  );
+
+  // SMTP
+  const [smtpHost, setSmtpHost] = useState(
+    initial[ORG_EMAIL_KEYS.EMAIL_SMTP_HOST] || "",
+  );
+  const [smtpPort, setSmtpPort] = useState(
+    initial[ORG_EMAIL_KEYS.EMAIL_SMTP_PORT] || "587",
+  );
+  const [smtpUser, setSmtpUser] = useState(
+    initial[ORG_EMAIL_KEYS.EMAIL_SMTP_USER] || "",
+  );
+  const [smtpPass, setSmtpPass] = useState(
+    initial[ORG_EMAIL_KEYS.EMAIL_SMTP_PASS] || "",
+  );
+  const [smtpSecure, setSmtpSecure] = useState(
+    initial[ORG_EMAIL_KEYS.EMAIL_SMTP_SECURE] === "true",
+  );
+  const [smtpFromEmail, setSmtpFromEmail] = useState(
+    initial[ORG_EMAIL_KEYS.EMAIL_SMTP_FROM_EMAIL] || "",
+  );
+  const [smtpFromName, setSmtpFromName] = useState(
+    initial[ORG_EMAIL_KEYS.EMAIL_SMTP_FROM_NAME] || "",
+  );
+  const [smtpRejectUnauthorized, setSmtpRejectUnauthorized] = useState(
+    initial[ORG_EMAIL_KEYS.EMAIL_SMTP_REJECT_UNAUTHORIZED] !== "false",
+  );
+  const [smtpRequireTls, setSmtpRequireTls] = useState(
+    initial[ORG_EMAIL_KEYS.EMAIL_SMTP_REQUIRE_TLS] === "true",
+  );
+
+  // Resend
+  const [resendApiKey, setResendApiKey] = useState(
+    initial[ORG_EMAIL_KEYS.EMAIL_RESEND_API_KEY] || "",
+  );
+  const [resendFromEmail, setResendFromEmail] = useState(
+    initial[ORG_EMAIL_KEYS.EMAIL_RESEND_FROM_EMAIL] || "",
+  );
+  const [resendFromName, setResendFromName] = useState(
+    initial[ORG_EMAIL_KEYS.EMAIL_RESEND_FROM_NAME] || "",
+  );
+
+  // Postmark
+  const [postmarkApiKey, setPostmarkApiKey] = useState(
+    initial[ORG_EMAIL_KEYS.EMAIL_POSTMARK_API_KEY] || "",
+  );
+  const [postmarkFromEmail, setPostmarkFromEmail] = useState(
+    initial[ORG_EMAIL_KEYS.EMAIL_POSTMARK_FROM_EMAIL] || "",
+  );
+  const [postmarkFromName, setPostmarkFromName] = useState(
+    initial[ORG_EMAIL_KEYS.EMAIL_POSTMARK_FROM_NAME] || "",
+  );
+
+  // Mailgun
+  const [mailgunApiKey, setMailgunApiKey] = useState(
+    initial[ORG_EMAIL_KEYS.EMAIL_MAILGUN_API_KEY] || "",
+  );
+  const [mailgunDomain, setMailgunDomain] = useState(
+    initial[ORG_EMAIL_KEYS.EMAIL_MAILGUN_DOMAIN] || "",
+  );
+  const [mailgunRegion, setMailgunRegion] = useState(
+    initial[ORG_EMAIL_KEYS.EMAIL_MAILGUN_REGION] || "us",
+  );
+  const [mailgunFromEmail, setMailgunFromEmail] = useState(
+    initial[ORG_EMAIL_KEYS.EMAIL_MAILGUN_FROM_EMAIL] || "",
+  );
+  const [mailgunFromName, setMailgunFromName] = useState(
+    initial[ORG_EMAIL_KEYS.EMAIL_MAILGUN_FROM_NAME] || "",
+  );
+
+  // SendGrid
+  const [sendgridApiKey, setSendgridApiKey] = useState(
+    initial[ORG_EMAIL_KEYS.EMAIL_SENDGRID_API_KEY] || "",
+  );
+  const [sendgridFromEmail, setSendgridFromEmail] = useState(
+    initial[ORG_EMAIL_KEYS.EMAIL_SENDGRID_FROM_EMAIL] || "",
+  );
+  const [sendgridFromName, setSendgridFromName] = useState(
+    initial[ORG_EMAIL_KEYS.EMAIL_SENDGRID_FROM_NAME] || "",
+  );
+
+  // Amazon SES
+  const [sesAccessKeyId, setSesAccessKeyId] = useState(
+    initial[ORG_EMAIL_KEYS.EMAIL_SES_ACCESS_KEY_ID] || "",
+  );
+  const [sesSecretAccessKey, setSesSecretAccessKey] = useState(
+    initial[ORG_EMAIL_KEYS.EMAIL_SES_SECRET_ACCESS_KEY] || "",
+  );
+  const [sesRegion, setSesRegion] = useState(
+    initial[ORG_EMAIL_KEYS.EMAIL_SES_REGION] || "us-east-1",
+  );
+  const [sesFromEmail, setSesFromEmail] = useState(
+    initial[ORG_EMAIL_KEYS.EMAIL_SES_FROM_EMAIL] || "",
+  );
+  const [sesFromName, setSesFromName] = useState(
+    initial[ORG_EMAIL_KEYS.EMAIL_SES_FROM_NAME] || "",
+  );
+
+  const handleSave = () => {
+    startTransition(async () => {
+      if (!useCustom) {
+        // Clear the provider key to revert to platform default
+        const result = await setEmailSettings({
+          [ORG_EMAIL_KEYS.EMAIL_PROVIDER]: "",
+        });
+        if (result.success) {
+          toast.success("Email settings saved — using platform default");
+          router.refresh();
+        } else {
+          toast.error(result.error ?? "Failed to save settings");
+        }
+        return;
+      }
+
+      const data: Record<string, string> = {
+        [ORG_EMAIL_KEYS.EMAIL_PROVIDER]: emailProvider,
+        // SMTP
+        [ORG_EMAIL_KEYS.EMAIL_SMTP_HOST]: smtpHost,
+        [ORG_EMAIL_KEYS.EMAIL_SMTP_PORT]: smtpPort,
+        [ORG_EMAIL_KEYS.EMAIL_SMTP_USER]: smtpUser,
+        [ORG_EMAIL_KEYS.EMAIL_SMTP_PASS]: smtpPass,
+        [ORG_EMAIL_KEYS.EMAIL_SMTP_SECURE]: String(smtpSecure),
+        [ORG_EMAIL_KEYS.EMAIL_SMTP_FROM_EMAIL]: smtpFromEmail,
+        [ORG_EMAIL_KEYS.EMAIL_SMTP_FROM_NAME]: smtpFromName,
+        [ORG_EMAIL_KEYS.EMAIL_SMTP_REJECT_UNAUTHORIZED]: String(
+          smtpRejectUnauthorized,
+        ),
+        [ORG_EMAIL_KEYS.EMAIL_SMTP_REQUIRE_TLS]: String(smtpRequireTls),
+        // Resend
+        [ORG_EMAIL_KEYS.EMAIL_RESEND_API_KEY]: resendApiKey,
+        [ORG_EMAIL_KEYS.EMAIL_RESEND_FROM_EMAIL]: resendFromEmail,
+        [ORG_EMAIL_KEYS.EMAIL_RESEND_FROM_NAME]: resendFromName,
+        // Postmark
+        [ORG_EMAIL_KEYS.EMAIL_POSTMARK_API_KEY]: postmarkApiKey,
+        [ORG_EMAIL_KEYS.EMAIL_POSTMARK_FROM_EMAIL]: postmarkFromEmail,
+        [ORG_EMAIL_KEYS.EMAIL_POSTMARK_FROM_NAME]: postmarkFromName,
+        // Mailgun
+        [ORG_EMAIL_KEYS.EMAIL_MAILGUN_API_KEY]: mailgunApiKey,
+        [ORG_EMAIL_KEYS.EMAIL_MAILGUN_DOMAIN]: mailgunDomain,
+        [ORG_EMAIL_KEYS.EMAIL_MAILGUN_REGION]: mailgunRegion,
+        [ORG_EMAIL_KEYS.EMAIL_MAILGUN_FROM_EMAIL]: mailgunFromEmail,
+        [ORG_EMAIL_KEYS.EMAIL_MAILGUN_FROM_NAME]: mailgunFromName,
+        // SendGrid
+        [ORG_EMAIL_KEYS.EMAIL_SENDGRID_API_KEY]: sendgridApiKey,
+        [ORG_EMAIL_KEYS.EMAIL_SENDGRID_FROM_EMAIL]: sendgridFromEmail,
+        [ORG_EMAIL_KEYS.EMAIL_SENDGRID_FROM_NAME]: sendgridFromName,
+        // Amazon SES
+        [ORG_EMAIL_KEYS.EMAIL_SES_ACCESS_KEY_ID]: sesAccessKeyId,
+        [ORG_EMAIL_KEYS.EMAIL_SES_SECRET_ACCESS_KEY]: sesSecretAccessKey,
+        [ORG_EMAIL_KEYS.EMAIL_SES_REGION]: sesRegion,
+        [ORG_EMAIL_KEYS.EMAIL_SES_FROM_EMAIL]: sesFromEmail,
+        [ORG_EMAIL_KEYS.EMAIL_SES_FROM_NAME]: sesFromName,
+      };
+
+      const result = await setEmailSettings(data);
+      if (result.success) {
+        toast.success("Email settings saved");
+        router.refresh();
+      } else {
+        toast.error(result.error ?? "Failed to save settings");
+      }
+    });
+  };
+
+  const handleTestEmail = async () => {
+    setIsTesting(true);
+    try {
+      const result = await testOrgEmailConnection();
+      if (result.success) {
+        toast.success(`Test email sent to ${result.data?.sentTo}`);
+      } else {
+        toast.error(result.error ?? "Email test failed");
+      }
+    } finally {
+      setIsTesting(false);
+    }
+  };
+
+  const isTestDisabled =
+    isTesting ||
+    !useCustom ||
+    (emailProvider === "smtp" && !smtpHost) ||
+    (emailProvider === "resend" && !resendApiKey) ||
+    (emailProvider === "postmark" && !postmarkApiKey) ||
+    (emailProvider === "mailgun" && !mailgunApiKey) ||
+    (emailProvider === "sendgrid" && !sendgridApiKey) ||
+    (emailProvider === "ses" && !sesAccessKeyId);
+
+  return (
+    <div className="space-y-6">
+      <Card>
+        <CardHeader>
+          <CardTitle>Email Provider</CardTitle>
+          <CardDescription>
+            Configure your organization&apos;s email provider for sending
+            invoices and quotes. If not configured, the platform&apos;s default
+            email provider will be used.
+          </CardDescription>
+        </CardHeader>
+        <CardContent className="space-y-6">
+          <div className="flex items-center justify-between">
+            <div className="space-y-0.5">
+              <Label htmlFor="use-custom-email">
+                Use custom email provider
+              </Label>
+              <p className="text-xs text-muted-foreground">
+                Enable to configure your own email provider instead of using the
+                platform default
+              </p>
+            </div>
+            <Switch
+              id="use-custom-email"
+              checked={useCustom}
+              onCheckedChange={setUseCustom}
+            />
+          </div>
+
+          {!useCustom && (
+            <div className="flex items-start gap-3 rounded-lg border bg-muted/50 p-4">
+              <Info className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
+              <p className="text-sm text-muted-foreground">
+                Your organization is using the platform&apos;s default email
+                provider. Invoices and quotes will be sent using the
+                platform-configured email settings.
+              </p>
+            </div>
+          )}
+
+          {useCustom && (
+            <>
+              <div className="flex flex-wrap gap-2">
+                <Button
+                  type="button"
+                  variant={emailProvider === "smtp" ? "default" : "outline"}
+                  onClick={() => setEmailProvider("smtp")}
+                  className="flex-1"
+                >
+                  SMTP
+                </Button>
+                <Button
+                  type="button"
+                  variant={emailProvider === "resend" ? "default" : "outline"}
+                  onClick={() => setEmailProvider("resend")}
+                  className="flex-1"
+                >
+                  Resend
+                </Button>
+                <Button
+                  type="button"
+                  variant={emailProvider === "postmark" ? "default" : "outline"}
+                  onClick={() => setEmailProvider("postmark")}
+                  className="flex-1"
+                >
+                  Postmark
+                </Button>
+                <Button
+                  type="button"
+                  variant={emailProvider === "mailgun" ? "default" : "outline"}
+                  onClick={() => setEmailProvider("mailgun")}
+                  className="flex-1"
+                >
+                  Mailgun
+                </Button>
+                <Button
+                  type="button"
+                  variant={
+                    emailProvider === "sendgrid" ? "default" : "outline"
+                  }
+                  onClick={() => setEmailProvider("sendgrid")}
+                  className="flex-1"
+                >
+                  SendGrid
+                </Button>
+                <Button
+                  type="button"
+                  variant={emailProvider === "ses" ? "default" : "outline"}
+                  onClick={() => setEmailProvider("ses")}
+                  className="flex-1"
+                >
+                  Amazon SES
+                </Button>
+              </div>
+
+              {emailProvider === "smtp" && (
+                <>
+                  <div className="grid gap-4 sm:grid-cols-2">
+                    <div className="space-y-2">
+                      <Label htmlFor="org-smtp-host">SMTP Host</Label>
+                      <Input
+                        id="org-smtp-host"
+                        placeholder="smtp.example.com"
+                        value={smtpHost}
+                        onChange={(e) => setSmtpHost(e.target.value)}
+                      />
+                    </div>
+                    <div className="space-y-2">
+                      <Label htmlFor="org-smtp-port">SMTP Port</Label>
+                      <Input
+                        id="org-smtp-port"
+                        placeholder="587"
+                        value={smtpPort}
+                        onChange={(e) => setSmtpPort(e.target.value)}
+                      />
+                    </div>
+                  </div>
+
+                  <div className="grid gap-4 sm:grid-cols-2">
+                    <div className="space-y-2">
+                      <Label htmlFor="org-smtp-user">Username</Label>
+                      <Input
+                        id="org-smtp-user"
+                        placeholder="user@example.com"
+                        value={smtpUser}
+                        onChange={(e) => setSmtpUser(e.target.value)}
+                      />
+                    </div>
+                    <div className="space-y-2">
+                      <Label htmlFor="org-smtp-pass">Password</Label>
+                      <Input
+                        id="org-smtp-pass"
+                        type="password"
+                        placeholder="••••••••"
+                        value={smtpPass}
+                        onChange={(e) => setSmtpPass(e.target.value)}
+                      />
+                    </div>
+                  </div>
+
+                  <div className="grid gap-4 sm:grid-cols-2">
+                    <div className="space-y-2">
+                      <Label htmlFor="org-smtp-from-email">From Email</Label>
+                      <Input
+                        id="org-smtp-from-email"
+                        placeholder="noreply@example.com"
+                        value={smtpFromEmail}
+                        onChange={(e) => setSmtpFromEmail(e.target.value)}
+                      />
+                    </div>
+                    <div className="space-y-2">
+                      <Label htmlFor="org-smtp-from-name">From Name</Label>
+                      <Input
+                        id="org-smtp-from-name"
+                        placeholder="Your Workshop"
+                        value={smtpFromName}
+                        onChange={(e) => setSmtpFromName(e.target.value)}
+                      />
+                    </div>
+                  </div>
+
+                  <div className="space-y-4">
+                    <div className="flex items-center justify-between">
+                      <div className="space-y-0.5">
+                        <Label htmlFor="org-smtp-secure">
+                          TLS Connection (Port 465)
+                        </Label>
+                        <p className="text-xs text-muted-foreground">
+                          Enable for implicit TLS. Disable for STARTTLS (port
+                          587/25).
+                        </p>
+                      </div>
+                      <Switch
+                        id="org-smtp-secure"
+                        checked={smtpSecure}
+                        onCheckedChange={setSmtpSecure}
+                      />
+                    </div>
+
+                    <div className="flex items-center justify-between">
+                      <div className="space-y-0.5">
+                        <Label htmlFor="org-smtp-reject-unauthorized">
+                          Verify TLS Certificates
+                        </Label>
+                        <p className="text-xs text-muted-foreground">
+                          Disable to allow self-signed certificates (not
+                          recommended for production)
+                        </p>
+                      </div>
+                      <Switch
+                        id="org-smtp-reject-unauthorized"
+                        checked={smtpRejectUnauthorized}
+                        onCheckedChange={setSmtpRejectUnauthorized}
+                      />
+                    </div>
+
+                    <div className="flex items-center justify-between">
+                      <div className="space-y-0.5">
+                        <Label htmlFor="org-smtp-require-tls">
+                          Require TLS Upgrade
+                        </Label>
+                        <p className="text-xs text-muted-foreground">
+                          Force TLS upgrade on STARTTLS connections
+                        </p>
+                      </div>
+                      <Switch
+                        id="org-smtp-require-tls"
+                        checked={smtpRequireTls}
+                        onCheckedChange={setSmtpRequireTls}
+                      />
+                    </div>
+                  </div>
+                </>
+              )}
+
+              {emailProvider === "resend" && (
+                <>
+                  <div className="space-y-2">
+                    <Label htmlFor="org-resend-api-key">API Key</Label>
+                    <Input
+                      id="org-resend-api-key"
+                      type="password"
+                      placeholder="re_••••••••"
+                      value={resendApiKey}
+                      onChange={(e) => setResendApiKey(e.target.value)}
+                    />
+                    <p className="text-xs text-muted-foreground">
+                      Get your API key from{" "}
+                      <a
+                        href="https://resend.com"
+                        target="_blank"
+                        rel="noopener noreferrer"
+                        className="underline"
+                      >
+                        resend.com
+                      </a>
+                    </p>
+                  </div>
+
+                  <div className="grid gap-4 sm:grid-cols-2">
+                    <div className="space-y-2">
+                      <Label htmlFor="org-resend-from-email">From Email</Label>
+                      <Input
+                        id="org-resend-from-email"
+                        placeholder="noreply@yourdomain.com"
+                        value={resendFromEmail}
+                        onChange={(e) => setResendFromEmail(e.target.value)}
+                      />
+                    </div>
+                    <div className="space-y-2">
+                      <Label htmlFor="org-resend-from-name">From Name</Label>
+                      <Input
+                        id="org-resend-from-name"
+                        placeholder="Your Workshop"
+                        value={resendFromName}
+                        onChange={(e) => setResendFromName(e.target.value)}
+                      />
+                    </div>
+                  </div>
+                </>
+              )}
+
+              {emailProvider === "postmark" && (
+                <>
+                  <div className="space-y-2">
+                    <Label htmlFor="org-postmark-api-key">Server Token</Label>
+                    <Input
+                      id="org-postmark-api-key"
+                      type="password"
+                      placeholder="••••••••-••••-••••-••••-••••••••••••"
+                      value={postmarkApiKey}
+                      onChange={(e) => setPostmarkApiKey(e.target.value)}
+                    />
+                    <p className="text-xs text-muted-foreground">
+                      Get your Server Token from{" "}
+                      <a
+                        href="https://postmarkapp.com"
+                        target="_blank"
+                        rel="noopener noreferrer"
+                        className="underline"
+                      >
+                        postmarkapp.com
+                      </a>
+                    </p>
+                  </div>
+
+                  <div className="grid gap-4 sm:grid-cols-2">
+                    <div className="space-y-2">
+                      <Label htmlFor="org-postmark-from-email">
+                        From Email
+                      </Label>
+                      <Input
+                        id="org-postmark-from-email"
+                        placeholder="noreply@yourdomain.com"
+                        value={postmarkFromEmail}
+                        onChange={(e) => setPostmarkFromEmail(e.target.value)}
+                      />
+                    </div>
+                    <div className="space-y-2">
+                      <Label htmlFor="org-postmark-from-name">From Name</Label>
+                      <Input
+                        id="org-postmark-from-name"
+                        placeholder="Your Workshop"
+                        value={postmarkFromName}
+                        onChange={(e) => setPostmarkFromName(e.target.value)}
+                      />
+                    </div>
+                  </div>
+                </>
+              )}
+
+              {emailProvider === "mailgun" && (
+                <>
+                  <div className="space-y-2">
+                    <Label htmlFor="org-mailgun-api-key">API Key</Label>
+                    <Input
+                      id="org-mailgun-api-key"
+                      type="password"
+                      placeholder="key-••••••••••••••••••••••••••••••••"
+                      value={mailgunApiKey}
+                      onChange={(e) => setMailgunApiKey(e.target.value)}
+                    />
+                    <p className="text-xs text-muted-foreground">
+                      Get your API key from{" "}
+                      <a
+                        href="https://app.mailgun.com"
+                        target="_blank"
+                        rel="noopener noreferrer"
+                        className="underline"
+                      >
+                        app.mailgun.com
+                      </a>
+                    </p>
+                  </div>
+
+                  <div className="grid gap-4 sm:grid-cols-2">
+                    <div className="space-y-2">
+                      <Label htmlFor="org-mailgun-domain">Domain</Label>
+                      <Input
+                        id="org-mailgun-domain"
+                        placeholder="mg.yourdomain.com"
+                        value={mailgunDomain}
+                        onChange={(e) => setMailgunDomain(e.target.value)}
+                      />
+                    </div>
+                    <div className="space-y-2">
+                      <Label htmlFor="org-mailgun-region">Region</Label>
+                      <div className="flex gap-2">
+                        <Button
+                          type="button"
+                          variant={
+                            mailgunRegion === "us" ? "default" : "outline"
+                          }
+                          onClick={() => setMailgunRegion("us")}
+                          className="flex-1"
+                          size="sm"
+                        >
+                          US
+                        </Button>
+                        <Button
+                          type="button"
+                          variant={
+                            mailgunRegion === "eu" ? "default" : "outline"
+                          }
+                          onClick={() => setMailgunRegion("eu")}
+                          className="flex-1"
+                          size="sm"
+                        >
+                          EU
+                        </Button>
+                      </div>
+                    </div>
+                  </div>
+
+                  <div className="grid gap-4 sm:grid-cols-2">
+                    <div className="space-y-2">
+                      <Label htmlFor="org-mailgun-from-email">
+                        From Email
+                      </Label>
+                      <Input
+                        id="org-mailgun-from-email"
+                        placeholder="noreply@yourdomain.com"
+                        value={mailgunFromEmail}
+                        onChange={(e) => setMailgunFromEmail(e.target.value)}
+                      />
+                    </div>
+                    <div className="space-y-2">
+                      <Label htmlFor="org-mailgun-from-name">From Name</Label>
+                      <Input
+                        id="org-mailgun-from-name"
+                        placeholder="Your Workshop"
+                        value={mailgunFromName}
+                        onChange={(e) => setMailgunFromName(e.target.value)}
+                      />
+                    </div>
+                  </div>
+                </>
+              )}
+
+              {emailProvider === "sendgrid" && (
+                <>
+                  <div className="space-y-2">
+                    <Label htmlFor="org-sendgrid-api-key">API Key</Label>
+                    <Input
+                      id="org-sendgrid-api-key"
+                      type="password"
+                      placeholder="SG.••••••••••••••••••••••••••••••••"
+                      value={sendgridApiKey}
+                      onChange={(e) => setSendgridApiKey(e.target.value)}
+                    />
+                    <p className="text-xs text-muted-foreground">
+                      Get your API key from{" "}
+                      <a
+                        href="https://app.sendgrid.com"
+                        target="_blank"
+                        rel="noopener noreferrer"
+                        className="underline"
+                      >
+                        app.sendgrid.com
+                      </a>
+                    </p>
+                  </div>
+
+                  <div className="grid gap-4 sm:grid-cols-2">
+                    <div className="space-y-2">
+                      <Label htmlFor="org-sendgrid-from-email">
+                        From Email
+                      </Label>
+                      <Input
+                        id="org-sendgrid-from-email"
+                        placeholder="noreply@yourdomain.com"
+                        value={sendgridFromEmail}
+                        onChange={(e) => setSendgridFromEmail(e.target.value)}
+                      />
+                    </div>
+                    <div className="space-y-2">
+                      <Label htmlFor="org-sendgrid-from-name">From Name</Label>
+                      <Input
+                        id="org-sendgrid-from-name"
+                        placeholder="Your Workshop"
+                        value={sendgridFromName}
+                        onChange={(e) => setSendgridFromName(e.target.value)}
+                      />
+                    </div>
+                  </div>
+                </>
+              )}
+
+              {emailProvider === "ses" && (
+                <>
+                  <div className="grid gap-4 sm:grid-cols-2">
+                    <div className="space-y-2">
+                      <Label htmlFor="org-ses-access-key">
+                        Access Key ID
+                      </Label>
+                      <Input
+                        id="org-ses-access-key"
+                        type="password"
+                        placeholder="AKIA••••••••••••••••"
+                        value={sesAccessKeyId}
+                        onChange={(e) => setSesAccessKeyId(e.target.value)}
+                      />
+                    </div>
+                    <div className="space-y-2">
+                      <Label htmlFor="org-ses-secret-key">
+                        Secret Access Key
+                      </Label>
+                      <Input
+                        id="org-ses-secret-key"
+                        type="password"
+                        placeholder="••••••••••••••••••••••••••••••••••••••••"
+                        value={sesSecretAccessKey}
+                        onChange={(e) => setSesSecretAccessKey(e.target.value)}
+                      />
+                    </div>
+                  </div>
+
+                  <div className="space-y-2">
+                    <Label htmlFor="org-ses-region">AWS Region</Label>
+                    <Input
+                      id="org-ses-region"
+                      placeholder="us-east-1"
+                      value={sesRegion}
+                      onChange={(e) => setSesRegion(e.target.value)}
+                    />
+                    <p className="text-xs text-muted-foreground">
+                      The AWS region where SES is configured (e.g. us-east-1,
+                      eu-west-1)
+                    </p>
+                  </div>
+
+                  <div className="grid gap-4 sm:grid-cols-2">
+                    <div className="space-y-2">
+                      <Label htmlFor="org-ses-from-email">From Email</Label>
+                      <Input
+                        id="org-ses-from-email"
+                        placeholder="noreply@yourdomain.com"
+                        value={sesFromEmail}
+                        onChange={(e) => setSesFromEmail(e.target.value)}
+                      />
+                    </div>
+                    <div className="space-y-2">
+                      <Label htmlFor="org-ses-from-name">From Name</Label>
+                      <Input
+                        id="org-ses-from-name"
+                        placeholder="Your Workshop"
+                        value={sesFromName}
+                        onChange={(e) => setSesFromName(e.target.value)}
+                      />
+                    </div>
+                  </div>
+                </>
+              )}
+
+              <div className="flex items-center gap-2 pt-2">
+                <Button
+                  type="button"
+                  variant="outline"
+                  onClick={handleTestEmail}
+                  disabled={isTestDisabled}
+                >
+                  {isTesting ? (
+                    <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+                  ) : (
+                    <Send className="mr-2 h-4 w-4" />
+                  )}
+                  Send Test Email
+                </Button>
+                <p className="text-xs text-muted-foreground">
+                  Save settings first, then send a test email to your account
+                </p>
+              </div>
+            </>
+          )}
+        </CardContent>
+      </Card>
+
+      <div className="flex justify-end">
+        <Button onClick={handleSave} disabled={isPending}>
+          {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+          Save Settings
+        </Button>
+      </div>
+    </div>
+  );
+}

+ 47 - 0
src/features/email/Schema/emailSettingsSchema.ts

@@ -0,0 +1,47 @@
+export const ORG_EMAIL_KEYS = {
+  EMAIL_PROVIDER: "email.provider",
+
+  // SMTP
+  EMAIL_SMTP_HOST: "email.smtp.host",
+  EMAIL_SMTP_PORT: "email.smtp.port",
+  EMAIL_SMTP_USER: "email.smtp.user",
+  EMAIL_SMTP_PASS: "email.smtp.pass",
+  EMAIL_SMTP_SECURE: "email.smtp.secure",
+  EMAIL_SMTP_FROM_EMAIL: "email.smtp.fromEmail",
+  EMAIL_SMTP_FROM_NAME: "email.smtp.fromName",
+  EMAIL_SMTP_REJECT_UNAUTHORIZED: "email.smtp.rejectUnauthorized",
+  EMAIL_SMTP_REQUIRE_TLS: "email.smtp.requireTls",
+
+  // Resend
+  EMAIL_RESEND_API_KEY: "email.resend.apiKey",
+  EMAIL_RESEND_FROM_EMAIL: "email.resend.fromEmail",
+  EMAIL_RESEND_FROM_NAME: "email.resend.fromName",
+
+  // Postmark
+  EMAIL_POSTMARK_API_KEY: "email.postmark.apiKey",
+  EMAIL_POSTMARK_FROM_EMAIL: "email.postmark.fromEmail",
+  EMAIL_POSTMARK_FROM_NAME: "email.postmark.fromName",
+
+  // Mailgun
+  EMAIL_MAILGUN_API_KEY: "email.mailgun.apiKey",
+  EMAIL_MAILGUN_DOMAIN: "email.mailgun.domain",
+  EMAIL_MAILGUN_REGION: "email.mailgun.region",
+  EMAIL_MAILGUN_FROM_EMAIL: "email.mailgun.fromEmail",
+  EMAIL_MAILGUN_FROM_NAME: "email.mailgun.fromName",
+
+  // SendGrid
+  EMAIL_SENDGRID_API_KEY: "email.sendgrid.apiKey",
+  EMAIL_SENDGRID_FROM_EMAIL: "email.sendgrid.fromEmail",
+  EMAIL_SENDGRID_FROM_NAME: "email.sendgrid.fromName",
+
+  // Amazon SES
+  EMAIL_SES_ACCESS_KEY_ID: "email.ses.accessKeyId",
+  EMAIL_SES_SECRET_ACCESS_KEY: "email.ses.secretAccessKey",
+  EMAIL_SES_REGION: "email.ses.region",
+  EMAIL_SES_FROM_EMAIL: "email.ses.fromEmail",
+  EMAIL_SES_FROM_NAME: "email.ses.fromName",
+} as const;
+
+export type OrgEmailKey = (typeof ORG_EMAIL_KEYS)[keyof typeof ORG_EMAIL_KEYS];
+
+export const ALL_ORG_EMAIL_KEYS = Object.values(ORG_EMAIL_KEYS);

+ 9 - 11
src/features/quotes/Components/QuotePDF.tsx

@@ -1,5 +1,5 @@
 import { Document, Page, Text, View, StyleSheet, Image } from '@react-pdf/renderer'
-import { formatCurrency, getCurrencySymbol } from '@/lib/format'
+import { formatCurrency, getCurrencySymbol, formatDateForPdf, DEFAULT_DATE_FORMAT } from '@/lib/format'
 
 const amber = '#d97706'
 const amberLight = '#fef3c7'
@@ -141,26 +141,24 @@ export function QuotePDF({
   currencyCode = 'USD',
   logoDataUri,
   torqvoiceLogoDataUri,
+  dateFormat,
+  timezone,
 }: {
   data: QuoteData
   workshop?: WorkshopInfo
   currencyCode?: string
   logoDataUri?: string
   torqvoiceLogoDataUri?: string
+  dateFormat?: string
+  timezone?: string
 }) {
   const cs = getCurrencySymbol(currencyCode)
   const quoteNum = data.quoteNumber || 'QUOTE'
-  const createdDate = new Date(data.createdAt).toLocaleDateString('en-US', {
-    year: 'numeric',
-    month: 'long',
-    day: 'numeric',
-  })
+  const df = dateFormat || DEFAULT_DATE_FORMAT
+  const tz = timezone || undefined
+  const createdDate = formatDateForPdf(data.createdAt, df, tz)
   const validDate = data.validUntil
-    ? new Date(data.validUntil).toLocaleDateString('en-US', {
-        year: 'numeric',
-        month: 'long',
-        day: 'numeric',
-      })
+    ? formatDateForPdf(data.validUntil, df, tz)
     : null
   const shopName = workshop?.name || 'Torqvoice'
 

+ 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>
+  );
+}

+ 74 - 2
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";
 
@@ -12,7 +16,7 @@ function downloadCsv(
   rows: Record<string, unknown>[],
   keys: string[],
 ) {
-  const escape = (v: unknown) => {
+  const escapeCsv = (v: unknown) => {
     const s = String(v ?? "");
     return s.includes(",") || s.includes('"') || s.includes("\n")
       ? `"${s.replace(/"/g, '""')}"`
@@ -21,7 +25,7 @@ function downloadCsv(
 
   const lines = [
     headers.map(escape).join(","),
-    ...rows.map((r) => keys.map((k) => escape(r[k])).join(",")),
+    ...rows.map((r) => keys.map((k) => escapeCsv(r[k])).join(",")),
   ];
 
   const blob = new Blob([lines.join("\n")], { type: "text/csv;charset=utf-8;" });
@@ -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[];
+}

+ 137 - 0
src/features/settings/Actions/deleteAccount.ts

@@ -0,0 +1,137 @@
+"use server";
+
+import { db } from "@/lib/db";
+import { withAuth } from "@/lib/with-auth";
+import { resolveUploadPath } from "@/lib/resolve-upload-path";
+import { unlink, rm } from "fs/promises";
+import path from "path";
+
+export async function deleteAccount() {
+  return withAuth(async ({ userId, organizationId }) => {
+    // Count other members in the organization
+    const memberCount = organizationId
+      ? await db.organizationMember.count({
+          where: { organizationId },
+        })
+      : 0;
+
+    const isLastMember = memberCount <= 1;
+
+    if (isLastMember && organizationId) {
+      // --- LAST MEMBER: delete everything ---
+
+      // Collect file paths to clean up from disk
+      const filePaths: string[] = [];
+
+      const attachments = await db.serviceAttachment.findMany({
+        where: { serviceRecord: { vehicle: { organizationId } } },
+        select: { fileUrl: true },
+      });
+      for (const att of attachments) {
+        filePaths.push(resolveUploadPath(att.fileUrl));
+      }
+
+      const inventoryParts = await db.inventoryPart.findMany({
+        where: { organizationId },
+        select: { imageUrl: true },
+      });
+      for (const part of inventoryParts) {
+        if (part.imageUrl) filePaths.push(resolveUploadPath(part.imageUrl));
+      }
+
+      const vehicles = await db.vehicle.findMany({
+        where: { organizationId },
+        select: { imageUrl: true },
+      });
+      for (const v of vehicles) {
+        if (v.imageUrl) filePaths.push(resolveUploadPath(v.imageUrl));
+      }
+
+      // Delete membership first (not auto-cascaded from user deletion)
+      await db.organizationMember.deleteMany({
+        where: { userId },
+      });
+
+      // Delete the organization — cascades all org data (vehicles, customers,
+      // quotes, inventory, custom fields, settings, roles, invitations, subscription)
+      await db.organization.delete({
+        where: { id: organizationId },
+      });
+
+      // Delete the user — cascades sessions, accounts, 2FA
+      await db.user.delete({
+        where: { id: userId },
+      });
+
+      // Clean up files from disk (best effort)
+      for (const filePath of filePaths) {
+        try {
+          await unlink(filePath);
+        } catch {
+          // File may already be missing
+        }
+      }
+
+      // Try to remove the org upload directory
+      try {
+        const orgUploadDir = path.join(process.cwd(), "data", "uploads", organizationId);
+        await rm(orgUploadDir, { recursive: true, force: true });
+      } catch {
+        // Directory may not exist
+      }
+    } else {
+      // --- NOT LAST MEMBER: reassign org data, then delete user ---
+
+      // Find another member to reassign data to
+      const otherMember = await db.organizationMember.findFirst({
+        where: { organizationId, NOT: { userId } },
+        select: { userId: true },
+      });
+
+      if (otherMember) {
+        const newOwnerId = otherMember.userId;
+
+        // Reassign all org data owned by this user to another member
+        await db.$transaction([
+          db.vehicle.updateMany({
+            where: { userId, organizationId },
+            data: { userId: newOwnerId },
+          }),
+          db.customer.updateMany({
+            where: { userId, organizationId },
+            data: { userId: newOwnerId },
+          }),
+          db.quote.updateMany({
+            where: { userId, organizationId },
+            data: { userId: newOwnerId },
+          }),
+          db.inventoryPart.updateMany({
+            where: { userId, organizationId },
+            data: { userId: newOwnerId },
+          }),
+          db.customFieldDefinition.updateMany({
+            where: { userId, organizationId },
+            data: { userId: newOwnerId },
+          }),
+          db.appSetting.updateMany({
+            where: { userId, organizationId },
+            data: { userId: newOwnerId },
+          }),
+        ]);
+      }
+
+      // Remove membership
+      await db.organizationMember.deleteMany({
+        where: { userId },
+      });
+
+      // Delete the user — cascades only user-specific data
+      // (sessions, accounts, 2FA; org data was reassigned above)
+      await db.user.delete({
+        where: { id: userId },
+      });
+    }
+
+    return { deleted: true };
+  });
+}

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

@@ -45,6 +45,9 @@ export const SETTING_KEYS = {
   LICENSE_VALID: "license.valid",
   LICENSE_CHECKED_AT: "license.checkedAt",
   LICENSE_PLAN: "license.plan",
+  DATE_FORMAT: "workshop.dateFormat",
+  TIME_FORMAT: "workshop.timeFormat",
+  TIMEZONE: "workshop.timezone",
 } as const;
 
 export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS];

+ 80 - 0
src/features/team/Actions/acceptInvitation.ts

@@ -0,0 +1,80 @@
+"use server";
+
+import { headers, cookies } from "next/headers";
+import { auth } from "@/lib/auth";
+import { db } from "@/lib/db";
+import { acceptInvitationSchema } from "../Schema/teamSchema";
+
+export async function acceptInvitation(input: unknown) {
+  try {
+    const data = acceptInvitationSchema.parse(input);
+
+    const session = await auth.api.getSession({ headers: await headers() });
+    if (!session?.user?.id) {
+      return { success: false, error: "You must be signed in to accept an invitation" };
+    }
+
+    const invitation = await db.teamInvitation.findUnique({
+      where: { token: data.token },
+    });
+
+    if (!invitation) {
+      return { success: false, error: "Invitation not found" };
+    }
+
+    if (invitation.status !== "pending") {
+      return { success: false, error: "This invitation is no longer valid" };
+    }
+
+    if (invitation.expiresAt < new Date()) {
+      return { success: false, error: "This invitation has expired" };
+    }
+
+    if (invitation.email !== session.user.email) {
+      return { success: false, error: "This invitation was sent to a different email address" };
+    }
+
+    // Check if already a member
+    const existingMembership = await db.organizationMember.findFirst({
+      where: { userId: session.user.id, organizationId: invitation.organizationId },
+    });
+    if (existingMembership) {
+      // Mark invitation as accepted even if already a member
+      await db.teamInvitation.update({
+        where: { id: invitation.id },
+        data: { status: "accepted" },
+      });
+      return { success: true, data: { accepted: true } };
+    }
+
+    // Create membership and mark invitation as accepted
+    await db.$transaction([
+      db.organizationMember.create({
+        data: {
+          userId: session.user.id,
+          organizationId: invitation.organizationId,
+          role: invitation.role,
+        },
+      }),
+      db.teamInvitation.update({
+        where: { id: invitation.id },
+        data: { status: "accepted" },
+      }),
+    ]);
+
+    // Set the active org cookie
+    const cookieStore = await cookies();
+    cookieStore.set("active-org-id", invitation.organizationId, {
+      httpOnly: true,
+      sameSite: "lax",
+      path: "/",
+      maxAge: 60 * 60 * 24 * 365,
+    });
+
+    return { success: true, data: { accepted: true } };
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "An unexpected error occurred";
+    console.error("[acceptInvitation] Error:", message);
+    return { success: false, error: message };
+  }
+}

+ 33 - 0
src/features/team/Actions/cancelInvitation.ts

@@ -0,0 +1,33 @@
+"use server";
+
+import { db } from "@/lib/db";
+import { withAuth } from "@/lib/with-auth";
+import { cancelInvitationSchema } from "../Schema/teamSchema";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { revalidatePath } from "next/cache";
+
+export async function cancelInvitation(input: unknown) {
+  return withAuth(async ({ userId, organizationId }) => {
+    const data = cancelInvitationSchema.parse(input);
+
+    // Verify caller is owner or admin
+    const membership = await db.organizationMember.findFirst({
+      where: { userId, organizationId },
+    });
+    if (!membership) throw new Error("You don't belong to an organization");
+    if (membership.role === "member") throw new Error("Only owners and admins can cancel invitations");
+
+    const invitation = await db.teamInvitation.findFirst({
+      where: { id: data.invitationId, organizationId, status: "pending" },
+    });
+    if (!invitation) throw new Error("Invitation not found");
+
+    await db.teamInvitation.update({
+      where: { id: data.invitationId },
+      data: { status: "cancelled" },
+    });
+
+    revalidatePath("/settings/team");
+    return { cancelled: true };
+  }, { requiredPermissions: [{ action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS }] });
+}

+ 27 - 0
src/features/team/Actions/getPendingInvitations.ts

@@ -0,0 +1,27 @@
+"use server";
+
+import { db } from "@/lib/db";
+import { withAuth } from "@/lib/with-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+
+export async function getPendingInvitations() {
+  return withAuth(async ({ organizationId }) => {
+    const invitations = await db.teamInvitation.findMany({
+      where: {
+        organizationId,
+        status: "pending",
+        expiresAt: { gt: new Date() },
+      },
+      orderBy: { createdAt: "desc" },
+      select: {
+        id: true,
+        email: true,
+        role: true,
+        createdAt: true,
+        expiresAt: true,
+      },
+    });
+
+    return invitations;
+  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SETTINGS }] });
+}

+ 80 - 0
src/features/team/Actions/sendInvitation.ts

@@ -0,0 +1,80 @@
+"use server";
+
+import { randomUUID } from "crypto";
+import { db } from "@/lib/db";
+import { withAuth } from "@/lib/with-auth";
+import { sendInvitationSchema } from "../Schema/teamSchema";
+import { sendMail, getFromAddress } from "@/lib/email";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { revalidatePath } from "next/cache";
+
+export async function sendInvitation(input: unknown) {
+  return withAuth(async ({ userId, organizationId }) => {
+    const data = sendInvitationSchema.parse(input);
+
+    // Verify caller is owner or admin
+    const membership = await db.organizationMember.findFirst({
+      where: { userId, organizationId },
+      include: { organization: true },
+    });
+    if (!membership) throw new Error("You don't belong to an organization");
+    if (membership.role === "member") throw new Error("Only owners and admins can invite members");
+
+    // Check for existing pending invitation
+    const existing = await db.teamInvitation.findFirst({
+      where: {
+        email: data.email,
+        organizationId,
+        status: "pending",
+      },
+    });
+    if (existing) throw new Error("An invitation has already been sent to this email");
+
+    // Create invitation with 7-day expiry
+    const token = randomUUID();
+    const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
+
+    await db.teamInvitation.create({
+      data: {
+        email: data.email,
+        role: data.role,
+        token,
+        expiresAt,
+        organizationId,
+        invitedById: userId,
+      },
+    });
+
+    const from = await getFromAddress();
+
+    const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
+    const signupUrl = `${baseUrl}/auth/sign-up?invite=${token}`;
+
+    await sendMail({
+      from,
+      to: data.email,
+      subject: `You've been invited to join ${membership.organization.name} on Torqvoice`,
+      html: `
+        <div style="font-family: sans-serif; max-width: 480px; margin: 0 auto;">
+          <h2>Team Invitation</h2>
+          <p>You've been invited to join <strong>${membership.organization.name}</strong> on Torqvoice as a <strong>${data.role}</strong>.</p>
+          <p>Click the button below to create your account and join the team:</p>
+          <div style="margin: 24px 0;">
+            <a href="${signupUrl}" style="display: inline-block; padding: 12px 24px; background-color: #171717; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
+              Accept Invitation
+            </a>
+          </div>
+          <p style="color: #6b7280; font-size: 14px;">This invitation expires in 7 days.</p>
+          <hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
+          <p style="color: #6b7280; font-size: 12px;">
+            If the button doesn't work, copy and paste this URL into your browser:<br/>
+            <a href="${signupUrl}" style="color: #6b7280;">${signupUrl}</a>
+          </p>
+        </div>
+      `,
+    });
+
+    revalidatePath("/settings/team");
+    return { invited: true, pending: true };
+  }, { requiredPermissions: [{ action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS }] });
+}

+ 1 - 1
src/features/team/Actions/teamActions.ts

@@ -109,7 +109,7 @@ export async function inviteMember(input: unknown) {
     const invitedUser = await db.user.findFirst({
       where: { email: data.email },
     });
-    if (!invitedUser) throw new Error("No user found with that email. They must sign up first.");
+    if (!invitedUser) return { invited: false, userNotFound: true };
 
     // Check if already a member of this org
     const existingMembership = await db.organizationMember.findFirst({

+ 13 - 0
src/features/team/Schema/teamSchema.ts

@@ -46,3 +46,16 @@ export const assignRoleSchema = z.object({
   memberId: z.string(),
   roleId: z.string().nullable(),
 });
+
+export const sendInvitationSchema = z.object({
+  email: z.string().email(),
+  role: z.enum(["admin", "member"]).default("member"),
+});
+
+export const cancelInvitationSchema = z.object({
+  invitationId: z.string(),
+});
+
+export const acceptInvitationSchema = z.object({
+  token: z.string(),
+});

+ 3 - 3
src/features/vehicles/Actions/createDraftServiceRecord.ts

@@ -4,7 +4,7 @@ import { db } from "@/lib/db";
 import { withAuth } from "@/lib/with-auth";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
 
-export async function createDraftServiceRecord(vehicleId: string) {
+export async function createDraftServiceRecord(vehicleId: string, serviceDate?: Date) {
   return withAuth(
     async ({ organizationId }) => {
       const vehicle = await db.vehicle.findFirst({
@@ -70,11 +70,11 @@ export async function createDraftServiceRecord(vehicleId: string) {
         data: {
           title: "New Service Record",
           type: "maintenance",
-          status: "in_progress",
+          status: "pending",
           vehicleId,
           shopName,
           invoiceNumber,
-          serviceDate: now,
+          serviceDate: serviceDate ?? now,
         },
       });
 

+ 10 - 13
src/features/vehicles/Components/InvoicePDF.tsx

@@ -1,5 +1,5 @@
 import { Document, Page, Text, View, StyleSheet, Image } from '@react-pdf/renderer'
-import { formatCurrency, getCurrencySymbol } from '@/lib/format'
+import { formatCurrency, getCurrencySymbol, formatDateForPdf, DEFAULT_DATE_FORMAT } from '@/lib/format'
 
 interface TemplateConfig {
   primaryColor?: string
@@ -191,6 +191,8 @@ interface InvoiceSettingsProps {
   dueDays?: number
   currencyCode?: string
   unitSystem?: string
+  dateFormat?: string
+  timezone?: string
 }
 
 interface PaymentSummary {
@@ -245,22 +247,17 @@ export function InvoicePDF({
   const laborSubtotal = data.laborItems.reduce((sum, l) => sum + l.total, 0)
   const displayTotal = data.totalAmount > 0 ? data.totalAmount : data.cost
   const invoiceNum = data.invoiceNumber || `INV-${data.id.slice(-8).toUpperCase()}`
-  const serviceDate = new Date(data.serviceDate).toLocaleDateString('en-US', {
-    year: 'numeric',
-    month: 'long',
-    day: 'numeric',
-  })
+  const df = invoiceSettings?.dateFormat || DEFAULT_DATE_FORMAT
+  const tz = invoiceSettings?.timezone || undefined
+  const serviceDate = formatDateForPdf(data.serviceDate, df, tz)
 
   const dueDays = invoiceSettings?.dueDays || 0
   const dueDate =
     dueDays > 0
-      ? new Date(new Date(data.serviceDate).getTime() + dueDays * 86400000).toLocaleDateString(
-          'en-US',
-          {
-            year: 'numeric',
-            month: 'long',
-            day: 'numeric',
-          }
+      ? formatDateForPdf(
+          new Date(new Date(data.serviceDate).getTime() + dueDays * 86400000),
+          df,
+          tz,
         )
       : null
 

+ 1 - 2
src/features/vehicles/Components/ReminderForm.tsx

@@ -16,7 +16,6 @@ import { useGlassModal } from "@/components/glass-modal";
 import { toast } from "sonner";
 import { createReminder, updateReminder } from "../Actions/reminderActions";
 import { Loader2 } from "lucide-react";
-import { format } from "date-fns";
 
 interface ReminderData {
   id: string;
@@ -48,7 +47,7 @@ export function ReminderForm({ vehicleId, open, onOpenChange, reminder }: Remind
     if (open && reminder) {
       setTitle(reminder.title);
       setDescription(reminder.description || "");
-      setDueDate(reminder.dueDate ? format(new Date(reminder.dueDate), "yyyy-MM-dd") : "");
+      setDueDate(reminder.dueDate ? new Date(reminder.dueDate).toISOString().split("T")[0] : "");
       setDueMileage(reminder.dueMileage ? String(reminder.dueMileage) : "");
     } else if (open) {
       setTitle("");

+ 14 - 39
src/lib/auth.ts

@@ -16,46 +16,11 @@ export const auth = betterAuth({
   emailAndPassword: {
     enabled: true,
     sendResetPassword: async ({ user, url }) => {
-      const { SYSTEM_SETTING_KEYS } = await import(
-        "@/features/admin/Schema/systemSettingsSchema"
-      );
+      const { sendMail, getFromAddress } = await import("@/lib/email");
+      const from = await getFromAddress();
 
-      // Determine provider and get from address
-      const providerSetting = await db.systemSetting.findUnique({
-        where: { key: SYSTEM_SETTING_KEYS.EMAIL_PROVIDER },
-      });
-      const provider =
-        providerSetting?.value === "resend" ? "resend" : "smtp";
-
-      let fromEmail: string;
-      let fromName: string;
-
-      if (provider === "resend") {
-        const emailSetting = await db.systemSetting.findUnique({
-          where: { key: SYSTEM_SETTING_KEYS.RESEND_FROM_EMAIL },
-        });
-        const nameSetting = await db.systemSetting.findUnique({
-          where: { key: SYSTEM_SETTING_KEYS.RESEND_FROM_NAME },
-        });
-        fromEmail = emailSetting?.value || "noreply@example.com";
-        fromName = nameSetting?.value || "Torqvoice";
-      } else {
-        const emailSetting = await db.systemSetting.findUnique({
-          where: { key: SYSTEM_SETTING_KEYS.SMTP_FROM_EMAIL },
-        });
-        const nameSetting = await db.systemSetting.findUnique({
-          where: { key: SYSTEM_SETTING_KEYS.SMTP_FROM_NAME },
-        });
-        fromEmail =
-          emailSetting?.value ||
-          process.env.SMTP_FROM_EMAIL ||
-          "noreply@example.com";
-        fromName = nameSetting?.value || "Torqvoice";
-      }
-
-      const { sendMail } = await import("@/lib/email");
       await sendMail({
-        from: `${fromName} <${fromEmail}>`,
+        from,
         to: user.email,
         subject: "Reset your Torqvoice password",
         html: `
@@ -99,7 +64,17 @@ export const auth = betterAuth({
             where: { key: "registration.disabled" },
           });
           if (setting?.value === "true") {
-            return false;
+            // Allow registration if there's a pending invitation for this email
+            const invitation = await db.teamInvitation.findFirst({
+              where: {
+                email: user.email,
+                status: "pending",
+                expiresAt: { gt: new Date() },
+              },
+            });
+            if (!invitation) {
+              return false;
+            }
           }
           return { data: user };
         },

+ 531 - 64
src/lib/email.ts

@@ -1,7 +1,21 @@
 import nodemailer from "nodemailer";
 import { Resend } from "resend";
+import { ServerClient as PostmarkClient } from "postmark";
+import Mailgun from "mailgun.js";
+import FormData from "form-data";
+import sgMail, { type MailDataRequired } from "@sendgrid/mail";
+import { SESClient, SendRawEmailCommand } from "@aws-sdk/client-ses";
 import { db } from "./db";
 import { SYSTEM_SETTING_KEYS } from "@/features/admin/Schema/systemSettingsSchema";
+import { ORG_EMAIL_KEYS } from "@/features/email/Schema/emailSettingsSchema";
+
+export type EmailProvider =
+  | "smtp"
+  | "resend"
+  | "postmark"
+  | "mailgun"
+  | "sendgrid"
+  | "ses";
 
 export interface SendMailOptions {
   from: string;
@@ -14,89 +28,184 @@ export interface SendMailOptions {
   }[];
 }
 
-async function getEmailProvider(): Promise<"smtp" | "resend"> {
+// ─── Settings map helper ────────────────────────────────────────────────────
+
+type SettingsMap = Map<string, string>;
+
+async function getSystemSettings(keys: string[]): Promise<SettingsMap> {
+  const rows = await db.systemSetting.findMany({
+    where: { key: { in: keys } },
+  });
+  return new Map(rows.map((r) => [r.key, r.value]));
+}
+
+async function getOrgSettings(
+  organizationId: string,
+  keys: string[],
+): Promise<SettingsMap> {
+  const rows = await db.appSetting.findMany({
+    where: { organizationId, key: { in: keys } },
+  });
+  return new Map(rows.map((r) => [r.key, r.value]));
+}
+
+// ─── System-level helpers (read from SystemSetting + env) ───────────────────
+
+async function getEmailProvider(): Promise<EmailProvider> {
   const setting = await db.systemSetting.findUnique({
     where: { key: SYSTEM_SETTING_KEYS.EMAIL_PROVIDER },
   });
-  return setting?.value === "resend" ? "resend" : "smtp";
+  const value = setting?.value;
+  if (
+    value === "resend" ||
+    value === "postmark" ||
+    value === "mailgun" ||
+    value === "sendgrid" ||
+    value === "ses"
+  ) {
+    return value;
+  }
+  return "smtp";
 }
 
-async function getSmtpSettings() {
-  const keys = [
-    SYSTEM_SETTING_KEYS.SMTP_HOST,
-    SYSTEM_SETTING_KEYS.SMTP_PORT,
-    SYSTEM_SETTING_KEYS.SMTP_USER,
-    SYSTEM_SETTING_KEYS.SMTP_PASS,
-    SYSTEM_SETTING_KEYS.SMTP_SECURE,
-    SYSTEM_SETTING_KEYS.SMTP_REJECT_UNAUTHORIZED,
-    SYSTEM_SETTING_KEYS.SMTP_REQUIRE_TLS,
-  ];
+export async function getFromAddress(): Promise<string> {
+  const provider = await getEmailProvider();
+
+  const keyMap: Record<EmailProvider, { email: string; name: string }> = {
+    smtp: {
+      email: SYSTEM_SETTING_KEYS.SMTP_FROM_EMAIL,
+      name: SYSTEM_SETTING_KEYS.SMTP_FROM_NAME,
+    },
+    resend: {
+      email: SYSTEM_SETTING_KEYS.RESEND_FROM_EMAIL,
+      name: SYSTEM_SETTING_KEYS.RESEND_FROM_NAME,
+    },
+    postmark: {
+      email: SYSTEM_SETTING_KEYS.POSTMARK_FROM_EMAIL,
+      name: SYSTEM_SETTING_KEYS.POSTMARK_FROM_NAME,
+    },
+    mailgun: {
+      email: SYSTEM_SETTING_KEYS.MAILGUN_FROM_EMAIL,
+      name: SYSTEM_SETTING_KEYS.MAILGUN_FROM_NAME,
+    },
+    sendgrid: {
+      email: SYSTEM_SETTING_KEYS.SENDGRID_FROM_EMAIL,
+      name: SYSTEM_SETTING_KEYS.SENDGRID_FROM_NAME,
+    },
+    ses: {
+      email: SYSTEM_SETTING_KEYS.SES_FROM_EMAIL,
+      name: SYSTEM_SETTING_KEYS.SES_FROM_NAME,
+    },
+  };
 
+  const keys = keyMap[provider];
   const rows = await db.systemSetting.findMany({
-    where: { key: { in: keys } },
+    where: { key: { in: [keys.email, keys.name] } },
   });
-
   const map = new Map(rows.map((r) => [r.key, r.value]));
-  return map;
+
+  const fromEmail =
+    map.get(keys.email) || process.env.SMTP_FROM_EMAIL || "noreply@example.com";
+  const fromName = map.get(keys.name) || "Torqvoice";
+
+  return `${fromName} <${fromEmail}>`;
 }
 
-async function getTransporter() {
-  // Read from DB first, fall back to env vars
-  const settings = await getSmtpSettings();
+// ─── SMTP ──────────────────────────────────────────────────────────────────
+
+interface SmtpConfig {
+  host: string;
+  port: number;
+  user?: string;
+  pass?: string;
+  secure: boolean;
+  rejectUnauthorized: boolean;
+  requireTls: boolean;
+}
 
-  const host =
-    settings.get(SYSTEM_SETTING_KEYS.SMTP_HOST) || process.env.SMTP_HOST;
+function buildSmtpConfig(
+  settings: SettingsMap,
+  keys: {
+    host: string;
+    port: string;
+    user: string;
+    pass: string;
+    secure: string;
+    rejectUnauthorized: string;
+    requireTls: string;
+  },
+  useEnvFallback: boolean,
+): SmtpConfig {
+  const host = settings.get(keys.host) || (useEnvFallback ? process.env.SMTP_HOST : undefined);
   const port =
-    Number(settings.get(SYSTEM_SETTING_KEYS.SMTP_PORT)) ||
-    Number(process.env.SMTP_PORT) ||
+    Number(settings.get(keys.port)) ||
+    (useEnvFallback ? Number(process.env.SMTP_PORT) : 0) ||
     587;
-  const user =
-    settings.get(SYSTEM_SETTING_KEYS.SMTP_USER) || process.env.SMTP_USER;
-  const pass =
-    settings.get(SYSTEM_SETTING_KEYS.SMTP_PASS) || process.env.SMTP_PASS;
+  const user = settings.get(keys.user) || (useEnvFallback ? process.env.SMTP_USER : undefined);
+  const pass = settings.get(keys.pass) || (useEnvFallback ? process.env.SMTP_PASS : undefined);
 
-  const secureSetting = settings.get(SYSTEM_SETTING_KEYS.SMTP_SECURE);
+  const secureSetting = settings.get(keys.secure);
   const secure =
     secureSetting !== undefined
       ? secureSetting === "true"
-      : process.env.SMTP_SECURE === "true";
+      : useEnvFallback
+        ? process.env.SMTP_SECURE === "true"
+        : false;
 
-  const rejectSetting = settings.get(
-    SYSTEM_SETTING_KEYS.SMTP_REJECT_UNAUTHORIZED,
-  );
+  const rejectSetting = settings.get(keys.rejectUnauthorized);
   const rejectUnauthorized =
     rejectSetting !== undefined
       ? rejectSetting !== "false"
-      : process.env.SMTP_REJECT_UNAUTHORIZED !== "false";
+      : useEnvFallback
+        ? process.env.SMTP_REJECT_UNAUTHORIZED !== "false"
+        : true;
 
-  const requireTlsSetting = settings.get(SYSTEM_SETTING_KEYS.SMTP_REQUIRE_TLS);
+  const requireTlsSetting = settings.get(keys.requireTls);
   const requireTls = requireTlsSetting === "true";
 
   if (!host) {
     throw new Error(
-      "SMTP is not configured. Configure SMTP in Admin Settings or add SMTP_HOST to your .env file.",
+      "SMTP is not configured. Configure SMTP in your email settings.",
     );
   }
 
+  return { host, port, user, pass, secure, rejectUnauthorized, requireTls };
+}
+
+function createSmtpTransporter(config: SmtpConfig) {
   return nodemailer.createTransport({
-    host,
-    port,
-    secure,
-    auth: user
+    host: config.host,
+    port: config.port,
+    secure: config.secure,
+    auth: config.user
       ? {
-          user,
-          pass,
+          user: config.user,
+          pass: config.pass,
         }
       : undefined,
     tls: {
-      rejectUnauthorized,
+      rejectUnauthorized: config.rejectUnauthorized,
     },
-    requireTLS: requireTls,
+    requireTLS: config.requireTls,
   });
 }
 
-async function sendViaSmtp(options: SendMailOptions) {
-  const transporter = await getTransporter();
+async function sendViaSmtpWithSettings(
+  options: SendMailOptions,
+  settings: SettingsMap,
+  keys: {
+    host: string;
+    port: string;
+    user: string;
+    pass: string;
+    secure: string;
+    rejectUnauthorized: string;
+    requireTls: string;
+  },
+  useEnvFallback: boolean,
+) {
+  const config = buildSmtpConfig(settings, keys, useEnvFallback);
+  const transporter = createSmtpTransporter(config);
   await transporter.sendMail({
     from: options.from,
     to: options.to,
@@ -109,34 +218,205 @@ async function sendViaSmtp(options: SendMailOptions) {
   });
 }
 
-async function getResendSettings() {
+async function sendViaSmtp(options: SendMailOptions) {
   const keys = [
-    SYSTEM_SETTING_KEYS.RESEND_API_KEY,
-    SYSTEM_SETTING_KEYS.RESEND_FROM_EMAIL,
-    SYSTEM_SETTING_KEYS.RESEND_FROM_NAME,
+    SYSTEM_SETTING_KEYS.SMTP_HOST,
+    SYSTEM_SETTING_KEYS.SMTP_PORT,
+    SYSTEM_SETTING_KEYS.SMTP_USER,
+    SYSTEM_SETTING_KEYS.SMTP_PASS,
+    SYSTEM_SETTING_KEYS.SMTP_SECURE,
+    SYSTEM_SETTING_KEYS.SMTP_REJECT_UNAUTHORIZED,
+    SYSTEM_SETTING_KEYS.SMTP_REQUIRE_TLS,
   ];
+  const settings = await getSystemSettings(keys);
+  await sendViaSmtpWithSettings(options, settings, {
+    host: SYSTEM_SETTING_KEYS.SMTP_HOST,
+    port: SYSTEM_SETTING_KEYS.SMTP_PORT,
+    user: SYSTEM_SETTING_KEYS.SMTP_USER,
+    pass: SYSTEM_SETTING_KEYS.SMTP_PASS,
+    secure: SYSTEM_SETTING_KEYS.SMTP_SECURE,
+    rejectUnauthorized: SYSTEM_SETTING_KEYS.SMTP_REJECT_UNAUTHORIZED,
+    requireTls: SYSTEM_SETTING_KEYS.SMTP_REQUIRE_TLS,
+  }, true);
+}
 
-  const rows = await db.systemSetting.findMany({
-    where: { key: { in: keys } },
-  });
+// ─── Resend ────────────────────────────────────────────────────────────────
 
-  const map = new Map(rows.map((r) => [r.key, r.value]));
-  return map;
+async function sendViaResendWithSettings(
+  options: SendMailOptions,
+  settings: SettingsMap,
+  apiKeyField: string,
+) {
+  const apiKey = settings.get(apiKeyField);
+  if (!apiKey) {
+    throw new Error("Resend is not configured. Add your Resend API key.");
+  }
+
+  const resend = new Resend(apiKey);
+  await resend.emails.send({
+    from: options.from,
+    to: options.to,
+    subject: options.subject,
+    html: options.html,
+    attachments: options.attachments?.map((a) => ({
+      filename: a.filename,
+      content: a.content,
+    })),
+  });
 }
 
 async function sendViaResend(options: SendMailOptions) {
-  const settings = await getResendSettings();
-  const apiKey = settings.get(SYSTEM_SETTING_KEYS.RESEND_API_KEY);
+  const settings = await getSystemSettings([SYSTEM_SETTING_KEYS.RESEND_API_KEY]);
+  await sendViaResendWithSettings(options, settings, SYSTEM_SETTING_KEYS.RESEND_API_KEY);
+}
+
+// ─── Postmark ──────────────────────────────────────────────────────────────
 
+async function sendViaPostmarkWithSettings(
+  options: SendMailOptions,
+  settings: SettingsMap,
+  apiKeyField: string,
+) {
+  const apiKey = settings.get(apiKeyField);
   if (!apiKey) {
-    throw new Error(
-      "Resend is not configured. Add your Resend API key in Admin Settings.",
-    );
+    throw new Error("Postmark is not configured. Add your Server Token.");
   }
 
-  const resend = new Resend(apiKey);
+  const client = new PostmarkClient(apiKey);
 
-  await resend.emails.send({
+  if (options.attachments?.length) {
+    await client.sendEmail({
+      From: options.from,
+      To: options.to,
+      Subject: options.subject,
+      HtmlBody: options.html,
+      Attachments: options.attachments.map((a) => ({
+        Name: a.filename,
+        Content: a.content.toString("base64"),
+        ContentType: "application/octet-stream",
+        ContentID: "",
+      })),
+    });
+  } else {
+    await client.sendEmail({
+      From: options.from,
+      To: options.to,
+      Subject: options.subject,
+      HtmlBody: options.html,
+    });
+  }
+}
+
+async function sendViaPostmark(options: SendMailOptions) {
+  const settings = await getSystemSettings([SYSTEM_SETTING_KEYS.POSTMARK_API_KEY]);
+  await sendViaPostmarkWithSettings(options, settings, SYSTEM_SETTING_KEYS.POSTMARK_API_KEY);
+}
+
+// ─── Mailgun ───────────────────────────────────────────────────────────────
+
+async function sendViaMailgunWithSettings(
+  options: SendMailOptions,
+  settings: SettingsMap,
+  keys: { apiKey: string; domain: string; region: string },
+) {
+  const apiKey = settings.get(keys.apiKey);
+  const domain = settings.get(keys.domain);
+  const region = settings.get(keys.region) || "us";
+
+  if (!apiKey || !domain) {
+    throw new Error("Mailgun is not configured. Add your API key and domain.");
+  }
+
+  const mailgun = new Mailgun(FormData);
+  const mg = mailgun.client({
+    username: "api",
+    key: apiKey,
+    url: region === "eu" ? "https://api.eu.mailgun.net" : undefined,
+  });
+
+  await mg.messages.create(domain, {
+    from: options.from,
+    to: [options.to],
+    subject: options.subject,
+    html: options.html,
+    ...(options.attachments?.length && {
+      attachment: options.attachments.map((a) => ({
+        filename: a.filename,
+        data: a.content,
+      })),
+    }),
+  });
+}
+
+async function sendViaMailgun(options: SendMailOptions) {
+  const keys = [
+    SYSTEM_SETTING_KEYS.MAILGUN_API_KEY,
+    SYSTEM_SETTING_KEYS.MAILGUN_DOMAIN,
+    SYSTEM_SETTING_KEYS.MAILGUN_REGION,
+  ];
+  const settings = await getSystemSettings(keys);
+  await sendViaMailgunWithSettings(options, settings, {
+    apiKey: SYSTEM_SETTING_KEYS.MAILGUN_API_KEY,
+    domain: SYSTEM_SETTING_KEYS.MAILGUN_DOMAIN,
+    region: SYSTEM_SETTING_KEYS.MAILGUN_REGION,
+  });
+}
+
+// ─── SendGrid ──────────────────────────────────────────────────────────────
+
+async function sendViaSendGridWithSettings(
+  options: SendMailOptions,
+  settings: SettingsMap,
+  apiKeyField: string,
+) {
+  const apiKey = settings.get(apiKeyField);
+  if (!apiKey) {
+    throw new Error("SendGrid is not configured. Add your API key.");
+  }
+
+  sgMail.setApiKey(apiKey);
+
+  const msg: MailDataRequired = {
+    from: options.from,
+    to: options.to,
+    subject: options.subject,
+    html: options.html,
+  };
+
+  if (options.attachments?.length) {
+    msg.attachments = options.attachments.map((a) => ({
+      filename: a.filename,
+      content: a.content.toString("base64"),
+      type: "application/octet-stream",
+      disposition: "attachment" as const,
+    }));
+  }
+
+  await sgMail.send(msg);
+}
+
+async function sendViaSendGrid(options: SendMailOptions) {
+  const settings = await getSystemSettings([SYSTEM_SETTING_KEYS.SENDGRID_API_KEY]);
+  await sendViaSendGridWithSettings(options, settings, SYSTEM_SETTING_KEYS.SENDGRID_API_KEY);
+}
+
+// ─── Amazon SES ────────────────────────────────────────────────────────────
+
+async function sendViaSesWithSettings(
+  options: SendMailOptions,
+  settings: SettingsMap,
+  keys: { accessKeyId: string; secretAccessKey: string; region: string },
+) {
+  const accessKeyId = settings.get(keys.accessKeyId);
+  const secretAccessKey = settings.get(keys.secretAccessKey);
+  const region = settings.get(keys.region) || "us-east-1";
+
+  if (!accessKeyId || !secretAccessKey) {
+    throw new Error("Amazon SES is not configured. Add your credentials.");
+  }
+
+  const transporter = nodemailer.createTransport({ streamTransport: true });
+  const info = await transporter.sendMail({
     from: options.from,
     to: options.to,
     subject: options.subject,
@@ -146,14 +426,201 @@ async function sendViaResend(options: SendMailOptions) {
       content: a.content,
     })),
   });
+
+  const rawMessage = Buffer.isBuffer(info.message)
+    ? info.message
+    : await streamToBuffer(info.message);
+
+  const client = new SESClient({
+    region,
+    credentials: { accessKeyId, secretAccessKey },
+  });
+
+  await client.send(
+    new SendRawEmailCommand({ RawMessage: { Data: rawMessage } }),
+  );
 }
 
+async function sendViaSes(options: SendMailOptions) {
+  const keys = [
+    SYSTEM_SETTING_KEYS.SES_ACCESS_KEY_ID,
+    SYSTEM_SETTING_KEYS.SES_SECRET_ACCESS_KEY,
+    SYSTEM_SETTING_KEYS.SES_REGION,
+  ];
+  const settings = await getSystemSettings(keys);
+  await sendViaSesWithSettings(options, settings, {
+    accessKeyId: SYSTEM_SETTING_KEYS.SES_ACCESS_KEY_ID,
+    secretAccessKey: SYSTEM_SETTING_KEYS.SES_SECRET_ACCESS_KEY,
+    region: SYSTEM_SETTING_KEYS.SES_REGION,
+  });
+}
+
+function streamToBuffer(
+  stream: NodeJS.ReadableStream,
+): Promise<Uint8Array> {
+  return new Promise((resolve, reject) => {
+    const chunks: Buffer[] = [];
+    stream.on("data", (chunk: Buffer) => chunks.push(chunk));
+    stream.on("end", () => resolve(Buffer.concat(chunks)));
+    stream.on("error", reject);
+  });
+}
+
+// ─── Provider dispatch (shared by both system and org) ──────────────────────
+
+function sendWithProvider(
+  provider: EmailProvider,
+  options: SendMailOptions,
+  settings: SettingsMap,
+  keySet: "system" | "org",
+) {
+  if (keySet === "org") {
+    switch (provider) {
+      case "smtp":
+        return sendViaSmtpWithSettings(options, settings, {
+          host: ORG_EMAIL_KEYS.EMAIL_SMTP_HOST,
+          port: ORG_EMAIL_KEYS.EMAIL_SMTP_PORT,
+          user: ORG_EMAIL_KEYS.EMAIL_SMTP_USER,
+          pass: ORG_EMAIL_KEYS.EMAIL_SMTP_PASS,
+          secure: ORG_EMAIL_KEYS.EMAIL_SMTP_SECURE,
+          rejectUnauthorized: ORG_EMAIL_KEYS.EMAIL_SMTP_REJECT_UNAUTHORIZED,
+          requireTls: ORG_EMAIL_KEYS.EMAIL_SMTP_REQUIRE_TLS,
+        }, false);
+      case "resend":
+        return sendViaResendWithSettings(options, settings, ORG_EMAIL_KEYS.EMAIL_RESEND_API_KEY);
+      case "postmark":
+        return sendViaPostmarkWithSettings(options, settings, ORG_EMAIL_KEYS.EMAIL_POSTMARK_API_KEY);
+      case "mailgun":
+        return sendViaMailgunWithSettings(options, settings, {
+          apiKey: ORG_EMAIL_KEYS.EMAIL_MAILGUN_API_KEY,
+          domain: ORG_EMAIL_KEYS.EMAIL_MAILGUN_DOMAIN,
+          region: ORG_EMAIL_KEYS.EMAIL_MAILGUN_REGION,
+        });
+      case "sendgrid":
+        return sendViaSendGridWithSettings(options, settings, ORG_EMAIL_KEYS.EMAIL_SENDGRID_API_KEY);
+      case "ses":
+        return sendViaSesWithSettings(options, settings, {
+          accessKeyId: ORG_EMAIL_KEYS.EMAIL_SES_ACCESS_KEY_ID,
+          secretAccessKey: ORG_EMAIL_KEYS.EMAIL_SES_SECRET_ACCESS_KEY,
+          region: ORG_EMAIL_KEYS.EMAIL_SES_REGION,
+        });
+    }
+  }
+
+  // system keySet
+  switch (provider) {
+    case "resend":
+      return sendViaResend(options);
+    case "postmark":
+      return sendViaPostmark(options);
+    case "mailgun":
+      return sendViaMailgun(options);
+    case "sendgrid":
+      return sendViaSendGrid(options);
+    case "ses":
+      return sendViaSes(options);
+    default:
+      return sendViaSmtp(options);
+  }
+}
+
+// ─── System-level router (unchanged behavior) ──────────────────────────────
+
 export async function sendMail(options: SendMailOptions) {
   const provider = await getEmailProvider();
+  await sendWithProvider(provider, options, new Map(), "system");
+}
 
-  if (provider === "resend") {
-    await sendViaResend(options);
-  } else {
-    await sendViaSmtp(options);
+// ─── Org-level email ────────────────────────────────────────────────────────
+
+async function getOrgEmailProvider(
+  organizationId: string,
+): Promise<EmailProvider | null> {
+  const setting = await db.appSetting.findUnique({
+    where: {
+      organizationId_key: {
+        organizationId,
+        key: ORG_EMAIL_KEYS.EMAIL_PROVIDER,
+      },
+    },
+  });
+  const value = setting?.value;
+  if (
+    value === "smtp" ||
+    value === "resend" ||
+    value === "postmark" ||
+    value === "mailgun" ||
+    value === "sendgrid" ||
+    value === "ses"
+  ) {
+    return value;
   }
+  return null;
+}
+
+export async function getOrgFromAddress(
+  organizationId: string,
+): Promise<string> {
+  const provider = await getOrgEmailProvider(organizationId);
+
+  if (!provider) {
+    return getFromAddress();
+  }
+
+  const keyMap: Record<EmailProvider, { email: string; name: string }> = {
+    smtp: {
+      email: ORG_EMAIL_KEYS.EMAIL_SMTP_FROM_EMAIL,
+      name: ORG_EMAIL_KEYS.EMAIL_SMTP_FROM_NAME,
+    },
+    resend: {
+      email: ORG_EMAIL_KEYS.EMAIL_RESEND_FROM_EMAIL,
+      name: ORG_EMAIL_KEYS.EMAIL_RESEND_FROM_NAME,
+    },
+    postmark: {
+      email: ORG_EMAIL_KEYS.EMAIL_POSTMARK_FROM_EMAIL,
+      name: ORG_EMAIL_KEYS.EMAIL_POSTMARK_FROM_NAME,
+    },
+    mailgun: {
+      email: ORG_EMAIL_KEYS.EMAIL_MAILGUN_FROM_EMAIL,
+      name: ORG_EMAIL_KEYS.EMAIL_MAILGUN_FROM_NAME,
+    },
+    sendgrid: {
+      email: ORG_EMAIL_KEYS.EMAIL_SENDGRID_FROM_EMAIL,
+      name: ORG_EMAIL_KEYS.EMAIL_SENDGRID_FROM_NAME,
+    },
+    ses: {
+      email: ORG_EMAIL_KEYS.EMAIL_SES_FROM_EMAIL,
+      name: ORG_EMAIL_KEYS.EMAIL_SES_FROM_NAME,
+    },
+  };
+
+  const keys = keyMap[provider];
+  const settings = await getOrgSettings(organizationId, [
+    keys.email,
+    keys.name,
+  ]);
+
+  const fromEmail = settings.get(keys.email) || "noreply@example.com";
+  const fromName = settings.get(keys.name) || "Torqvoice";
+
+  return `${fromName} <${fromEmail}>`;
+}
+
+export async function sendOrgMail(
+  organizationId: string,
+  options: SendMailOptions,
+) {
+  const provider = await getOrgEmailProvider(organizationId);
+
+  if (!provider) {
+    // Fall back to global platform email
+    await sendMail(options);
+    return;
+  }
+
+  // Load all org email settings
+  const allKeys = Object.values(ORG_EMAIL_KEYS);
+  const settings = await getOrgSettings(organizationId, allKeys);
+
+  await sendWithProvider(provider, options, settings, "org");
 }

+ 140 - 0
src/lib/format.ts

@@ -91,3 +91,143 @@ export function getCurrencySymbol(currencyCode: string = "USD"): string {
     return currencyCode;
   }
 }
+
+/**
+ * Convert a date to a specific timezone by shifting its local representation.
+ * Uses Intl.DateTimeFormat to get the date parts in the target timezone,
+ * then reconstructs a Date object whose local time matches those parts.
+ */
+function toTimezone(date: Date, timezone: string): Date {
+  try {
+    const parts = new Intl.DateTimeFormat("en-US", {
+      timeZone: timezone,
+      year: "numeric",
+      month: "2-digit",
+      day: "2-digit",
+      hour: "2-digit",
+      minute: "2-digit",
+      second: "2-digit",
+      hour12: false,
+    }).formatToParts(date);
+
+    const get = (type: Intl.DateTimeFormatPartTypes) =>
+      Number(parts.find((p) => p.type === type)?.value || 0);
+
+    return new Date(
+      get("year"),
+      get("month") - 1,
+      get("day"),
+      get("hour") === 24 ? 0 : get("hour"),
+      get("minute"),
+      get("second"),
+    );
+  } catch {
+    return date;
+  }
+}
+
+/**
+ * Default date format matching the most common existing pattern.
+ */
+export const DEFAULT_DATE_FORMAT = "MMM d, yyyy";
+export const DEFAULT_TIME_FORMAT = "12h" as const;
+
+/**
+ * Simple date-fns-style format function that handles common tokens.
+ * Tokens: yyyy, yy, MMMM, MMM, MM, dd, d, HH, hh, mm, ss, a
+ */
+function formatWithPattern(date: Date, pattern: string): string {
+  const months = [
+    "January", "February", "March", "April", "May", "June",
+    "July", "August", "September", "October", "November", "December",
+  ];
+  const monthsShort = [
+    "Jan", "Feb", "Mar", "Apr", "May", "Jun",
+    "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
+  ];
+
+  const y = date.getFullYear();
+  const M = date.getMonth();
+  const d = date.getDate();
+  const H = date.getHours();
+  const m = date.getMinutes();
+  const s = date.getSeconds();
+  const h12 = H % 12 || 12;
+  const ampm = H < 12 ? "AM" : "PM";
+
+  return pattern
+    .replace("yyyy", String(y))
+    .replace("yy", String(y).slice(-2))
+    .replace("MMMM", months[M])
+    .replace("MMM", monthsShort[M])
+    .replace("MM", String(M + 1).padStart(2, "0"))
+    .replace(/\bdd\b/, String(d).padStart(2, "0"))
+    .replace(/\bd\b/, String(d))
+    .replace("HH", String(H).padStart(2, "0"))
+    .replace("hh", String(h12).padStart(2, "0"))
+    .replace("mm", String(m).padStart(2, "0"))
+    .replace("ss", String(s).padStart(2, "0"))
+    .replace("a", ampm);
+}
+
+/**
+ * Format a date using the org date format and optional timezone.
+ */
+export function formatDate(
+  date: Date | string,
+  dateFormat: string = DEFAULT_DATE_FORMAT,
+  timezone?: string,
+): string {
+  let d = typeof date === "string" ? new Date(date) : date;
+  if (timezone) d = toTimezone(d, timezone);
+  return formatWithPattern(d, dateFormat);
+}
+
+/**
+ * Format a date and time using org settings.
+ */
+export function formatDateTime(
+  date: Date | string,
+  dateFormat: string = DEFAULT_DATE_FORMAT,
+  timeFormat: "12h" | "24h" = "12h",
+  timezone?: string,
+): string {
+  let d = typeof date === "string" ? new Date(date) : date;
+  if (timezone) d = toTimezone(d, timezone);
+  const datePart = formatWithPattern(d, dateFormat);
+  const timePart = timeFormat === "24h"
+    ? formatWithPattern(d, "HH:mm")
+    : formatWithPattern(d, "hh:mm a");
+  return `${datePart} ${timePart}`;
+}
+
+/**
+ * Format time only using org settings.
+ */
+export function formatTime(
+  date: Date | string,
+  timeFormat: "12h" | "24h" = "12h",
+  timezone?: string,
+): string {
+  let d = typeof date === "string" ? new Date(date) : date;
+  if (timezone) d = toTimezone(d, timezone);
+  return timeFormat === "24h"
+    ? formatWithPattern(d, "HH:mm")
+    : formatWithPattern(d, "hh:mm a");
+}
+
+/**
+ * Format a date for server-side PDF/invoice rendering (uses toLocaleDateString for full month names).
+ * Applies timezone if provided, otherwise UTC for server contexts.
+ */
+export function formatDateForPdf(
+  date: Date | string,
+  dateFormat: string = DEFAULT_DATE_FORMAT,
+  timezone?: string,
+): string {
+  let d = typeof date === "string" ? new Date(date) : date;
+  if (timezone) {
+    d = toTimezone(d, timezone);
+  }
+  return formatWithPattern(d, dateFormat);
+}

+ 9 - 0
src/lib/get-layout-data.ts

@@ -12,6 +12,9 @@ type AuthResult =
       role: string;
       isSuperAdmin: boolean;
       companyLogo: string | undefined;
+      dateFormat: string | undefined;
+      timeFormat: string | undefined;
+      timezone: string | undefined;
       organizations: { id: string; name: string; role: string }[];
     };
 
@@ -39,6 +42,9 @@ export async function getLayoutData(): Promise<AuthResult> {
             key: {
               in: [
                 SETTING_KEYS.COMPANY_LOGO,
+                SETTING_KEYS.DATE_FORMAT,
+                SETTING_KEYS.TIME_FORMAT,
+                SETTING_KEYS.TIMEZONE,
               ],
             },
           },
@@ -63,6 +69,9 @@ export async function getLayoutData(): Promise<AuthResult> {
     role: isSuperAdmin ? "super_admin" : (membership?.role ?? "member"),
     isSuperAdmin,
     companyLogo: orgMap.get(SETTING_KEYS.COMPANY_LOGO) || undefined,
+    dateFormat: orgMap.get(SETTING_KEYS.DATE_FORMAT) || undefined,
+    timeFormat: orgMap.get(SETTING_KEYS.TIME_FORMAT) || undefined,
+    timezone: orgMap.get(SETTING_KEYS.TIMEZONE) || undefined,
     organizations: memberships.map((m) => ({
       id: m.organization.id,
       name: m.organization.name,

+ 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;
+}

+ 20 - 0
src/lib/use-format-date.ts

@@ -0,0 +1,20 @@
+"use client";
+
+import { useDateSettings } from "@/components/date-settings-context";
+import {
+  formatDate as fmtDate,
+  formatDateTime as fmtDateTime,
+  formatTime as fmtTime,
+} from "@/lib/format";
+
+export function useFormatDate() {
+  const { dateFormat, timeFormat, timezone } = useDateSettings();
+  const tz = timezone || undefined;
+
+  return {
+    formatDate: (date: Date | string) => fmtDate(date, dateFormat, tz),
+    formatDateTime: (date: Date | string) =>
+      fmtDateTime(date, dateFormat, timeFormat, tz),
+    formatTime: (date: Date | string) => fmtTime(date, timeFormat, tz),
+  };
+}

+ 10 - 0
src/lib/with-auth.ts

@@ -1,3 +1,4 @@
+import { ZodError } from "zod";
 import { getCachedSession, getCachedMembership } from "./cached-session";
 import { db } from "./db";
 import type { PermissionInput } from "./permissions";
@@ -66,6 +67,15 @@ export async function withAuth<T>(
     });
     return { success: true, data };
   } catch (error) {
+    if (error instanceof ZodError) {
+      const messages = error.issues.map((e) => {
+        const field = e.path.join(".");
+        return field ? `${field}: ${e.message}` : e.message;
+      });
+      const message = messages.join(". ");
+      console.error("[withAuth] Validation error:", message);
+      return { success: false, error: message };
+    }
     const message =
       error instanceof Error ? error.message : "An unexpected error occurred";
     console.error("[withAuth] Error:", message);

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů