Bläddra i källkod

Add exclude checkbox to quotes and autosave for quotes & work orders (#30)

* added sorting to work order table

* qoute autosave

* work order autsave

* test
Bernt Christian Egeland 7 månader sedan
förälder
incheckning
255f048e7d
35 ändrade filer med 528 tillägg och 214 borttagningar
  1. 2 0
      messages/de/quotes.json
  2. 3 1
      messages/de/service.json
  3. 2 0
      messages/en/quotes.json
  4. 3 1
      messages/en/service.json
  5. 2 0
      messages/es/quotes.json
  6. 3 1
      messages/es/service.json
  7. 2 0
      messages/fr/quotes.json
  8. 3 1
      messages/fr/service.json
  9. 2 0
      messages/it/quotes.json
  10. 3 1
      messages/it/service.json
  11. 2 0
      messages/nb/quotes.json
  12. 3 1
      messages/nb/service.json
  13. 2 0
      messages/nl/quotes.json
  14. 3 1
      messages/nl/service.json
  15. 2 0
      messages/pl/quotes.json
  16. 3 1
      messages/pl/service.json
  17. 2 0
      messages/pt-BR/quotes.json
  18. 3 1
      messages/pt-BR/service.json
  19. 2 0
      messages/tr/quotes.json
  20. 3 1
      messages/tr/service.json
  21. 5 0
      prisma/migrations/20260302163120_exlcude_parts_option/migration.sql
  22. 6 4
      prisma/schema.prisma
  23. 1 0
      src/__tests__/features/public/quote-view.test.tsx
  24. 1 1
      src/app/(authenticated)/quotes/new/page.tsx
  25. 6 0
      src/app/(authenticated)/work-orders/page.tsx
  26. 52 6
      src/app/(authenticated)/work-orders/work-orders-client.tsx
  27. 58 40
      src/app/(public)/share/quote/[orgId]/[token]/quote-view.tsx
  28. 6 4
      src/features/quotes/Actions/quoteActions.ts
  29. 22 14
      src/features/quotes/Components/QuoteForm.tsx
  30. 75 60
      src/features/quotes/Components/QuotePDF.tsx
  31. 110 37
      src/features/quotes/Components/QuotePageClient.tsx
  32. 2 0
      src/features/quotes/Schema/quoteSchema.ts
  33. 14 1
      src/features/vehicles/Actions/serviceActions.ts
  34. 101 30
      src/features/vehicles/Components/service-page/ServicePageClient.tsx
  35. 19 7
      src/features/vehicles/Components/service-page/UnifiedServiceHeader.tsx

+ 2 - 0
messages/de/quotes.json

@@ -27,6 +27,8 @@
     "deleteTitle": "Angebot löschen",
     "deleteDescription": "Dieses Angebot wird dauerhaft gelöscht.",
     "saved": "Angebot gespeichert",
+    "unsavedChanges": "Nicht gespeicherte Änderungen",
+    "saveFirst": "Bitte speichern Sie zuerst Ihre Änderungen",
     "failedSave": "Angebot konnte nicht gespeichert werden",
     "failedDelete": "Löschen fehlgeschlagen",
     "failedPdf": "PDF konnte nicht erstellt werden",

+ 3 - 1
messages/de/service.json

@@ -20,7 +20,9 @@
     "email": "E-Mail",
     "share": "Teilen",
     "delete": "Löschen",
-    "unpaid": "Unbezahlt"
+    "unpaid": "Unbezahlt",
+    "unsavedChanges": "Nicht gespeicherte Änderungen",
+    "saved": "Gespeichert"
   },
   "basicInfo": {
     "title": "Grundlegende Informationen",

+ 2 - 0
messages/en/quotes.json

@@ -27,6 +27,8 @@
     "deleteTitle": "Delete Quote",
     "deleteDescription": "This will permanently delete this quote.",
     "saved": "Quote saved",
+    "unsavedChanges": "Unsaved changes",
+    "saveFirst": "Please save your changes first",
     "failedSave": "Failed to save quote",
     "failedDelete": "Failed to delete",
     "failedPdf": "Failed to generate PDF",

+ 3 - 1
messages/en/service.json

@@ -20,7 +20,9 @@
     "email": "Email",
     "share": "Share",
     "delete": "Delete",
-    "unpaid": "Unpaid"
+    "unpaid": "Unpaid",
+    "unsavedChanges": "Unsaved changes",
+    "saved": "Saved"
   },
   "basicInfo": {
     "title": "Basic Information",

+ 2 - 0
messages/es/quotes.json

@@ -27,6 +27,8 @@
     "deleteTitle": "Eliminar presupuesto",
     "deleteDescription": "Este presupuesto se eliminará permanentemente.",
     "saved": "Presupuesto guardado",
+    "unsavedChanges": "Cambios sin guardar",
+    "saveFirst": "Por favor, guarde sus cambios primero",
     "failedSave": "Error al guardar el presupuesto",
     "failedDelete": "Error al eliminar",
     "failedPdf": "Error al generar el PDF",

+ 3 - 1
messages/es/service.json

@@ -20,7 +20,9 @@
     "email": "Correo Electrónico",
     "share": "Compartir",
     "delete": "Eliminar",
-    "unpaid": "Sin Pagar"
+    "unpaid": "Sin Pagar",
+    "unsavedChanges": "Cambios sin guardar",
+    "saved": "Guardado"
   },
   "basicInfo": {
     "title": "Información Básica",

+ 2 - 0
messages/fr/quotes.json

@@ -27,6 +27,8 @@
     "deleteTitle": "Supprimer le devis",
     "deleteDescription": "Ce devis sera définitivement supprimé.",
     "saved": "Devis enregistré",
+    "unsavedChanges": "Modifications non enregistrées",
+    "saveFirst": "Veuillez d'abord enregistrer vos modifications",
     "failedSave": "Échec de l'enregistrement du devis",
     "failedDelete": "Échec de la suppression",
     "failedPdf": "Échec de la génération du PDF",

+ 3 - 1
messages/fr/service.json

@@ -20,7 +20,9 @@
     "email": "Email",
     "share": "Partager",
     "delete": "Supprimer",
-    "unpaid": "Non Payé"
+    "unpaid": "Non Payé",
+    "unsavedChanges": "Modifications non enregistrées",
+    "saved": "Enregistré"
   },
   "basicInfo": {
     "title": "Informations de Base",

+ 2 - 0
messages/it/quotes.json

@@ -27,6 +27,8 @@
     "deleteTitle": "Elimina preventivo",
     "deleteDescription": "Questo preventivo verrà eliminato definitivamente.",
     "saved": "Preventivo salvato",
+    "unsavedChanges": "Modifiche non salvate",
+    "saveFirst": "Salva prima le modifiche",
     "failedSave": "Salvataggio del preventivo non riuscito",
     "failedDelete": "Eliminazione non riuscita",
     "failedPdf": "Generazione PDF non riuscita",

+ 3 - 1
messages/it/service.json

@@ -20,7 +20,9 @@
     "email": "Email",
     "share": "Condividi",
     "delete": "Elimina",
-    "unpaid": "Non Pagato"
+    "unpaid": "Non Pagato",
+    "unsavedChanges": "Modifiche non salvate",
+    "saved": "Salvato"
   },
   "basicInfo": {
     "title": "Informazioni di Base",

+ 2 - 0
messages/nb/quotes.json

@@ -27,6 +27,8 @@
     "deleteTitle": "Slett tilbud",
     "deleteDescription": "Dette tilbudet vil bli permanent slettet.",
     "saved": "Tilbud lagret",
+    "unsavedChanges": "Ulagrede endringer",
+    "saveFirst": "Lagre endringene dine først",
     "failedSave": "Kunne ikke lagre tilbudet",
     "failedDelete": "Kunne ikke slette",
     "failedPdf": "Kunne ikke generere PDF",

+ 3 - 1
messages/nb/service.json

@@ -20,7 +20,9 @@
     "email": "E-post",
     "share": "Del",
     "delete": "Slett",
-    "unpaid": "Ubetalt"
+    "unpaid": "Ubetalt",
+    "unsavedChanges": "Ulagrede endringer",
+    "saved": "Lagret"
   },
   "basicInfo": {
     "title": "Grunnleggende informasjon",

+ 2 - 0
messages/nl/quotes.json

@@ -27,6 +27,8 @@
     "deleteTitle": "Offerte verwijderen",
     "deleteDescription": "Deze offerte wordt permanent verwijderd.",
     "saved": "Offerte opgeslagen",
+    "unsavedChanges": "Niet-opgeslagen wijzigingen",
+    "saveFirst": "Sla eerst uw wijzigingen op",
     "failedSave": "Offerte opslaan mislukt",
     "failedDelete": "Verwijderen mislukt",
     "failedPdf": "PDF genereren mislukt",

+ 3 - 1
messages/nl/service.json

@@ -20,7 +20,9 @@
     "email": "E-mail",
     "share": "Delen",
     "delete": "Verwijderen",
-    "unpaid": "Onbetaald"
+    "unpaid": "Onbetaald",
+    "unsavedChanges": "Niet-opgeslagen wijzigingen",
+    "saved": "Opgeslagen"
   },
   "basicInfo": {
     "title": "Basisinformatie",

+ 2 - 0
messages/pl/quotes.json

@@ -27,6 +27,8 @@
     "deleteTitle": "Usuń wycenę",
     "deleteDescription": "Ta wycena zostanie trwale usunięta.",
     "saved": "Wycena zapisana",
+    "unsavedChanges": "Niezapisane zmiany",
+    "saveFirst": "Najpierw zapisz zmiany",
     "failedSave": "Nie udało się zapisać wyceny",
     "failedDelete": "Nie udało się usunąć",
     "failedPdf": "Nie udało się wygenerować PDF",

+ 3 - 1
messages/pl/service.json

@@ -20,7 +20,9 @@
     "email": "Email",
     "share": "Udostępnij",
     "delete": "Usuń",
-    "unpaid": "Niezapłacone"
+    "unpaid": "Niezapłacone",
+    "unsavedChanges": "Niezapisane zmiany",
+    "saved": "Zapisano"
   },
   "basicInfo": {
     "title": "Informacje Podstawowe",

+ 2 - 0
messages/pt-BR/quotes.json

@@ -27,6 +27,8 @@
     "deleteTitle": "Excluir orçamento",
     "deleteDescription": "Este orçamento será excluído permanentemente.",
     "saved": "Orçamento salvo",
+    "unsavedChanges": "Alterações não salvas",
+    "saveFirst": "Salve suas alterações primeiro",
     "failedSave": "Falha ao salvar o orçamento",
     "failedDelete": "Falha ao excluir",
     "failedPdf": "Falha ao gerar o PDF",

+ 3 - 1
messages/pt-BR/service.json

@@ -20,7 +20,9 @@
     "email": "Email",
     "share": "Compartilhar",
     "delete": "Deletar",
-    "unpaid": "Não Pago"
+    "unpaid": "Não Pago",
+    "unsavedChanges": "Alterações não salvas",
+    "saved": "Salvo"
   },
   "basicInfo": {
     "title": "Informações Básicas",

+ 2 - 0
messages/tr/quotes.json

@@ -27,6 +27,8 @@
     "deleteTitle": "Teklifi Sil",
     "deleteDescription": "Bu teklif kalıcı olarak silinecektir.",
     "saved": "Teklif kaydedildi",
+    "unsavedChanges": "Kaydedilmemiş değişiklikler",
+    "saveFirst": "Lütfen önce değişikliklerinizi kaydedin",
     "failedSave": "Teklif kaydedilemedi",
     "failedDelete": "Silinemedi",
     "failedPdf": "PDF oluşturulamadı",

+ 3 - 1
messages/tr/service.json

@@ -20,7 +20,9 @@
     "email": "E-posta",
     "share": "Paylaş",
     "delete": "Sil",
-    "unpaid": "Ödenmedi"
+    "unpaid": "Ödenmedi",
+    "unsavedChanges": "Kaydedilmemiş değişiklikler",
+    "saved": "Kaydedildi"
   },
   "basicInfo": {
     "title": "Temel Bilgiler",

+ 5 - 0
prisma/migrations/20260302163120_exlcude_parts_option/migration.sql

@@ -0,0 +1,5 @@
+-- AlterTable
+ALTER TABLE "quote_labor" ADD COLUMN     "excluded" BOOLEAN NOT NULL DEFAULT false;
+
+-- AlterTable
+ALTER TABLE "quote_parts" ADD COLUMN     "excluded" BOOLEAN NOT NULL DEFAULT false;

+ 6 - 4
prisma/schema.prisma

@@ -462,6 +462,7 @@ model QuotePart {
   quantity   Float   @default(1)
   unitPrice  Float   @default(0)
   total      Float   @default(0)
+  excluded   Boolean @default(false)
 
   quoteId String
   quote   Quote  @relation(fields: [quoteId], references: [id], onDelete: Cascade)
@@ -471,11 +472,12 @@ model QuotePart {
 }
 
 model QuoteLabor {
-  id          String @id @default(cuid())
+  id          String  @id @default(cuid())
   description String
-  hours       Float  @default(0)
-  rate        Float  @default(0)
-  total       Float  @default(0)
+  hours       Float   @default(0)
+  rate        Float   @default(0)
+  total       Float   @default(0)
+  excluded    Boolean @default(false)
 
   quoteId String
   quote   Quote  @relation(fields: [quoteId], references: [id], onDelete: Cascade)

+ 1 - 0
src/__tests__/features/public/quote-view.test.tsx

@@ -163,6 +163,7 @@ describe("QuoteView", () => {
         ...DEFAULT_PROPS,
         quote: {
           ...BASE_QUOTE,
+          partItems: [{ name: "Part A", partNumber: "", quantity: 1, unitPrice: 110, total: 110 }],
           discountType: "fixed",
           discountValue: 10,
           discountAmount: 10,

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

@@ -29,7 +29,7 @@ export default async function NewQuotePage({
   const vehicles = vehiclesResult.success && vehiclesResult.data ? vehiclesResult.data : [];
 
   // Build prefill data from inspection if provided
-  let prefill: { title?: string; vehicleId?: string; customerId?: string; inspectionId?: string; laborItems?: { description: string; hours: number; rate: number; total: number }[] } | undefined;
+  let prefill: { title?: string; vehicleId?: string; customerId?: string; inspectionId?: string; laborItems?: { description: string; hours: number; rate: number; total: number; excluded?: boolean }[] } | undefined;
 
   if (params.fromInspection) {
     const inspResult = await getInspection(params.fromInspection);

+ 6 - 0
src/app/(authenticated)/work-orders/page.tsx

@@ -17,6 +17,8 @@ export default async function WorkOrdersPage({
     pageSize?: string;
     search?: string;
     status?: string;
+    sortBy?: string;
+    sortOrder?: string;
   }>;
 }) {
   const params = await searchParams;
@@ -26,6 +28,8 @@ export default async function WorkOrdersPage({
       pageSize: params.pageSize ? parseInt(params.pageSize) : 20,
       search: params.search,
       status: params.status,
+      sortBy: params.sortBy,
+      sortOrder: params.sortOrder as "asc" | "desc" | undefined,
     }),
     getSettings([SETTING_KEYS.CURRENCY_CODE]),
     getVehicles(),
@@ -75,6 +79,8 @@ export default async function WorkOrdersPage({
           currencyCode={currencyCode}
           search={params.search || ""}
           statusFilter={params.status || "all"}
+          sortBy={params.sortBy || "serviceDate"}
+          sortOrder={(params.sortOrder as "asc" | "desc") || "desc"}
           smsEnabled={features?.sms ?? false}
           emailEnabled={features?.smtp ?? false}
         />

+ 52 - 6
src/app/(authenticated)/work-orders/work-orders-client.tsx

@@ -25,6 +25,9 @@ import { DataTablePagination } from "@/components/data-table-pagination";
 import { statusColors } from "@/lib/table-utils";
 import { updateServiceStatus } from "@/features/vehicles/Actions/serviceActions";
 import {
+  ArrowDown,
+  ArrowUp,
+  ArrowUpDown,
   ChevronDown,
   Loader2,
   Plus,
@@ -131,6 +134,8 @@ export function WorkOrdersClient({
   currencyCode = "USD",
   search,
   statusFilter,
+  sortBy,
+  sortOrder,
   smsEnabled = false,
   emailEnabled = false,
 }: {
@@ -140,6 +145,8 @@ export function WorkOrdersClient({
   currencyCode?: string;
   search: string;
   statusFilter: string;
+  sortBy: string;
+  sortOrder: "asc" | "desc";
   smsEnabled?: boolean;
   emailEnabled?: boolean;
 }) {
@@ -177,6 +184,21 @@ export function WorkOrdersClient({
     [router, pathname, searchParams]
   );
 
+  const handleSort = useCallback(
+    (column: string) => {
+      const newOrder = sortBy === column && sortOrder === "asc" ? "desc" : "asc";
+      navigate({ sortBy: column, sortOrder: newOrder });
+    },
+    [navigate, sortBy, sortOrder]
+  );
+
+  const SortIcon = ({ column }: { column: string }) => {
+    if (sortBy !== column) return <ArrowUpDown className="ml-1 h-3 w-3 opacity-50" />;
+    return sortOrder === "asc"
+      ? <ArrowUp className="ml-1 h-3 w-3" />
+      : <ArrowDown className="ml-1 h-3 w-3" />;
+  };
+
   const handleSearch = useCallback(
     (e: React.FormEvent) => {
       e.preventDefault();
@@ -261,14 +283,38 @@ export function WorkOrdersClient({
         <Table>
           <TableHeader>
             <TableRow>
-              <TableHead className="hidden sm:table-cell w-[100px]">{t("table.invoice")}</TableHead>
+              <TableHead className="hidden sm:table-cell w-[100px]">
+                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("invoiceNumber")}>
+                  {t("table.invoice")}<SortIcon column="invoiceNumber" />
+                </button>
+              </TableHead>
               <TableHead>{t("table.vehicle")}</TableHead>
               <TableHead className="hidden md:table-cell">{t("table.customer")}</TableHead>
-              <TableHead>{t("table.title")}</TableHead>
-              <TableHead className="w-[110px]">{t("table.status")}</TableHead>
-              <TableHead className="hidden lg:table-cell">{t("table.tech")}</TableHead>
-              <TableHead className="w-[90px]">{t("table.date")}</TableHead>
-              <TableHead className="w-[90px] text-right">{t("table.total")}</TableHead>
+              <TableHead>
+                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("title")}>
+                  {t("table.title")}<SortIcon column="title" />
+                </button>
+              </TableHead>
+              <TableHead className="w-[110px]">
+                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("status")}>
+                  {t("table.status")}<SortIcon column="status" />
+                </button>
+              </TableHead>
+              <TableHead className="hidden lg:table-cell">
+                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("techName")}>
+                  {t("table.tech")}<SortIcon column="techName" />
+                </button>
+              </TableHead>
+              <TableHead className="w-[90px]">
+                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("serviceDate")}>
+                  {t("table.date")}<SortIcon column="serviceDate" />
+                </button>
+              </TableHead>
+              <TableHead className="w-[90px] text-right">
+                <button type="button" className="flex items-center justify-end hover:text-foreground ml-auto" onClick={() => handleSort("totalAmount")}>
+                  {t("table.total")}<SortIcon column="totalAmount" />
+                </button>
+              </TableHead>
               <TableHead className="w-[50px]" />
             </TableRow>
           </TableHeader>

+ 58 - 40
src/app/(public)/share/quote/[orgId]/[token]/quote-view.tsx

@@ -28,12 +28,14 @@ interface QuoteRecord {
     quantity: number;
     unitPrice: number;
     total: number;
+    excluded?: boolean;
   }[];
   laborItems: {
     description: string;
     hours: number;
     rate: number;
     total: number;
+    excluded?: boolean;
   }[];
   customer: {
     name: string;
@@ -389,7 +391,7 @@ export function QuoteView({
                 </thead>
                 <tbody className="divide-y">
                   {quote.partItems.map((p, i) => (
-                    <tr key={i}>
+                    <tr key={i} className={p.excluded ? "line-through text-gray-400" : ""}>
                       <td className="p-2 font-mono text-xs">{p.partNumber || "-"}</td>
                       <td className="p-2">{p.name}</td>
                       <td className="p-2 text-right">{p.quantity}</td>
@@ -419,7 +421,7 @@ export function QuoteView({
                 </thead>
                 <tbody className="divide-y">
                   {quote.laborItems.map((l, i) => (
-                    <tr key={i}>
+                    <tr key={i} className={l.excluded ? "line-through text-gray-400" : ""}>
                       <td className="p-2">{l.description}</td>
                       <td className="p-2 text-right">{l.hours}</td>
                       <td className="p-2 text-right">{t('ratePerHour', { rate: formatCurrency(l.rate, currencyCode) })}</td>
@@ -434,44 +436,60 @@ export function QuoteView({
 
         {/* Totals */}
         <div className="mt-6 ml-auto max-w-xs space-y-2">
-          {quote.laborItems.length > 0 && (
-            <div className="flex justify-between text-sm">
-              <span className="text-gray-500">{t('labor')}</span>
-              <span>{formatCurrency(quote.laborItems.reduce((sum, l) => sum + l.total, 0), currencyCode)}</span>
-            </div>
-          )}
-          {quote.partItems.length > 0 && (
-            <div className="flex justify-between text-sm">
-              <span className="text-gray-500">{t('parts')}</span>
-              <span>{formatCurrency(quote.partItems.reduce((sum, p) => sum + p.total, 0), currencyCode)}</span>
-            </div>
-          )}
-          {quote.subtotal > 0 && (
-            <div className="flex justify-between text-sm">
-              <span className="text-gray-500">{t('subtotal')}</span>
-              <span>{formatCurrency(quote.subtotal, currencyCode)}</span>
-            </div>
-          )}
-          {quote.discountAmount > 0 && (
-            <div className="flex justify-between text-sm">
-              <span className="text-gray-500">
-                {quote.discountType === "percentage" ? t('discountPercent', { percent: quote.discountValue }) : t('discount')}
-              </span>
-              <span className="text-red-500">{formatCurrency(-quote.discountAmount, currencyCode)}</span>
-            </div>
-          )}
-          {quote.taxRate > 0 && (
-            <div className="flex justify-between text-sm">
-              <span className="text-gray-500">{t('tax', { rate: quote.taxRate })}</span>
-              <span>{formatCurrency(quote.taxAmount, currencyCode)}</span>
-            </div>
-          )}
-          <div className="border-t pt-2" style={{ borderColor: primaryColor }}>
-            <div className="flex justify-between text-lg font-bold">
-              <span>{t('total')}</span>
-              <span style={{ color: primaryColor }}>{formatCurrency(quote.totalAmount, currencyCode)}</span>
-            </div>
-          </div>
+          {(() => {
+            const laborTotal = quote.laborItems.reduce((sum, l) => l.excluded ? sum : sum + l.total, 0);
+            const partsTotal = quote.partItems.reduce((sum, p) => p.excluded ? sum : sum + p.total, 0);
+            const sub = laborTotal + partsTotal;
+            const disc = quote.discountType === "percentage"
+              ? sub * (quote.discountValue / 100)
+              : quote.discountType === "fixed"
+              ? Math.min(quote.discountValue, sub)
+              : 0;
+            const tax = (sub - disc) * (quote.taxRate / 100);
+            const total = sub - disc + tax;
+            return (
+              <>
+                {quote.laborItems.length > 0 && (
+                  <div className="flex justify-between text-sm">
+                    <span className="text-gray-500">{t('labor')}</span>
+                    <span>{formatCurrency(laborTotal, currencyCode)}</span>
+                  </div>
+                )}
+                {quote.partItems.length > 0 && (
+                  <div className="flex justify-between text-sm">
+                    <span className="text-gray-500">{t('parts')}</span>
+                    <span>{formatCurrency(partsTotal, currencyCode)}</span>
+                  </div>
+                )}
+                {sub > 0 && (
+                  <div className="flex justify-between text-sm">
+                    <span className="text-gray-500">{t('subtotal')}</span>
+                    <span>{formatCurrency(sub, currencyCode)}</span>
+                  </div>
+                )}
+                {disc > 0 && (
+                  <div className="flex justify-between text-sm">
+                    <span className="text-gray-500">
+                      {quote.discountType === "percentage" ? t('discountPercent', { percent: quote.discountValue }) : t('discount')}
+                    </span>
+                    <span className="text-red-500">{formatCurrency(-disc, currencyCode)}</span>
+                  </div>
+                )}
+                {quote.taxRate > 0 && (
+                  <div className="flex justify-between text-sm">
+                    <span className="text-gray-500">{t('tax', { rate: quote.taxRate })}</span>
+                    <span>{formatCurrency(tax, currencyCode)}</span>
+                  </div>
+                )}
+                <div className="border-t pt-2" style={{ borderColor: primaryColor }}>
+                  <div className="flex justify-between text-lg font-bold">
+                    <span>{t('total')}</span>
+                    <span style={{ color: primaryColor }}>{formatCurrency(total, currencyCode)}</span>
+                  </div>
+                </div>
+              </>
+            );
+          })()}
         </div>
 
         {/* Image Attachments */}

+ 6 - 4
src/features/quotes/Actions/quoteActions.ts

@@ -290,9 +290,10 @@ export async function convertQuoteToServiceRecord(quoteId: string, vehicleId: st
         },
       });
 
-      if (quote.partItems.length > 0) {
+      const includedParts = quote.partItems.filter((p) => !p.excluded);
+      if (includedParts.length > 0) {
         await tx.servicePart.createMany({
-          data: quote.partItems.map((p) => ({
+          data: includedParts.map((p) => ({
             partNumber: p.partNumber,
             name: p.name,
             quantity: p.quantity,
@@ -303,9 +304,10 @@ export async function convertQuoteToServiceRecord(quoteId: string, vehicleId: st
         });
       }
 
-      if (quote.laborItems.length > 0) {
+      const includedLabor = quote.laborItems.filter((l) => !l.excluded);
+      if (includedLabor.length > 0) {
         await tx.serviceLabor.createMany({
-          data: quote.laborItems.map((l) => ({
+          data: includedLabor.map((l) => ({
             description: l.description,
             hours: l.hours,
             rate: l.rate,

+ 22 - 14
src/features/quotes/Components/QuoteForm.tsx

@@ -70,6 +70,7 @@ const emptyPart = (): QuotePartInput => ({
   quantity: 1,
   unitPrice: 0,
   total: 0,
+  excluded: false,
 });
 
 const makeEmptyLabor = (defaultRate: number): QuoteLaborInput => ({
@@ -77,6 +78,7 @@ const makeEmptyLabor = (defaultRate: number): QuoteLaborInput => ({
   hours: 0,
   rate: defaultRate,
   total: 0,
+  excluded: false,
 });
 
 const LG_BREAKPOINT = 1024;
@@ -98,7 +100,7 @@ interface PrefillData {
   vehicleId?: string;
   customerId?: string;
   inspectionId?: string;
-  laborItems?: QuoteLaborInput[];
+  laborItems?: (Omit<QuoteLaborInput, 'excluded'> & { excluded?: boolean })[];
 }
 
 export function QuoteForm({
@@ -133,7 +135,7 @@ export function QuoteForm({
   const [customerId, setCustomerId] = useState(initialData?.customerId || prefill?.customerId || "");
   const [vehicleId, setVehicleId] = useState(initialData?.vehicleId || prefill?.vehicleId || "");
   const [partItems, setPartItems] = useState<QuotePartInput[]>(initialData?.partItems || []);
-  const [laborItems, setLaborItems] = useState<QuoteLaborInput[]>(initialData?.laborItems || prefill?.laborItems || []);
+  const [laborItems, setLaborItems] = useState<QuoteLaborInput[]>(initialData?.laborItems || (prefill?.laborItems?.map((l) => ({ ...l, excluded: l.excluded ?? false }))) || []);
   const [taxRate, setTaxRate] = useState(initialData?.taxRate ?? defaultTaxRate);
   const [discountType, setDiscountType] = useState<string>(initialData?.discountType || "none");
   const [discountValue, setDiscountValue] = useState(initialData?.discountValue ?? 0);
@@ -147,8 +149,8 @@ export function QuoteForm({
       : new Date(Date.now() + quoteValidDays * 86400000).toISOString().split("T")[0]
   );
 
-  const partsSubtotal = partItems.reduce((sum, p) => sum + p.total, 0);
-  const laborSubtotal = laborItems.reduce((sum, l) => sum + l.total, 0);
+  const partsSubtotal = partItems.reduce((sum, p) => p.excluded ? sum : sum + p.total, 0);
+  const laborSubtotal = laborItems.reduce((sum, l) => l.excluded ? sum : sum + l.total, 0);
   const subtotal = partsSubtotal + laborSubtotal;
   const discountAmount = discountType === "percentage"
     ? subtotal * (discountValue / 100)
@@ -172,7 +174,7 @@ export function QuoteForm({
     }
   };
 
-  const updatePart = useCallback((index: number, field: keyof QuotePartInput, value: string | number) => {
+  const updatePart = useCallback((index: number, field: keyof QuotePartInput, value: string | number | boolean) => {
     setPartItems((prev) => {
       const updated = [...prev];
       const part = { ...updated[index], [field]: value };
@@ -184,7 +186,7 @@ export function QuoteForm({
     });
   }, []);
 
-  const updateLabor = useCallback((index: number, field: keyof QuoteLaborInput, value: string | number) => {
+  const updateLabor = useCallback((index: number, field: keyof QuoteLaborInput, value: string | number | boolean) => {
     setLaborItems((prev) => {
       const updated = [...prev];
       const labor = { ...updated[index], [field]: value };
@@ -252,15 +254,18 @@ export function QuoteForm({
               <span>{t("parts.partNumber")}</span><span>{t("parts.name")}</span><span>{t("parts.qty")}</span><span>{t("parts.unitPrice")}</span><span>{t("parts.total")}</span><span />
             </div>
             {partItems.map((part, i) => (
-              <div key={i} className="grid grid-cols-2 gap-2 sm:grid-cols-[1fr_2fr_0.7fr_1fr_1fr_auto]">
+              <div key={i} className={`grid grid-cols-2 gap-2 sm:grid-cols-[1fr_2fr_0.7fr_1fr_1fr_auto]${part.excluded ? " line-through opacity-50" : ""}`}>
                 <Input placeholder={t("parts.partNumber")} value={part.partNumber ?? ""} onChange={(e) => updatePart(i, "partNumber", e.target.value)} />
                 <Input placeholder={t("parts.namePlaceholder")} value={part.name} onChange={(e) => updatePart(i, "name", e.target.value)} />
                 <Input type="number" min="0" step="1" value={part.quantity} onChange={(e) => updatePart(i, "quantity", e.target.value)} />
                 <Input type="number" min="0" step="0.01" value={part.unitPrice} onChange={(e) => updatePart(i, "unitPrice", e.target.value)} />
                 <div className="flex items-center rounded-md bg-muted/50 px-3 text-sm font-medium">{formatCurrency(part.total, currencyCode)}</div>
-                <Button type="button" variant="ghost" size="icon" className="h-9 w-9 text-muted-foreground hover:text-destructive" onClick={() => setPartItems(partItems.filter((_, j) => j !== i))}>
-                  <Trash2 className="h-4 w-4" />
-                </Button>
+                <div className="flex items-center gap-1">
+                  <input type="checkbox" checked={part.excluded ?? false} onChange={(e) => updatePart(i, "excluded", e.target.checked)} className="h-4 w-4 rounded border-gray-300" title="Exclude from total" />
+                  <Button type="button" variant="ghost" size="icon" className="h-9 w-9 text-muted-foreground hover:text-destructive" onClick={() => setPartItems(partItems.filter((_, j) => j !== i))}>
+                    <Trash2 className="h-4 w-4" />
+                  </Button>
+                </div>
               </div>
             ))}
             <button
@@ -301,14 +306,17 @@ export function QuoteForm({
               <span>{t("labor.description")}</span><span>{t("labor.hours")}</span><span>{t("labor.rate", { currency: cs })}</span><span>{t("labor.total")}</span><span />
             </div>
             {laborItems.map((labor, i) => (
-              <div key={i} className="grid grid-cols-2 gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]">
+              <div key={i} className={`grid grid-cols-2 gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]${labor.excluded ? " line-through opacity-50" : ""}`}>
                 <Input placeholder={t("labor.descriptionPlaceholder")} value={labor.description} onChange={(e) => updateLabor(i, "description", e.target.value)} className="col-span-2 sm:col-span-1" />
                 <Input type="number" min="0" step="0.1" value={labor.hours} onChange={(e) => updateLabor(i, "hours", e.target.value)} />
                 <Input type="number" min="0" step="0.01" value={labor.rate} onChange={(e) => updateLabor(i, "rate", e.target.value)} />
                 <div className="flex items-center rounded-md bg-muted/50 px-3 text-sm font-medium">{formatCurrency(labor.total, currencyCode)}</div>
-                <Button type="button" variant="ghost" size="icon" className="h-9 w-9 text-muted-foreground hover:text-destructive" onClick={() => setLaborItems(laborItems.filter((_, j) => j !== i))}>
-                  <Trash2 className="h-4 w-4" />
-                </Button>
+                <div className="flex items-center gap-1">
+                  <input type="checkbox" checked={labor.excluded ?? false} onChange={(e) => updateLabor(i, "excluded", e.target.checked)} className="h-4 w-4 rounded border-gray-300" title="Exclude from total" />
+                  <Button type="button" variant="ghost" size="icon" className="h-9 w-9 text-muted-foreground hover:text-destructive" onClick={() => setLaborItems(laborItems.filter((_, j) => j !== i))}>
+                    <Trash2 className="h-4 w-4" />
+                  </Button>
+                </div>
               </div>
             ))}
             <button

+ 75 - 60
src/features/quotes/Components/QuotePDF.tsx

@@ -30,8 +30,9 @@ interface QuoteData {
     quantity: number
     unitPrice: number
     total: number
+    excluded?: boolean
   }[]
-  laborItems: { description: string; hours: number; rate: number; total: number }[]
+  laborItems: { description: string; hours: number; rate: number; total: number; excluded?: boolean }[]
   customer: {
     name: string
     email: string | null
@@ -353,16 +354,16 @@ export function QuotePDF({
                 </Text>
               </View>
               {data.partItems.map((p, i) => (
-                <View key={i} style={styles.tableRow}>
-                  <Text style={{ ...styles.tableCell, width: '15%' }}>{p.partNumber || '-'}</Text>
-                  <Text style={{ ...styles.tableCell, width: '35%' }}>{p.name}</Text>
-                  <Text style={{ ...styles.tableCell, width: '12%', textAlign: 'right' }}>
+                <View key={i} style={{ ...styles.tableRow, ...(p.excluded ? { opacity: 0.5 } : {}) }}>
+                  <Text style={{ ...styles.tableCell, width: '15%', ...(p.excluded ? { textDecoration: 'line-through' } : {}) }}>{p.partNumber || '-'}</Text>
+                  <Text style={{ ...styles.tableCell, width: '35%', ...(p.excluded ? { textDecoration: 'line-through' } : {}) }}>{p.name}</Text>
+                  <Text style={{ ...styles.tableCell, width: '12%', textAlign: 'right', ...(p.excluded ? { textDecoration: 'line-through' } : {}) }}>
                     {p.quantity}
                   </Text>
-                  <Text style={{ ...styles.tableCell, width: '18%', textAlign: 'right' }}>
+                  <Text style={{ ...styles.tableCell, width: '18%', textAlign: 'right', ...(p.excluded ? { textDecoration: 'line-through' } : {}) }}>
                     {formatCurrency(p.unitPrice, currencyCode)}
                   </Text>
-                  <Text style={{ ...styles.tableCellBold, width: '20%', textAlign: 'right' }}>
+                  <Text style={{ ...styles.tableCellBold, width: '20%', textAlign: 'right', ...(p.excluded ? { textDecoration: 'line-through' } : {}) }}>
                     {formatCurrency(p.total, currencyCode)}
                   </Text>
                 </View>
@@ -388,15 +389,15 @@ export function QuotePDF({
                 </Text>
               </View>
               {data.laborItems.map((l, i) => (
-                <View key={i} style={styles.tableRow}>
-                  <Text style={{ ...styles.tableCell, width: '45%' }}>{l.description}</Text>
-                  <Text style={{ ...styles.tableCell, width: '15%', textAlign: 'right' }}>
+                <View key={i} style={{ ...styles.tableRow, ...(l.excluded ? { opacity: 0.5 } : {}) }}>
+                  <Text style={{ ...styles.tableCell, width: '45%', ...(l.excluded ? { textDecoration: 'line-through' } : {}) }}>{l.description}</Text>
+                  <Text style={{ ...styles.tableCell, width: '15%', textAlign: 'right', ...(l.excluded ? { textDecoration: 'line-through' } : {}) }}>
                     {l.hours}
                   </Text>
-                  <Text style={{ ...styles.tableCell, width: '20%', textAlign: 'right' }}>
+                  <Text style={{ ...styles.tableCell, width: '20%', textAlign: 'right', ...(l.excluded ? { textDecoration: 'line-through' } : {}) }}>
                     {labels.ratePerHour ? fillTemplate(labels.ratePerHour, { rate: formatCurrency(l.rate, currencyCode) }) : `${formatCurrency(l.rate, currencyCode)}/hr`}
                   </Text>
-                  <Text style={{ ...styles.tableCellBold, width: '20%', textAlign: 'right' }}>
+                  <Text style={{ ...styles.tableCellBold, width: '20%', textAlign: 'right', ...(l.excluded ? { textDecoration: 'line-through' } : {}) }}>
                     {formatCurrency(l.total, currencyCode)}
                   </Text>
                 </View>
@@ -405,55 +406,69 @@ export function QuotePDF({
           </View>
         )}
 
-        <View style={styles.totalsBox}>
-          {data.laborItems.length > 0 && (
-            <View style={styles.totalRow}>
-              <Text style={styles.totalLabel}>{labels.labor || 'Labor'}</Text>
-              <Text style={styles.totalValue}>
-                {formatCurrency(data.laborItems.reduce((sum, l) => sum + l.total, 0), currencyCode)}
-              </Text>
-            </View>
-          )}
-          {data.partItems.length > 0 && (
-            <View style={styles.totalRow}>
-              <Text style={styles.totalLabel}>{labels.parts || 'Parts'}</Text>
-              <Text style={styles.totalValue}>
-                {formatCurrency(data.partItems.reduce((sum, p) => sum + p.total, 0), currencyCode)}
-              </Text>
-            </View>
-          )}
-          {data.subtotal > 0 && (
-            <View style={styles.totalRow}>
-              <Text style={styles.totalLabel}>{labels.subtotal || 'Subtotal'}</Text>
-              <Text style={styles.totalValue}>{formatCurrency(data.subtotal, currencyCode)}</Text>
-            </View>
-          )}
-          {data.discountAmount > 0 && (
-            <View style={styles.totalRow}>
-              <Text style={styles.totalLabel}>
-                {data.discountType === 'percentage'
-                  ? (labels.discountPercent ? fillTemplate(labels.discountPercent, { percent: String(data.discountValue) }) : `Discount (${data.discountValue}%)`)
-                  : (labels.discount || 'Discount')}
-              </Text>
-              <Text style={{ ...styles.totalValue, color: '#dc2626' }}>
-                {formatCurrency(-data.discountAmount, currencyCode)}
-              </Text>
-            </View>
-          )}
-          {data.taxRate > 0 && (
-            <View style={styles.totalRow}>
-              <Text style={styles.totalLabel}>{labels.tax ? fillTemplate(labels.tax, { rate: String(data.taxRate) }) : `Tax (${data.taxRate}%)`}</Text>
-              <Text style={styles.totalValue}>{formatCurrency(data.taxAmount, currencyCode)}</Text>
+        {(() => {
+          const laborTotal = data.laborItems.reduce((sum, l) => l.excluded ? sum : sum + l.total, 0)
+          const partsTotal = data.partItems.reduce((sum, p) => p.excluded ? sum : sum + p.total, 0)
+          const sub = laborTotal + partsTotal
+          const disc = data.discountType === 'percentage'
+            ? sub * (data.discountValue / 100)
+            : data.discountType === 'fixed'
+            ? Math.min(data.discountValue, sub)
+            : 0
+          const tax = (sub - disc) * (data.taxRate / 100)
+          const total = sub - disc + tax
+          return (
+            <View style={styles.totalsBox}>
+              {data.laborItems.length > 0 && (
+                <View style={styles.totalRow}>
+                  <Text style={styles.totalLabel}>{labels.labor || 'Labor'}</Text>
+                  <Text style={styles.totalValue}>
+                    {formatCurrency(laborTotal, currencyCode)}
+                  </Text>
+                </View>
+              )}
+              {data.partItems.length > 0 && (
+                <View style={styles.totalRow}>
+                  <Text style={styles.totalLabel}>{labels.parts || 'Parts'}</Text>
+                  <Text style={styles.totalValue}>
+                    {formatCurrency(partsTotal, currencyCode)}
+                  </Text>
+                </View>
+              )}
+              {sub > 0 && (
+                <View style={styles.totalRow}>
+                  <Text style={styles.totalLabel}>{labels.subtotal || 'Subtotal'}</Text>
+                  <Text style={styles.totalValue}>{formatCurrency(sub, currencyCode)}</Text>
+                </View>
+              )}
+              {disc > 0 && (
+                <View style={styles.totalRow}>
+                  <Text style={styles.totalLabel}>
+                    {data.discountType === 'percentage'
+                      ? (labels.discountPercent ? fillTemplate(labels.discountPercent, { percent: String(data.discountValue) }) : `Discount (${data.discountValue}%)`)
+                      : (labels.discount || 'Discount')}
+                  </Text>
+                  <Text style={{ ...styles.totalValue, color: '#dc2626' }}>
+                    {formatCurrency(-disc, currencyCode)}
+                  </Text>
+                </View>
+              )}
+              {data.taxRate > 0 && (
+                <View style={styles.totalRow}>
+                  <Text style={styles.totalLabel}>{labels.tax ? fillTemplate(labels.tax, { rate: String(data.taxRate) }) : `Tax (${data.taxRate}%)`}</Text>
+                  <Text style={styles.totalValue}>{formatCurrency(tax, currencyCode)}</Text>
+                </View>
+              )}
+              <View style={styles.totalDivider} />
+              <View style={styles.grandTotalRow}>
+                <Text style={styles.grandTotalLabel}>{labels.total || 'Total'}</Text>
+                <Text style={styles.grandTotalValue}>
+                  {formatCurrency(total, currencyCode)}
+                </Text>
+              </View>
             </View>
-          )}
-          <View style={styles.totalDivider} />
-          <View style={styles.grandTotalRow}>
-            <Text style={styles.grandTotalLabel}>{labels.total || 'Total'}</Text>
-            <Text style={styles.grandTotalValue}>
-              {formatCurrency(data.totalAmount, currencyCode)}
-            </Text>
-          </View>
-        </View>
+          )
+        })()}
 
         {torqvoiceLogoDataUri && (
           <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end', gap: 3, marginTop: 6 }}>

+ 110 - 37
src/features/quotes/Components/QuotePageClient.tsx

@@ -1,6 +1,6 @@
 "use client";
 
-import { useState, useCallback, useEffect } from "react";
+import { useState, useCallback, useEffect, useRef } from "react";
 import { useRouter } from "next/navigation";
 import Link from "next/link";
 import { toast } from "sonner";
@@ -127,8 +127,8 @@ interface QuoteRecord {
   inspectionId: string | null;
   createdAt: Date;
   updatedAt: Date;
-  partItems: { id: string; partNumber: string | null; name: string; quantity: number; unitPrice: number; total: number }[];
-  laborItems: { id: string; description: string; hours: number; rate: number; total: number }[];
+  partItems: { id: string; partNumber: string | null; name: string; quantity: number; unitPrice: number; total: number; excluded?: boolean }[];
+  laborItems: { id: string; description: string; hours: number; rate: number; total: number; excluded?: boolean }[];
   customer: { id: string; name: string; email: string | null; phone: string | null; address: string | null; company: string | null } | null;
   vehicle: { id: string; make: string; model: string; year: number; vin: string | null; licensePlate: string | null; mileage: number } | null;
 }
@@ -139,6 +139,7 @@ const emptyPart = (): QuotePartInput => ({
   quantity: 1,
   unitPrice: 0,
   total: 0,
+  excluded: false,
 });
 
 const makeEmptyLabor = (defaultRate: number): QuoteLaborInput => ({
@@ -146,6 +147,7 @@ const makeEmptyLabor = (defaultRate: number): QuoteLaborInput => ({
   hours: 0,
   rate: defaultRate,
   total: 0,
+  excluded: false,
 });
 
 const LG_BREAKPOINT = 1024;
@@ -210,10 +212,10 @@ export function QuotePageClient({
   const [customerId, setCustomerId] = useState(quote.customer?.id || "");
   const [vehicleId, setVehicleId] = useState(quote.vehicle?.id || "");
   const [partItems, setPartItems] = useState<QuotePartInput[]>(
-    quote.partItems.map((p) => ({ partNumber: p.partNumber || "", name: p.name, quantity: p.quantity, unitPrice: p.unitPrice, total: p.total }))
+    quote.partItems.map((p) => ({ partNumber: p.partNumber || "", name: p.name, quantity: p.quantity, unitPrice: p.unitPrice, total: p.total, excluded: p.excluded ?? false }))
   );
   const [laborItems, setLaborItems] = useState<QuoteLaborInput[]>(
-    quote.laborItems.map((l) => ({ description: l.description, hours: l.hours, rate: l.rate, total: l.total }))
+    quote.laborItems.map((l) => ({ description: l.description, hours: l.hours, rate: l.rate, total: l.total, excluded: l.excluded ?? false }))
   );
   const [taxRate, setTaxRate] = useState(quote.taxRate ?? defaultTaxRate);
   const [discountType, setDiscountType] = useState<string>(quote.discountType || "none");
@@ -233,9 +235,42 @@ export function QuotePageClient({
     quote.validUntil ? new Date(quote.validUntil).toISOString().split("T")[0] : ""
   );
 
+  // Unsaved changes tracking & autosave
+  const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
+  const [showSaved, setShowSaved] = useState(false);
+  const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+  const formRef = useRef<HTMLFormElement>(null);
+  const autosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
+  const isSavingRef = useRef(false);
+
+  const markDirty = useCallback(() => {
+    setHasUnsavedChanges(true);
+    if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
+    autosaveTimer.current = setTimeout(() => {
+      if (!isSavingRef.current && formRef.current) {
+        formRef.current.requestSubmit();
+      }
+    }, 5000);
+  }, []);
+
+  useEffect(() => {
+    return () => {
+      if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
+    };
+  }, []);
+
+  useEffect(() => {
+    if (!hasUnsavedChanges) return;
+    const handler = (e: BeforeUnloadEvent) => {
+      e.preventDefault();
+    };
+    window.addEventListener("beforeunload", handler);
+    return () => window.removeEventListener("beforeunload", handler);
+  }, [hasUnsavedChanges]);
+
   // Calculations
-  const partsSubtotal = partItems.reduce((sum, p) => sum + p.total, 0);
-  const laborSubtotal = laborItems.reduce((sum, l) => sum + l.total, 0);
+  const partsSubtotal = partItems.reduce((sum, p) => p.excluded ? sum : sum + p.total, 0);
+  const laborSubtotal = laborItems.reduce((sum, l) => l.excluded ? sum : sum + l.total, 0);
   const subtotal = partsSubtotal + laborSubtotal;
   const discountAmount = discountType === "percentage"
     ? subtotal * (discountValue / 100)
@@ -255,9 +290,10 @@ export function QuotePageClient({
       const vehicle = vehicles.find((veh) => veh.id === vid);
       if (vehicle?.customerId) setCustomerId(vehicle.customerId);
     }
+    markDirty();
   };
 
-  const updatePart = useCallback((index: number, field: keyof QuotePartInput, value: string | number) => {
+  const updatePart = useCallback((index: number, field: keyof QuotePartInput, value: string | number | boolean) => {
     setPartItems((prev) => {
       const updated = [...prev];
       const part = { ...updated[index], [field]: value };
@@ -265,9 +301,10 @@ export function QuotePageClient({
       updated[index] = part;
       return updated;
     });
-  }, []);
+    markDirty();
+  }, [markDirty]);
 
-  const updateLabor = useCallback((index: number, field: keyof QuoteLaborInput, value: string | number) => {
+  const updateLabor = useCallback((index: number, field: keyof QuoteLaborInput, value: string | number | boolean) => {
     setLaborItems((prev) => {
       const updated = [...prev];
       const labor = { ...updated[index], [field]: value };
@@ -275,10 +312,14 @@ export function QuotePageClient({
       updated[index] = labor;
       return updated;
     });
-  }, []);
+    markDirty();
+  }, [markDirty]);
 
   const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
     e.preventDefault();
+    if (isSavingRef.current) return;
+    if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
+    isSavingRef.current = true;
     setSaving(true);
     const formData = new FormData(e.currentTarget);
     const result = await updateQuote({
@@ -301,11 +342,15 @@ export function QuotePageClient({
       totalAmount,
     });
     if (result.success) {
-      toast.success(t("page.saved"));
+      setHasUnsavedChanges(false);
+      if (savedTimerRef.current) clearTimeout(savedTimerRef.current);
+      setShowSaved(true);
+      savedTimerRef.current = setTimeout(() => setShowSaved(false), 2000);
       router.refresh();
     } else {
       modal.open("error", "Error", result.error || t("page.failedSave"));
     }
+    isSavingRef.current = false;
     setSaving(false);
   };
 
@@ -321,7 +366,22 @@ export function QuotePageClient({
     }
   };
 
+  const saveNow = async () => {
+    if (!formRef.current || isSavingRef.current) return;
+    if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
+    formRef.current.requestSubmit();
+    // Wait for save to complete
+    await new Promise<void>((resolve) => {
+      const check = () => {
+        if (!isSavingRef.current) return resolve();
+        setTimeout(check, 50);
+      };
+      setTimeout(check, 50);
+    });
+  };
+
   const handleDownloadPDF = async () => {
+    if (hasUnsavedChanges) await saveNow();
     setDownloading(true);
     try {
       const res = await fetch(`/api/protected/quotes/${quote.id}/pdf`);
@@ -340,6 +400,7 @@ export function QuotePageClient({
   };
 
   const handleConvert = async () => {
+    if (hasUnsavedChanges) await saveNow();
     if (!convertVehicleId) {
       modal.open("error", "Error", t("page.selectVehicle"));
       return;
@@ -375,7 +436,7 @@ export function QuotePageClient({
       <div className="rounded-lg border p-3 space-y-2">
         <div className="flex items-center justify-between">
           <h3 className="text-sm font-semibold">{t("parts.title")}</h3>
-          <Button type="button" variant="outline" size="sm" onClick={() => setPartItems([...partItems, emptyPart()])}>
+          <Button type="button" variant="outline" size="sm" onClick={() => { setPartItems([...partItems, emptyPart()]); markDirty(); }}>
             <Plus className="mr-1 h-3.5 w-3.5" /> {t("parts.addPart")}
           </Button>
         </div>
@@ -385,21 +446,24 @@ export function QuotePageClient({
               <span>{t("parts.partNumber")}</span><span>{t("parts.name")}</span><span>{t("parts.qty")}</span><span>{t("parts.unitPrice")}</span><span>{t("parts.total")}</span><span />
             </div>
             {partItems.map((part, i) => (
-              <div key={i} className="grid grid-cols-2 gap-2 sm:grid-cols-[1fr_2fr_0.7fr_1fr_1fr_auto]">
+              <div key={i} className={`grid grid-cols-2 gap-2 sm:grid-cols-[1fr_2fr_0.7fr_1fr_1fr_auto]${part.excluded ? " line-through opacity-50" : ""}`}>
                 <Input placeholder={t("parts.partNumber")} value={part.partNumber ?? ""} onChange={(e) => updatePart(i, "partNumber", e.target.value)} />
                 <Input placeholder={t("parts.namePlaceholder")} value={part.name} onChange={(e) => updatePart(i, "name", e.target.value)} />
                 <Input type="number" min="0" step="1" value={part.quantity} onChange={(e) => updatePart(i, "quantity", e.target.value)} />
                 <Input type="number" min="0" step="0.01" value={part.unitPrice} onChange={(e) => updatePart(i, "unitPrice", e.target.value)} />
                 <div className="flex items-center rounded-md bg-muted/50 px-3 text-sm font-medium">{formatCurrency(part.total, currencyCode)}</div>
-                <Button type="button" variant="ghost" size="icon" className="h-9 w-9 text-muted-foreground hover:text-destructive" onClick={() => setPartItems(partItems.filter((_, j) => j !== i))}><Trash2 className="h-4 w-4" /></Button>
+                <div className="flex items-center gap-1">
+                  <input type="checkbox" checked={part.excluded ?? false} onChange={(e) => updatePart(i, "excluded", e.target.checked)} className="h-4 w-4 rounded border-gray-300" title="Exclude from total" />
+                  <Button type="button" variant="ghost" size="icon" className="h-9 w-9 text-muted-foreground hover:text-destructive" onClick={() => { setPartItems(partItems.filter((_, j) => j !== i)); markDirty(); }}><Trash2 className="h-4 w-4" /></Button>
+                </div>
               </div>
             ))}
-            <button type="button" className="flex w-full items-center justify-center rounded-md border border-dashed border-muted-foreground/25 py-1.5 text-muted-foreground transition-colors hover:border-muted-foreground/50 hover:text-foreground" onClick={() => setPartItems([...partItems, emptyPart()])}><Plus className="h-4 w-4" /></button>
+            <button type="button" className="flex w-full items-center justify-center rounded-md border border-dashed border-muted-foreground/25 py-1.5 text-muted-foreground transition-colors hover:border-muted-foreground/50 hover:text-foreground" onClick={() => { setPartItems([...partItems, emptyPart()]); markDirty(); }}><Plus className="h-4 w-4" /></button>
             <div className="flex justify-end pt-1 text-sm"><span className="font-medium">{t("parts.subtotal", { amount: formatCurrency(partsSubtotal, currencyCode) })}</span></div>
           </>
         )}
         {partItems.length === 0 && (
-          <button type="button" className="flex w-full items-center justify-center rounded-md border border-dashed border-muted-foreground/25 py-1.5 text-muted-foreground transition-colors hover:border-muted-foreground/50 hover:text-foreground" onClick={() => setPartItems([...partItems, emptyPart()])}>
+          <button type="button" className="flex w-full items-center justify-center rounded-md border border-dashed border-muted-foreground/25 py-1.5 text-muted-foreground transition-colors hover:border-muted-foreground/50 hover:text-foreground" onClick={() => { setPartItems([...partItems, emptyPart()]); markDirty(); }}>
             <Plus className="mr-1 h-4 w-4" /><span className="text-sm">{t("parts.addPart")}</span>
           </button>
         )}
@@ -409,7 +473,7 @@ export function QuotePageClient({
       <div className="rounded-lg border p-3 space-y-2">
         <div className="flex items-center justify-between">
           <h3 className="text-sm font-semibold">{t("labor.title")}</h3>
-          <Button type="button" variant="outline" size="sm" onClick={() => setLaborItems([...laborItems, makeEmptyLabor(defaultLaborRate)])}><Plus className="mr-1 h-3.5 w-3.5" /> {t("labor.addLabor")}</Button>
+          <Button type="button" variant="outline" size="sm" onClick={() => { setLaborItems([...laborItems, makeEmptyLabor(defaultLaborRate)]); markDirty(); }}><Plus className="mr-1 h-3.5 w-3.5" /> {t("labor.addLabor")}</Button>
         </div>
         {laborItems.length > 0 && (
           <>
@@ -417,20 +481,23 @@ export function QuotePageClient({
               <span>{t("labor.description")}</span><span>{t("labor.hours")}</span><span>{t("labor.rate", { currency: cs })}</span><span>{t("labor.total")}</span><span />
             </div>
             {laborItems.map((labor, i) => (
-              <div key={i} className="grid grid-cols-2 gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]">
+              <div key={i} className={`grid grid-cols-2 gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]${labor.excluded ? " line-through opacity-50" : ""}`}>
                 <Input placeholder={t("labor.descriptionPlaceholder")} value={labor.description} onChange={(e) => updateLabor(i, "description", e.target.value)} className="col-span-2 sm:col-span-1" />
                 <Input type="number" min="0" step="0.1" value={labor.hours} onChange={(e) => updateLabor(i, "hours", e.target.value)} />
                 <Input type="number" min="0" step="0.01" value={labor.rate} onChange={(e) => updateLabor(i, "rate", e.target.value)} />
                 <div className="flex items-center rounded-md bg-muted/50 px-3 text-sm font-medium">{formatCurrency(labor.total, currencyCode)}</div>
-                <Button type="button" variant="ghost" size="icon" className="h-9 w-9 text-muted-foreground hover:text-destructive" onClick={() => setLaborItems(laborItems.filter((_, j) => j !== i))}><Trash2 className="h-4 w-4" /></Button>
+                <div className="flex items-center gap-1">
+                  <input type="checkbox" checked={labor.excluded ?? false} onChange={(e) => updateLabor(i, "excluded", e.target.checked)} className="h-4 w-4 rounded border-gray-300" title="Exclude from total" />
+                  <Button type="button" variant="ghost" size="icon" className="h-9 w-9 text-muted-foreground hover:text-destructive" onClick={() => { setLaborItems(laborItems.filter((_, j) => j !== i)); markDirty(); }}><Trash2 className="h-4 w-4" /></Button>
+                </div>
               </div>
             ))}
-            <button type="button" className="flex w-full items-center justify-center rounded-md border border-dashed border-muted-foreground/25 py-1.5 text-muted-foreground transition-colors hover:border-muted-foreground/50 hover:text-foreground" onClick={() => setLaborItems([...laborItems, makeEmptyLabor(defaultLaborRate)])}><Plus className="h-4 w-4" /></button>
+            <button type="button" className="flex w-full items-center justify-center rounded-md border border-dashed border-muted-foreground/25 py-1.5 text-muted-foreground transition-colors hover:border-muted-foreground/50 hover:text-foreground" onClick={() => { setLaborItems([...laborItems, makeEmptyLabor(defaultLaborRate)]); markDirty(); }}><Plus className="h-4 w-4" /></button>
             <div className="flex justify-end pt-1 text-sm"><span className="font-medium">{t("labor.subtotal", { amount: formatCurrency(laborSubtotal, currencyCode) })}</span></div>
           </>
         )}
         {laborItems.length === 0 && (
-          <button type="button" className="flex w-full items-center justify-center rounded-md border border-dashed border-muted-foreground/25 py-1.5 text-muted-foreground transition-colors hover:border-muted-foreground/50 hover:text-foreground" onClick={() => setLaborItems([...laborItems, makeEmptyLabor(defaultLaborRate)])}>
+          <button type="button" className="flex w-full items-center justify-center rounded-md border border-dashed border-muted-foreground/25 py-1.5 text-muted-foreground transition-colors hover:border-muted-foreground/50 hover:text-foreground" onClick={() => { setLaborItems([...laborItems, makeEmptyLabor(defaultLaborRate)]); markDirty(); }}>
             <Plus className="mr-1 h-4 w-4" /><span className="text-sm">{t("labor.addLabor")}</span>
           </button>
         )}
@@ -450,13 +517,13 @@ export function QuotePageClient({
         </div>
         {noteType === "public" && (
           <div className="space-y-1">
-            <RichTextEditor content={description} onChange={setDescription} placeholder={t("notes.publicPlaceholder")} />
+            <RichTextEditor content={description} onChange={(v) => { setDescription(v); markDirty(); }} placeholder={t("notes.publicPlaceholder")} />
             <p className="text-xs text-muted-foreground">{t("notes.publicHelper")}</p>
           </div>
         )}
         {noteType === "internal" && (
           <div className="space-y-1">
-            <RichTextEditor content={notes} onChange={setNotes} placeholder={t("notes.internalPlaceholder")} />
+            <RichTextEditor content={notes} onChange={(v) => { setNotes(v); markDirty(); }} placeholder={t("notes.internalPlaceholder")} />
             <p className="text-xs text-muted-foreground">{t("notes.internalHelper")}</p>
           </div>
         )}
@@ -513,12 +580,12 @@ export function QuotePageClient({
               <span className="font-medium">{selectedVehicle.year} {selectedVehicle.make} {selectedVehicle.model}</span>
               {selectedVehicle.licensePlate && <span className="ml-1.5 text-muted-foreground">{selectedVehicle.licensePlate}</span>}
             </Link>
-            <Button type="button" variant="ghost" size="icon" className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground" onClick={() => setVehicleId("")}><X className="h-3 w-3" /></Button>
+            <Button type="button" variant="ghost" size="icon" className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground" onClick={() => { setVehicleId(""); markDirty(); }}><X className="h-3 w-3" /></Button>
           </div>
         )}
         <div className="space-y-1">
           <Label className="text-xs">{t("details.customer")}</Label>
-          <Select value={customerId || "none"} onValueChange={(v) => setCustomerId(v === "none" ? "" : v)}>
+          <Select value={customerId || "none"} onValueChange={(v) => { setCustomerId(v === "none" ? "" : v); markDirty(); }}>
             <SelectTrigger><SelectValue placeholder={t("details.selectCustomer")} /></SelectTrigger>
             <SelectContent>
               <SelectItem value="none">{t("details.none")}</SelectItem>
@@ -535,7 +602,7 @@ export function QuotePageClient({
               <span className="font-medium">{selectedCustomer.name}</span>
               {selectedCustomer.company && <span className="ml-1.5 text-muted-foreground">{selectedCustomer.company}</span>}
             </Link>
-            <Button type="button" variant="ghost" size="icon" className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground" onClick={() => setCustomerId("")}><X className="h-3 w-3" /></Button>
+            <Button type="button" variant="ghost" size="icon" className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground" onClick={() => { setCustomerId(""); markDirty(); }}><X className="h-3 w-3" /></Button>
           </div>
         )}
       </div>
@@ -545,12 +612,12 @@ export function QuotePageClient({
         <h3 className="text-sm font-semibold">{t("details.title")}</h3>
         <div className="space-y-1">
           <Label htmlFor="title" className="text-xs">{t("details.titleLabel")}</Label>
-          <Input id="title" name="title" placeholder={t("details.titlePlaceholder")} defaultValue={quote.title} required />
+          <Input id="title" name="title" placeholder={t("details.titlePlaceholder")} defaultValue={quote.title} required onChange={markDirty} />
         </div>
         <div className="grid grid-cols-2 gap-2">
           <div className="space-y-1">
             <Label className="text-xs">{t("details.status")}</Label>
-            <Select value={status} onValueChange={setStatus}>
+            <Select value={status} onValueChange={(v) => { setStatus(v); markDirty(); }}>
               <SelectTrigger><SelectValue /></SelectTrigger>
               <SelectContent>
                 <SelectItem value="draft">{t("details.statusDraft")}</SelectItem>
@@ -562,7 +629,7 @@ export function QuotePageClient({
           </div>
           <div className="space-y-1">
             <Label htmlFor="validUntil" className="text-xs">{t("details.validUntil")}</Label>
-            <Input id="validUntil" name="validUntil" type="date" defaultValue={defaultValidDate} />
+            <Input id="validUntil" name="validUntil" type="date" defaultValue={defaultValidDate} onChange={markDirty} />
           </div>
         </div>
         {quote.inspectionId && (
@@ -626,7 +693,7 @@ export function QuotePageClient({
           <div className="flex items-center justify-between text-sm">
             <div className="flex items-center gap-2">
               <span className="text-muted-foreground">{t("totals.discount")}</span>
-              <Select value={discountType} onValueChange={setDiscountType}>
+              <Select value={discountType} onValueChange={(v) => { setDiscountType(v); markDirty(); }}>
                 <SelectTrigger className="h-7 w-28 text-xs"><SelectValue /></SelectTrigger>
                 <SelectContent>
                   <SelectItem value="none">{t("totals.discountNone")}</SelectItem>
@@ -635,7 +702,7 @@ export function QuotePageClient({
                 </SelectContent>
               </Select>
               {discountType !== "none" && (
-                <Input type="number" min="0" step="0.01" value={discountValue} onChange={(e) => setDiscountValue(e.target.value === "" ? 0 : Number(e.target.value))} className="h-7 w-20 text-right text-xs" />
+                <Input type="number" min="0" step="0.01" value={discountValue} onChange={(e) => { setDiscountValue(e.target.value === "" ? 0 : Number(e.target.value)); markDirty(); }} className="h-7 w-20 text-right text-xs" />
               )}
               {discountType === "percentage" && <span className="text-muted-foreground">%</span>}
             </div>
@@ -645,7 +712,7 @@ export function QuotePageClient({
             <div className="flex items-center justify-between text-sm">
               <div className="flex items-center gap-2">
                 <span className="text-muted-foreground">{t("totals.tax")}</span>
-                <Input type="number" min="0" step="0.1" value={taxRate} onChange={(e) => setTaxRate(e.target.value === "" ? 0 : Number(e.target.value))} className="h-7 w-20 text-right text-xs" />
+                <Input type="number" min="0" step="0.1" value={taxRate} onChange={(e) => { setTaxRate(e.target.value === "" ? 0 : Number(e.target.value)); markDirty(); }} className="h-7 w-20 text-right text-xs" />
                 <span className="text-muted-foreground">%</span>
               </div>
               <span>{formatCurrency(taxAmount, currencyCode)}</span>
@@ -682,7 +749,13 @@ export function QuotePageClient({
             </div>
           </Link>
           <div className="flex shrink-0 items-center gap-2">
-            <Button type="submit" form="quote-form" size="sm" disabled={saving}>
+            {hasUnsavedChanges && (
+              <span className="text-xs font-medium text-amber-600 dark:text-amber-400">{t("page.unsavedChanges")}</span>
+            )}
+            {showSaved && !hasUnsavedChanges && (
+              <span className="text-xs font-medium text-green-600 dark:text-green-400">{t("page.saved")}</span>
+            )}
+            <Button type="submit" form="quote-form" size="sm" disabled={saving} variant={hasUnsavedChanges ? "default" : "outline"} className={hasUnsavedChanges ? "animate-pulse" : ""}>
               {saving ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <Save className="mr-1 h-3.5 w-3.5" />}
               {t("page.save")}
             </Button>
@@ -691,11 +764,11 @@ export function QuotePageClient({
                 {downloading ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <Download className="mr-1 h-3.5 w-3.5" />}
                 {t("page.pdf")}
               </Button>
-              <Button variant="outline" size="sm" onClick={() => setShowEmailDialog(true)}>
+              <Button variant="outline" size="sm" onClick={async () => { if (hasUnsavedChanges) await saveNow(); setShowEmailDialog(true); }}>
                 <Mail className="mr-1 h-3.5 w-3.5" />
                 {t("page.email")}
               </Button>
-              <Button variant="outline" size="sm" onClick={() => setShowShareDialog(true)}>
+              <Button variant="outline" size="sm" onClick={async () => { if (hasUnsavedChanges) await saveNow(); setShowShareDialog(true); }}>
                 <Globe className="mr-1 h-3.5 w-3.5" />
                 {t("page.share")}
               </Button>
@@ -735,7 +808,7 @@ export function QuotePageClient({
 
       {/* Tab Content */}
       {activeTab === "details" && (
-        <form id="quote-form" onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col overflow-hidden">
+        <form id="quote-form" ref={formRef} onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col overflow-hidden">
           {isLarge ? (
             <ResizablePanelGroup orientation="horizontal" className="flex-1 overflow-hidden">
               <ResizablePanel defaultSize={75} minSize={40}>

+ 2 - 0
src/features/quotes/Schema/quoteSchema.ts

@@ -6,6 +6,7 @@ export const quotePartSchema = z.object({
   quantity: z.coerce.number().min(0).default(1),
   unitPrice: z.coerce.number().min(0).default(0),
   total: z.coerce.number().min(0).default(0),
+  excluded: z.boolean().optional().default(false),
 });
 
 export const quoteLaborSchema = z.object({
@@ -13,6 +14,7 @@ export const quoteLaborSchema = z.object({
   hours: z.coerce.number().min(0).default(0),
   rate: z.coerce.number().min(0).default(0),
   total: z.coerce.number().min(0).default(0),
+  excluded: z.boolean().optional().default(false),
 });
 
 export const quoteAttachmentSchema = z.object({

+ 14 - 1
src/features/vehicles/Actions/serviceActions.ts

@@ -598,6 +598,8 @@ export async function getWorkOrders(params: {
   pageSize?: number;
   search?: string;
   status?: string;
+  sortBy?: string;
+  sortOrder?: "asc" | "desc";
 }) {
   return withAuth(async ({ organizationId }) => {
     const page = params.page || 1;
@@ -638,7 +640,18 @@ export async function getWorkOrders(params: {
             },
           },
         },
-        orderBy: { serviceDate: "desc" },
+        orderBy: (() => {
+          const dir = params.sortOrder || "desc";
+          switch (params.sortBy) {
+            case "invoiceNumber": return { invoiceNumber: dir };
+            case "title": return { title: dir };
+            case "status": return { status: dir };
+            case "techName": return { techName: dir };
+            case "totalAmount": return { totalAmount: dir };
+            case "serviceDate":
+            default: return { serviceDate: dir };
+          }
+        })(),
         skip,
         take: pageSize,
       }),

+ 101 - 30
src/features/vehicles/Components/service-page/ServicePageClient.tsx

@@ -1,6 +1,6 @@
 'use client'
 
-import { useState, useCallback, useRef } from 'react'
+import { useState, useCallback, useRef, useEffect } from 'react'
 import { useRouter } from 'next/navigation'
 import { toast } from 'sonner'
 import { useGlassModal } from '@/components/glass-modal'
@@ -127,6 +127,57 @@ export function ServicePageClient({
   const [discountType, setDiscountType] = useState<string>(initialData.discountType || 'none')
   const [discountValue, setDiscountValue] = useState(initialData.discountValue ?? 0)
   const [showInventoryPicker, setShowInventoryPicker] = useState(false)
+  // Detail-page state
+  const [downloading, setDownloading] = useState(false)
+  const [deletingAttachment, setDeletingAttachment] = useState<string | null>(null)
+  const [carouselIndex, setCarouselIndex] = useState<number | null>(null)
+  const [paymentLoading, setPaymentLoading] = useState(false)
+  const [deletingPayment, setDeletingPayment] = useState<string | null>(null)
+  const [showShareDialog, setShowShareDialog] = useState(false)
+  const [showEmailDialog, setShowEmailDialog] = useState(false)
+  const [showPaymentNotifyDialog, setShowPaymentNotifyDialog] = useState(false)
+  const [paymentNotifyMessage, setPaymentNotifyMessage] = useState('')
+
+  // Autosave state
+  const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
+  const [showSaved, setShowSaved] = useState(false)
+  const formRef = useRef<HTMLFormElement>(null)
+  const autosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
+  const isSavingRef = useRef(false)
+  const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
+
+  const markDirty = useCallback(() => {
+    setHasUnsavedChanges(true)
+    if (autosaveTimer.current) clearTimeout(autosaveTimer.current)
+    autosaveTimer.current = setTimeout(() => {
+      if (!isSavingRef.current && formRef.current) {
+        formRef.current.requestSubmit()
+      }
+    }, 5000)
+  }, [])
+
+  useEffect(() => {
+    if (!hasUnsavedChanges) return
+    const handler = (e: BeforeUnloadEvent) => {
+      e.preventDefault()
+    }
+    window.addEventListener('beforeunload', handler)
+    return () => window.removeEventListener('beforeunload', handler)
+  }, [hasUnsavedChanges])
+
+  const saveNow = async () => {
+    if (!formRef.current || isSavingRef.current) return
+    if (autosaveTimer.current) clearTimeout(autosaveTimer.current)
+    formRef.current.requestSubmit()
+    await new Promise<void>((resolve) => {
+      const check = () => {
+        if (!isSavingRef.current) return resolve()
+        setTimeout(check, 50)
+      }
+      setTimeout(check, 50)
+    })
+  }
+
   const notesRef = useRef({
     invoiceNotes: initialData.invoiceNotes || '',
     diagnosticNotes: initialData.diagnosticNotes || '',
@@ -136,8 +187,9 @@ export function ServicePageClient({
   const handleNotesChange = useCallback(
     (field: 'invoiceNotes' | 'diagnosticNotes' | 'description', value: string) => {
       notesRef.current[field] = value
+      markDirty()
     },
-    []
+    [markDirty]
   )
 
   // Computed totals
@@ -153,17 +205,6 @@ export function ServicePageClient({
   const taxAmount = (subtotal - discountAmount) * (taxRate / 100)
   const totalAmount = subtotal - discountAmount + taxAmount
 
-  // Detail-page state
-  const [downloading, setDownloading] = useState(false)
-  const [deletingAttachment, setDeletingAttachment] = useState<string | null>(null)
-  const [carouselIndex, setCarouselIndex] = useState<number | null>(null)
-  const [paymentLoading, setPaymentLoading] = useState(false)
-  const [deletingPayment, setDeletingPayment] = useState<string | null>(null)
-  const [showShareDialog, setShowShareDialog] = useState(false)
-  const [showEmailDialog, setShowEmailDialog] = useState(false)
-  const [showPaymentNotifyDialog, setShowPaymentNotifyDialog] = useState(false)
-  const [paymentNotifyMessage, setPaymentNotifyMessage] = useState('')
-
   const displayTotal = totalAmount > 0 ? totalAmount : record.cost
   const paidFromPayments = record.payments?.reduce((sum, p) => sum + p.amount, 0) || 0
   const totalPaid = record.manuallyPaid ? displayTotal : paidFromPayments
@@ -190,8 +231,9 @@ export function ServicePageClient({
         updated[index] = part
         return updated
       })
+      markDirty()
     },
-    []
+    [markDirty]
   )
 
   const updateLabor = useCallback(
@@ -205,13 +247,36 @@ export function ServicePageClient({
         updated[index] = labor
         return updated
       })
+      markDirty()
     },
-    []
+    [markDirty]
   )
 
+  // Wrapped setters that trigger autosave
+  const dirtySetPartItems: typeof setPartItems = useCallback((action) => {
+    setPartItems(action)
+    markDirty()
+  }, [markDirty])
+
+  const dirtySetLaborItems: typeof setLaborItems = useCallback((action) => {
+    setLaborItems(action)
+    markDirty()
+  }, [markDirty])
+
+  const dirtySetDiscountType = useCallback((v: string) => { setDiscountType(v); markDirty() }, [markDirty])
+  const dirtySetDiscountValue = useCallback((v: number) => { setDiscountValue(v); markDirty() }, [markDirty])
+  const dirtySetTaxRate = useCallback((v: number) => { setTaxRate(v); markDirty() }, [markDirty])
+  const dirtySetType = useCallback((v: string) => { setType(v); markDirty() }, [markDirty])
+  const dirtySetStatus = useCallback((v: string) => { setStatus(v); markDirty() }, [markDirty])
+  const dirtySetTechName = useCallback((v: string) => { setTechName(v); markDirty() }, [markDirty])
+  const dirtySetSelectedVehicleId = useCallback((v: string) => { setSelectedVehicleId(v); markDirty() }, [markDirty])
+
   // Form submit
   const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
     e.preventDefault()
+    if (isSavingRef.current) return
+    if (autosaveTimer.current) clearTimeout(autosaveTimer.current)
+    isSavingRef.current = true
     setLoading(true)
 
     const formData = new FormData(e.currentTarget)
@@ -243,7 +308,10 @@ export function ServicePageClient({
     const result = await updateServiceRecord(payload)
 
     if (result.success) {
-      toast.success(t('page.updated'))
+      setHasUnsavedChanges(false)
+      if (savedTimerRef.current) clearTimeout(savedTimerRef.current)
+      setShowSaved(true)
+      savedTimerRef.current = setTimeout(() => setShowSaved(false), 2000)
       if (selectedVehicleId !== vehicleId) {
         router.push(`/vehicles/${selectedVehicleId}/service/${initialData.id}`)
       } else {
@@ -253,6 +321,7 @@ export function ServicePageClient({
       modal.open('error', tc('errors.error'), result.error || t('page.failedUpdate'))
     }
 
+    isSavingRef.current = false
     setLoading(false)
   }
 
@@ -399,7 +468,7 @@ export function ServicePageClient({
     <div className="space-y-3">
       <PartsEditor
         partItems={partItems}
-        setPartItems={setPartItems}
+        setPartItems={dirtySetPartItems}
         updatePart={updatePart}
         partsSubtotal={partsSubtotal}
         currencyCode={currencyCode}
@@ -408,7 +477,7 @@ export function ServicePageClient({
       />
       <LaborEditor
         laborItems={laborItems}
-        setLaborItems={setLaborItems}
+        setLaborItems={dirtySetLaborItems}
         updateLabor={updateLabor}
         laborSubtotal={laborSubtotal}
         currencyCode={currencyCode}
@@ -472,16 +541,16 @@ export function ServicePageClient({
         vehicleId={vehicleId}
         vehicleName={vehicleName}
         selectedVehicleId={selectedVehicleId}
-        setSelectedVehicleId={setSelectedVehicleId}
+        setSelectedVehicleId={dirtySetSelectedVehicleId}
         vehicles={vehicles}
         vehicleOpen={vehicleOpen}
         setVehicleOpen={setVehicleOpen}
         type={type}
-        setType={setType}
+        setType={dirtySetType}
         status={status}
-        setStatus={setStatus}
+        setStatus={dirtySetStatus}
         techName={techName}
-        setTechName={setTechName}
+        setTechName={dirtySetTechName}
         techOpen={techOpen}
         setTechOpen={setTechOpen}
         teamMembers={teamMembers}
@@ -493,13 +562,13 @@ export function ServicePageClient({
         laborSubtotal={laborSubtotal}
         subtotal={subtotal}
         discountType={discountType}
-        setDiscountType={setDiscountType}
+        setDiscountType={dirtySetDiscountType}
         discountValue={discountValue}
-        setDiscountValue={setDiscountValue}
+        setDiscountValue={dirtySetDiscountValue}
         discountAmount={discountAmount}
         taxEnabled={taxEnabled}
         taxRate={taxRate}
-        setTaxRate={setTaxRate}
+        setTaxRate={dirtySetTaxRate}
         taxAmount={taxAmount}
         totalAmount={totalAmount}
         currencyCode={currencyCode}
@@ -531,14 +600,16 @@ export function ServicePageClient({
         }}
         downloading={downloading}
         saving={loading}
-        onDownloadPDF={handleDownloadPDF}
+        hasUnsavedChanges={hasUnsavedChanges}
+        showSaved={showSaved}
+        onDownloadPDF={async () => { if (hasUnsavedChanges) await saveNow(); handleDownloadPDF() }}
         onDelete={handleDelete}
-        onShowEmail={() => setShowEmailDialog(true)}
-        onShowShare={() => setShowShareDialog(true)}
+        onShowEmail={async () => { if (hasUnsavedChanges) await saveNow(); setShowEmailDialog(true) }}
+        onShowShare={async () => { if (hasUnsavedChanges) await saveNow(); setShowShareDialog(true) }}
       />
 
       {activeTab === 'details' && (
-        <form id="service-record-form" onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col">
+        <form id="service-record-form" ref={formRef} onSubmit={handleSubmit} onInput={markDirty} className="flex min-h-0 flex-1 flex-col">
           <ServiceDetailContent leftColumn={detailsLeftColumn} rightColumn={detailsRightColumn} />
         </form>
       )}
@@ -578,7 +649,7 @@ export function ServicePageClient({
         onOpenChange={setShowInventoryPicker}
         inventoryParts={inventoryParts}
         currencyCode={currencyCode}
-        onSelectPart={(part) => setPartItems((prev) => [...prev, part])}
+        onSelectPart={(part) => dirtySetPartItems((prev) => [...prev, part])}
       />
 
       <ImageCarousel

+ 19 - 7
src/features/vehicles/Components/service-page/UnifiedServiceHeader.tsx

@@ -36,6 +36,8 @@ interface UnifiedServiceHeaderProps {
   tabCounts: TabCounts
   downloading: boolean
   saving: boolean
+  hasUnsavedChanges?: boolean
+  showSaved?: boolean
   onDownloadPDF: () => void
   onDelete: () => void
   onShowEmail: () => void
@@ -53,6 +55,8 @@ export function UnifiedServiceHeader({
   tabCounts,
   downloading,
   saving,
+  hasUnsavedChanges = false,
+  showSaved = false,
   onDownloadPDF,
   onDelete,
   onShowEmail,
@@ -97,14 +101,22 @@ export function UnifiedServiceHeader({
         </Link>
         <div className="flex shrink-0 items-center gap-2">
           {activeTab === 'details' && (
-            <Button type="submit" form="service-record-form" size="sm" disabled={saving}>
-              {saving ? (
-                <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
-              ) : (
-                <Save className="mr-1 h-3.5 w-3.5" />
+            <>
+              {hasUnsavedChanges && (
+                <span className="text-xs font-medium text-amber-600 dark:text-amber-400">{t('unsavedChanges')}</span>
               )}
-              {t('save')}
-            </Button>
+              {showSaved && !hasUnsavedChanges && (
+                <span className="text-xs font-medium text-green-600 dark:text-green-400">{t('saved')}</span>
+              )}
+              <Button type="submit" form="service-record-form" size="sm" disabled={saving} variant={hasUnsavedChanges ? "default" : "outline"} className={hasUnsavedChanges ? "animate-pulse" : ""}>
+                {saving ? (
+                  <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
+                ) : (
+                  <Save className="mr-1 h-3.5 w-3.5" />
+                )}
+                {t('save')}
+              </Button>
+            </>
           )}
           <ButtonGroup>
             <Button variant="outline" size="sm" onClick={onDownloadPDF} disabled={downloading}>