ソースを参照

Inventory: stock ledger, barcode uniqueness, low-stock alerts (#199)

* lice tracking

* inventory

* docs

* read more
Bernt Christian Egeland 2 ヶ月 前
親
コミット
9ed85ab553
100 ファイル変更、4089 行追加、276 行削除
  1. 2 1
      messages/de/dashboard.json
  2. 27 3
      messages/de/inventory.json
  3. 2 1
      messages/de/quotes.json
  4. 31 0
      messages/de/settings.json
  5. 2 1
      messages/en/dashboard.json
  6. 27 3
      messages/en/inventory.json
  7. 2 1
      messages/en/quotes.json
  8. 31 0
      messages/en/settings.json
  9. 2 1
      messages/es/dashboard.json
  10. 27 3
      messages/es/inventory.json
  11. 2 1
      messages/es/quotes.json
  12. 31 0
      messages/es/settings.json
  13. 2 1
      messages/fr/dashboard.json
  14. 27 3
      messages/fr/inventory.json
  15. 2 1
      messages/fr/quotes.json
  16. 31 0
      messages/fr/settings.json
  17. 2 1
      messages/it/dashboard.json
  18. 27 3
      messages/it/inventory.json
  19. 2 1
      messages/it/quotes.json
  20. 31 0
      messages/it/settings.json
  21. 2 1
      messages/lt/dashboard.json
  22. 27 3
      messages/lt/inventory.json
  23. 2 1
      messages/lt/quotes.json
  24. 31 0
      messages/lt/settings.json
  25. 2 1
      messages/nb/dashboard.json
  26. 27 3
      messages/nb/inventory.json
  27. 2 1
      messages/nb/quotes.json
  28. 31 0
      messages/nb/settings.json
  29. 2 1
      messages/nl/dashboard.json
  30. 27 3
      messages/nl/inventory.json
  31. 2 1
      messages/nl/quotes.json
  32. 31 0
      messages/nl/settings.json
  33. 2 1
      messages/pl/dashboard.json
  34. 27 3
      messages/pl/inventory.json
  35. 2 1
      messages/pl/quotes.json
  36. 31 0
      messages/pl/settings.json
  37. 2 1
      messages/pt-BR/dashboard.json
  38. 27 3
      messages/pt-BR/inventory.json
  39. 2 1
      messages/pt-BR/quotes.json
  40. 31 0
      messages/pt-BR/settings.json
  41. 2 1
      messages/ru/dashboard.json
  42. 27 3
      messages/ru/inventory.json
  43. 2 1
      messages/ru/quotes.json
  44. 31 0
      messages/ru/settings.json
  45. 2 1
      messages/tr/dashboard.json
  46. 27 3
      messages/tr/inventory.json
  47. 2 1
      messages/tr/quotes.json
  48. 31 0
      messages/tr/settings.json
  49. 117 0
      prisma/migrations/20260727200000_inventory_ledger_and_alerts/migration.sql
  50. 65 6
      prisma/schema.prisma
  51. 158 0
      src/__tests__/features/inventory/barcode-conflict.test.ts
  52. 199 0
      src/__tests__/features/inventory/low-stock-alerts.test.ts
  53. 152 0
      src/__tests__/features/inventory/low-stock-email-isolation.test.ts
  54. 159 59
      src/__tests__/features/inventory/reconcile-stock.test.ts
  55. 222 0
      src/__tests__/features/quotes/convert-quote-inventory.test.ts
  56. 38 15
      src/__tests__/features/workorders/create-workorder.test.ts
  57. 103 0
      src/__tests__/hooks/use-debounced-search.test.ts
  58. 12 6
      src/app/(authenticated)/billing/billing-client.tsx
  59. 9 8
      src/app/(authenticated)/customers/customers-client.tsx
  60. 15 1
      src/app/(authenticated)/dashboard-client.tsx
  61. 9 8
      src/app/(authenticated)/inspections/inspections-client.tsx
  62. 99 0
      src/app/(authenticated)/inventory/[id]/page.tsx
  63. 286 0
      src/app/(authenticated)/inventory/[id]/part-detail-client.tsx
  64. 65 11
      src/app/(authenticated)/inventory/inventory-client.tsx
  65. 15 4
      src/app/(authenticated)/inventory/page.tsx
  66. 9 8
      src/app/(authenticated)/labor-presets/labor-presets-client.tsx
  67. 6 1
      src/app/(authenticated)/quotes/[id]/page.tsx
  68. 9 8
      src/app/(authenticated)/quotes/quotes-client.tsx
  69. 241 0
      src/app/(authenticated)/settings/alerts/alerts-settings.tsx
  70. 16 0
      src/app/(authenticated)/settings/alerts/page.tsx
  71. 2 0
      src/app/(authenticated)/settings/settings-nav.tsx
  72. 9 8
      src/app/(authenticated)/vehicles/[id]/service-records-table.tsx
  73. 9 8
      src/app/(authenticated)/work-orders/work-orders-client.tsx
  74. 1 0
      src/cronTasks.ts
  75. 10 9
      src/features/admin/Components/admin-organizations.tsx
  76. 10 9
      src/features/admin/Components/admin-users.tsx
  77. 166 0
      src/features/inventory/Actions/getStockMovements.ts
  78. 73 13
      src/features/inventory/Actions/inventoryActions.ts
  79. 9 1
      src/features/inventory/Actions/lookupPartByBarcode.ts
  80. 38 0
      src/features/inventory/Actions/runLowStockCheck.ts
  81. 18 4
      src/features/inventory/Components/InventoryPartForm.tsx
  82. 107 0
      src/features/inventory/Lib/barcode.ts
  83. 151 0
      src/features/inventory/Lib/lowStockAlerts.ts
  84. 30 0
      src/features/inventory/Lib/onInventoryChanged.ts
  85. 125 14
      src/features/inventory/Lib/reconcileStock.ts
  86. 17 0
      src/features/inventory/Lib/revalidateInventory.ts
  87. 15 0
      src/features/inventory/Lib/stockMovementReasons.ts
  88. 18 0
      src/features/quotes/Actions/quoteActions.ts
  89. 6 0
      src/features/quotes/Components/QuotePageClient.tsx
  90. 36 2
      src/features/quotes/Components/QuotePartsEditor.tsx
  91. 3 1
      src/features/quotes/Components/quote-page-types.ts
  92. 1 1
      src/features/quotes/Components/useQuoteFormState.ts
  93. 5 0
      src/features/quotes/Schema/quoteSchema.ts
  94. 10 0
      src/features/settings/Schema/settingsSchema.ts
  95. 16 5
      src/features/vehicles/Actions/addPartToServiceRecord.ts
  96. 24 0
      src/features/vehicles/Actions/dashboardActions.ts
  97. 28 6
      src/features/vehicles/Actions/serviceActions.ts
  98. 99 0
      src/hooks/use-debounced-search.ts
  99. 2 0
      src/instrumentation.ts
  100. 303 0
      src/lib/cron/low-stock-alerts.ts

+ 2 - 1
messages/de/dashboard.json

@@ -36,7 +36,8 @@
     "activeJobs": "Aktive Aufträge",
     "pending": "Ausstehend",
     "totalParts": "Teile gesamt",
-    "totalCustomers": "Kunden gesamt"
+    "totalCustomers": "Kunden gesamt",
+    "lowStock": "Geringer Bestand"
   },
   "customize": "Anpassen",
   "viewAll": "Alle anzeigen",

+ 27 - 3
messages/de/inventory.json

@@ -18,11 +18,15 @@
   },
   "empty": {
     "noMatch": "Keine Teile entsprechen Ihrer Suche.",
-    "noParts": "Noch keine Teile im Lager."
+    "noParts": "Noch keine Teile im Lager.",
+    "noLowStock": "Derzeit liegt nichts unter dem Meldebestand.",
+    "noReorderPoints": "Es sind keine Meldebestände gesetzt, daher kann kein Teil als knapp gelten.",
+    "configureReorderPoints": "Standard-Meldebestand festlegen"
   },
   "actions": {
     "edit": "Bearbeiten",
-    "delete": "Löschen"
+    "delete": "Löschen",
+    "details": "Details & Verlauf"
   },
   "deletePart": {
     "title": "Teil löschen",
@@ -119,5 +123,25 @@
     "saveChanges": "Änderungen speichern",
     "addPart": "Teil hinzufügen"
   },
-  "openMenu": "Open menu"
+  "openMenu": "Open menu",
+  "history": {
+    "title": "Bestandsverlauf",
+    "description": "Alle erfassten Änderungen an {name}, neueste zuerst.",
+    "empty": "Noch keine Bestandsbewegungen erfasst.",
+    "when": "Datum",
+    "change": "Änderung",
+    "balance": "Bestand",
+    "usedOn": "Verwendet in",
+    "by": "Von",
+    "reasons": {
+      "service_record": "Auftrag",
+      "service_record_deleted": "Auftrag gelöscht",
+      "quote_conversion": "Angebot umgewandelt",
+      "manual_adjustment": "Manuelle Anpassung",
+      "bulk_markup": "Massenaufschlag"
+    },
+    "backToInventory": "Lager",
+    "reason": "Grund",
+    "allReasons": "Alle Gründe"
+  }
 }

+ 2 - 1
messages/de/quotes.json

@@ -72,7 +72,8 @@
     "total": "Gesamt",
     "subtotal": "Teile Zwischensumme: {amount}",
     "deleteRow": "Delete row",
-    "excludeFromTotal": "Exclude from total"
+    "excludeFromTotal": "Exclude from total",
+    "addFromInventory": "Aus Lager"
   },
   "labor": {
     "title": "Arbeitszeit",

+ 31 - 0
messages/de/settings.json

@@ -110,6 +110,10 @@
       "about": {
         "title": "Info",
         "description": "Version & Informationen"
+      },
+      "alerts": {
+        "title": "Warnungen",
+        "description": "Benachrichtigung bei Nachbestellbedarf"
       }
     },
     "pro": "PRO"
@@ -1339,5 +1343,32 @@
       "retrying": "Wiederholt",
       "pending": "Ausstehend"
     }
+  },
+  "alerts": {
+    "title": "Warnungen",
+    "description": "Wählen Sie, über welche Ereignisse Sie wie informiert werden.",
+    "lowStock": {
+      "title": "Bestandswarnungen",
+      "description": "Warnt, wenn ein Teil seinen Meldebestand erreicht. Ein Teil gilt als knapp, wenn der Bestand auf oder unter die \"Mindestmenge\" fällt — oder unter den Standardwert unten, falls keine gesetzt ist. Jedes Teil wird pro Unterschreitung einmal gemeldet und bleibt bis zum Auffüllen still.",
+      "enable": "Bestandswarnungen aktivieren",
+      "enableHint": "Standardmäßig aus. Nur Teile mit Meldebestand werden überwacht.",
+      "inApp": "In-App-Benachrichtigung",
+      "inAppHint": "Eine gebündelte Benachrichtigung in der Glocke, nicht eine pro Teil.",
+      "email": "E-Mail-Zusammenfassung",
+      "emailHint": "Eine einzige E-Mail mit allen neu unterschrittenen Teilen.",
+      "emailInterval": "Mindeststunden zwischen E-Mails",
+      "emailIntervalHint": "Höchstens eine Zusammenfassung pro so vielen Stunden. 0 hebt das Limit auf.",
+      "save": "Warneinstellungen speichern",
+      "saved": "Bestandswarnungen gespeichert",
+      "defaultThreshold": "Standard-Meldebestand",
+      "defaultThresholdHint": "Gilt nur für Teile ohne eigenen Meldebestand. Die \"Mindestmenge\" eines Teils — beim Bearbeiten des Teils gesetzt — hat immer Vorrang. Bei 0 werden nur individuell konfigurierte Teile überwacht.",
+      "checkNow": "Jetzt prüfen",
+      "checkDisabled": "Warnungen sind deaktiviert.",
+      "checkNothingNew": "Nichts Neues — alle knappen Teile wurden bereits gemeldet.",
+      "checkAlerted": "{count} neu unterschrittene(s) Teil(e) gemeldet",
+      "checkFailed": "Prüfung fehlgeschlagen",
+      "configurePerPart": "Mindestmenge eines Teils auf der Lagerseite festlegen →",
+      "readMore": "Mehr erfahren"
+    }
   }
 }

+ 2 - 1
messages/en/dashboard.json

@@ -36,7 +36,8 @@
     "activeJobs": "Active Jobs",
     "pending": "Pending",
     "totalParts": "Total Parts",
-    "totalCustomers": "Total Customers"
+    "totalCustomers": "Total Customers",
+    "lowStock": "Low stock"
   },
   "customize": "Customize",
   "viewAll": "View All",

+ 27 - 3
messages/en/inventory.json

@@ -18,11 +18,15 @@
   },
   "empty": {
     "noMatch": "No parts match your search.",
-    "noParts": "No parts in inventory yet."
+    "noParts": "No parts in inventory yet.",
+    "noLowStock": "Nothing is below its reorder point right now.",
+    "noReorderPoints": "No reorder points are set, so no part can count as low.",
+    "configureReorderPoints": "Set a default reorder point"
   },
   "actions": {
     "edit": "Edit",
-    "delete": "Delete"
+    "delete": "Delete",
+    "details": "Details & history"
   },
   "deletePart": {
     "title": "Delete Part",
@@ -119,5 +123,25 @@
     "saveChanges": "Save Changes",
     "addPart": "Add Part"
   },
-  "openMenu": "Open menu"
+  "openMenu": "Open menu",
+  "history": {
+    "title": "Stock history",
+    "description": "Every recorded change to {name}, newest first.",
+    "empty": "No stock movements recorded yet.",
+    "when": "Date",
+    "change": "Change",
+    "balance": "Balance",
+    "usedOn": "Used on",
+    "by": "By",
+    "reasons": {
+      "service_record": "Work order",
+      "service_record_deleted": "Work order deleted",
+      "quote_conversion": "Quote converted",
+      "manual_adjustment": "Manual adjustment",
+      "bulk_markup": "Bulk markup"
+    },
+    "backToInventory": "Inventory",
+    "reason": "Reason",
+    "allReasons": "All reasons"
+  }
 }

+ 2 - 1
messages/en/quotes.json

@@ -72,7 +72,8 @@
     "total": "Total",
     "subtotal": "Parts Subtotal: {amount}",
     "deleteRow": "Delete row",
-    "excludeFromTotal": "Exclude from total"
+    "excludeFromTotal": "Exclude from total",
+    "addFromInventory": "From stock"
   },
   "labor": {
     "title": "Labor & Services",

+ 31 - 0
messages/en/settings.json

@@ -110,6 +110,10 @@
       "about": {
         "title": "About",
         "description": "Version & info"
+      },
+      "alerts": {
+        "title": "Alerts",
+        "description": "Get told when parts need reordering"
       }
     },
     "pro": "PRO"
@@ -1339,5 +1343,32 @@
       "retrying": "Retrying",
       "pending": "Pending"
     }
+  },
+  "alerts": {
+    "title": "Alerts",
+    "description": "Choose which events you want to be told about, and how.",
+    "lowStock": {
+      "title": "Low stock alerts",
+      "description": "Alerts when a part reaches its reorder point. A part is low when its quantity drops to or below its \"Min Qty\", or the default below if it has none. Each part is reported once per dip and stays quiet until it is restocked.",
+      "enable": "Enable low stock alerts",
+      "enableHint": "Off by default. Only parts with a reorder point set are watched.",
+      "inApp": "In-app notification",
+      "inAppHint": "One grouped notification in the bell, not one per part.",
+      "email": "Email digest",
+      "emailHint": "A single email listing every part that just went low.",
+      "emailInterval": "Minimum hours between emails",
+      "emailIntervalHint": "At most one digest per this many hours. 0 disables the limit.",
+      "save": "Save alert settings",
+      "saved": "Low stock alert settings saved",
+      "defaultThreshold": "Default reorder point",
+      "defaultThresholdHint": "Used only for parts that have no reorder point of their own. A part's own \"Min Qty\" — set when you edit that part — always overrides this. Leave at 0 to watch only the parts you have configured individually.",
+      "checkNow": "Check now",
+      "checkDisabled": "Alerts are turned off.",
+      "checkNothingNew": "Nothing new — every low part has already been reported.",
+      "checkAlerted": "{count} newly low part(s) reported",
+      "checkFailed": "Check failed",
+      "configurePerPart": "Set a part's own Min Qty on the inventory page →",
+      "readMore": "Read More"
+    }
   }
 }

+ 2 - 1
messages/es/dashboard.json

@@ -36,7 +36,8 @@
     "activeJobs": "Trabajos activos",
     "pending": "Pendientes",
     "totalParts": "Total de piezas",
-    "totalCustomers": "Total de clientes"
+    "totalCustomers": "Total de clientes",
+    "lowStock": "Stock bajo"
   },
   "customize": "Personalizar",
   "viewAll": "Ver todo",

+ 27 - 3
messages/es/inventory.json

@@ -18,11 +18,15 @@
   },
   "empty": {
     "noMatch": "Ninguna pieza coincide con su búsqueda.",
-    "noParts": "Aún no hay piezas en el inventario."
+    "noParts": "Aún no hay piezas en el inventario.",
+    "noLowStock": "Ahora mismo nada está por debajo de su punto de pedido.",
+    "noReorderPoints": "No hay puntos de pedido definidos, así que ninguna pieza puede considerarse baja.",
+    "configureReorderPoints": "Definir un punto de pedido predeterminado"
   },
   "actions": {
     "edit": "Editar",
-    "delete": "Eliminar"
+    "delete": "Eliminar",
+    "details": "Detalles e historial"
   },
   "deletePart": {
     "title": "Eliminar pieza",
@@ -119,5 +123,25 @@
     "saveChanges": "Guardar cambios",
     "addPart": "Añadir pieza"
   },
-  "openMenu": "Open menu"
+  "openMenu": "Open menu",
+  "history": {
+    "title": "Historial de stock",
+    "description": "Todos los cambios registrados en {name}, más recientes primero.",
+    "empty": "Aún no hay movimientos de stock.",
+    "when": "Fecha",
+    "change": "Cambio",
+    "balance": "Saldo",
+    "usedOn": "Usado en",
+    "by": "Por",
+    "reasons": {
+      "service_record": "Orden de trabajo",
+      "service_record_deleted": "Orden eliminada",
+      "quote_conversion": "Presupuesto convertido",
+      "manual_adjustment": "Ajuste manual",
+      "bulk_markup": "Margen masivo"
+    },
+    "backToInventory": "Inventario",
+    "reason": "Motivo",
+    "allReasons": "Todos los motivos"
+  }
 }

+ 2 - 1
messages/es/quotes.json

@@ -72,7 +72,8 @@
     "total": "Total",
     "subtotal": "Subtotal de repuestos: {amount}",
     "deleteRow": "Delete row",
-    "excludeFromTotal": "Exclude from total"
+    "excludeFromTotal": "Exclude from total",
+    "addFromInventory": "Del inventario"
   },
   "labor": {
     "title": "Mano de obra",

+ 31 - 0
messages/es/settings.json

@@ -110,6 +110,10 @@
       "about": {
         "title": "Acerca de",
         "description": "Version e informacion"
+      },
+      "alerts": {
+        "title": "Alertas",
+        "description": "Avisos cuando haya que reponer piezas"
       }
     },
     "pro": "PRO"
@@ -1339,5 +1343,32 @@
       "retrying": "Reintentando",
       "pending": "Pendiente"
     }
+  },
+  "alerts": {
+    "title": "Alertas",
+    "description": "Elija sobre qué eventos quiere recibir avisos y cómo.",
+    "lowStock": {
+      "title": "Alertas de stock bajo",
+      "description": "Avisa cuando una pieza alcanza su punto de pedido. Una pieza está baja cuando su cantidad cae a o por debajo de su \"Cant. mínima\", o del valor predeterminado si no tiene. Cada pieza se informa una vez por caída y no vuelve a avisar hasta reponerla.",
+      "enable": "Activar alertas de stock bajo",
+      "enableHint": "Desactivado por defecto. Solo se vigilan piezas con punto de pedido.",
+      "inApp": "Notificación en la app",
+      "inAppHint": "Una notificación agrupada en la campana, no una por pieza.",
+      "email": "Resumen por correo",
+      "emailHint": "Un único correo con todas las piezas que acaban de bajar.",
+      "emailInterval": "Horas mínimas entre correos",
+      "emailIntervalHint": "Como máximo un resumen cada tantas horas. 0 desactiva el límite.",
+      "save": "Guardar alertas",
+      "saved": "Alertas de stock bajo guardadas",
+      "defaultThreshold": "Punto de pedido predeterminado",
+      "defaultThresholdHint": "Solo se usa para piezas sin punto de pedido propio. La \"Cant. mínima\" de la pieza — definida al editarla — siempre tiene prioridad. Deje 0 para vigilar únicamente las piezas configuradas individualmente.",
+      "checkNow": "Comprobar ahora",
+      "checkDisabled": "Las alertas están desactivadas.",
+      "checkNothingNew": "Nada nuevo: todas las piezas bajas ya se informaron.",
+      "checkAlerted": "{count} pieza(s) recién baja(s) informada(s)",
+      "checkFailed": "Error al comprobar",
+      "configurePerPart": "Defina la cantidad mínima de una pieza en la página de inventario →",
+      "readMore": "Leer más"
+    }
   }
 }

+ 2 - 1
messages/fr/dashboard.json

@@ -36,7 +36,8 @@
     "activeJobs": "Travaux en cours",
     "pending": "En attente",
     "totalParts": "Total des pièces",
-    "totalCustomers": "Total des clients"
+    "totalCustomers": "Total des clients",
+    "lowStock": "Stock faible"
   },
   "customize": "Personnaliser",
   "viewAll": "Tout voir",

+ 27 - 3
messages/fr/inventory.json

@@ -18,11 +18,15 @@
   },
   "empty": {
     "noMatch": "Aucune pièce ne correspond à votre recherche.",
-    "noParts": "Aucune pièce dans l'inventaire pour l'instant."
+    "noParts": "Aucune pièce dans l'inventaire pour l'instant.",
+    "noLowStock": "Rien n'est actuellement sous son seuil de réapprovisionnement.",
+    "noReorderPoints": "Aucun seuil n'est défini, donc aucune pièce ne peut être considérée comme basse.",
+    "configureReorderPoints": "Définir un seuil par défaut"
   },
   "actions": {
     "edit": "Modifier",
-    "delete": "Supprimer"
+    "delete": "Supprimer",
+    "details": "Détails et historique"
   },
   "deletePart": {
     "title": "Supprimer la pièce",
@@ -119,5 +123,25 @@
     "saveChanges": "Enregistrer les modifications",
     "addPart": "Ajouter une pièce"
   },
-  "openMenu": "Open menu"
+  "openMenu": "Open menu",
+  "history": {
+    "title": "Historique du stock",
+    "description": "Toutes les modifications enregistrées de {name}, les plus récentes d'abord.",
+    "empty": "Aucun mouvement de stock enregistré.",
+    "when": "Date",
+    "change": "Variation",
+    "balance": "Solde",
+    "usedOn": "Utilisé sur",
+    "by": "Par",
+    "reasons": {
+      "service_record": "Ordre de travail",
+      "service_record_deleted": "Ordre supprimé",
+      "quote_conversion": "Devis converti",
+      "manual_adjustment": "Ajustement manuel",
+      "bulk_markup": "Marge en masse"
+    },
+    "backToInventory": "Inventaire",
+    "reason": "Motif",
+    "allReasons": "Tous les motifs"
+  }
 }

+ 2 - 1
messages/fr/quotes.json

@@ -72,7 +72,8 @@
     "total": "Total",
     "subtotal": "Sous-total pièces : {amount}",
     "deleteRow": "Delete row",
-    "excludeFromTotal": "Exclude from total"
+    "excludeFromTotal": "Exclude from total",
+    "addFromInventory": "Du stock"
   },
   "labor": {
     "title": "Main-d'œuvre",

+ 31 - 0
messages/fr/settings.json

@@ -110,6 +110,10 @@
       "about": {
         "title": "A propos",
         "description": "Version et informations"
+      },
+      "alerts": {
+        "title": "Alertes",
+        "description": "Être averti quand il faut réapprovisionner"
       }
     },
     "pro": "PRO"
@@ -1339,5 +1343,32 @@
       "retrying": "Nouvelle tentative",
       "pending": "En attente"
     }
+  },
+  "alerts": {
+    "title": "Alertes",
+    "description": "Choisissez les événements dont vous voulez être averti, et comment.",
+    "lowStock": {
+      "title": "Alertes de stock bas",
+      "description": "Alerte quand une pièce atteint son seuil de réapprovisionnement. Une pièce est basse lorsque sa quantité descend à ou sous sa \"Qté min\", ou sous la valeur par défaut ci-dessous si elle n'en a pas. Chaque pièce est signalée une fois par baisse et reste silencieuse jusqu'au réassort.",
+      "enable": "Activer les alertes de stock bas",
+      "enableHint": "Désactivé par défaut. Seules les pièces avec un seuil défini sont surveillées.",
+      "inApp": "Notification dans l'app",
+      "inAppHint": "Une notification groupée dans la cloche, pas une par pièce.",
+      "email": "Résumé par e-mail",
+      "emailHint": "Un seul e-mail listant toutes les pièces passées sous le seuil.",
+      "emailInterval": "Heures minimum entre e-mails",
+      "emailIntervalHint": "Au plus un résumé par tranche d'heures. 0 supprime la limite.",
+      "save": "Enregistrer les alertes",
+      "saved": "Alertes de stock bas enregistrées",
+      "defaultThreshold": "Seuil de réapprovisionnement par défaut",
+      "defaultThresholdHint": "Utilisé uniquement pour les pièces sans seuil propre. La \"Qté min\" d'une pièce — définie lors de sa modification — est toujours prioritaire. Laissez 0 pour ne surveiller que les pièces configurées individuellement.",
+      "checkNow": "Vérifier maintenant",
+      "checkDisabled": "Les alertes sont désactivées.",
+      "checkNothingNew": "Rien de nouveau — toutes les pièces basses ont déjà été signalées.",
+      "checkAlerted": "{count} pièce(s) nouvellement basse(s) signalée(s)",
+      "checkFailed": "Échec de la vérification",
+      "configurePerPart": "Définir la Qté min d'une pièce sur la page inventaire →",
+      "readMore": "En savoir plus"
+    }
   }
 }

+ 2 - 1
messages/it/dashboard.json

@@ -36,7 +36,8 @@
     "activeJobs": "Lavori attivi",
     "pending": "In attesa",
     "totalParts": "Ricambi totali",
-    "totalCustomers": "Clienti totali"
+    "totalCustomers": "Clienti totali",
+    "lowStock": "Scorte basse"
   },
   "customize": "Personalizza",
   "viewAll": "Vedi tutto",

+ 27 - 3
messages/it/inventory.json

@@ -18,11 +18,15 @@
   },
   "empty": {
     "noMatch": "Nessun ricambio corrisponde alla ricerca.",
-    "noParts": "Nessun ricambio in inventario."
+    "noParts": "Nessun ricambio in inventario.",
+    "noLowStock": "Al momento nulla è sotto il punto di riordino.",
+    "noReorderPoints": "Non è impostato alcun punto di riordino, quindi nessun ricambio può risultare basso.",
+    "configureReorderPoints": "Imposta un punto di riordino predefinito"
   },
   "actions": {
     "edit": "Modifica",
-    "delete": "Elimina"
+    "delete": "Elimina",
+    "details": "Dettagli e storico"
   },
   "deletePart": {
     "title": "Elimina ricambio",
@@ -119,5 +123,25 @@
     "saveChanges": "Salva modifiche",
     "addPart": "Aggiungi ricambio"
   },
-  "openMenu": "Open menu"
+  "openMenu": "Open menu",
+  "history": {
+    "title": "Storico magazzino",
+    "description": "Tutte le modifiche registrate a {name}, dalla più recente.",
+    "empty": "Nessun movimento di magazzino registrato.",
+    "when": "Data",
+    "change": "Variazione",
+    "balance": "Saldo",
+    "usedOn": "Usato in",
+    "by": "Da",
+    "reasons": {
+      "service_record": "Ordine di lavoro",
+      "service_record_deleted": "Ordine eliminato",
+      "quote_conversion": "Preventivo convertito",
+      "manual_adjustment": "Rettifica manuale",
+      "bulk_markup": "Ricarico massivo"
+    },
+    "backToInventory": "Magazzino",
+    "reason": "Motivo",
+    "allReasons": "Tutti i motivi"
+  }
 }

+ 2 - 1
messages/it/quotes.json

@@ -72,7 +72,8 @@
     "total": "Totale",
     "subtotal": "Subtotale ricambi: {amount}",
     "deleteRow": "Delete row",
-    "excludeFromTotal": "Exclude from total"
+    "excludeFromTotal": "Exclude from total",
+    "addFromInventory": "Dal magazzino"
   },
   "labor": {
     "title": "Manodopera",

+ 31 - 0
messages/it/settings.json

@@ -110,6 +110,10 @@
       "about": {
         "title": "Informazioni",
         "description": "Versione e info"
+      },
+      "alerts": {
+        "title": "Avvisi",
+        "description": "Segnalazione quando riordinare"
       }
     },
     "pro": "PRO"
@@ -1339,5 +1343,32 @@
       "retrying": "In ritentativo",
       "pending": "In attesa"
     }
+  },
+  "alerts": {
+    "title": "Avvisi",
+    "description": "Scegli di quali eventi vuoi essere avvisato e come.",
+    "lowStock": {
+      "title": "Avvisi scorte basse",
+      "description": "Avvisa quando un ricambio raggiunge il punto di riordino. Un ricambio è basso quando la quantità scende a o sotto la sua \"Qtà min\", o il valore predefinito se non ne ha. Ogni ricambio viene segnalato una volta per calo e resta silenzioso fino al riassortimento.",
+      "enable": "Attiva avvisi scorte basse",
+      "enableHint": "Disattivo di default. Vengono monitorati solo i ricambi con punto di riordino.",
+      "inApp": "Notifica in app",
+      "inAppHint": "Una notifica raggruppata nella campana, non una per ricambio.",
+      "email": "Riepilogo via e-mail",
+      "emailHint": "Una sola e-mail con tutti i ricambi appena scesi sotto soglia.",
+      "emailInterval": "Ore minime tra le e-mail",
+      "emailIntervalHint": "Al massimo un riepilogo ogni tot ore. 0 disattiva il limite.",
+      "save": "Salva avvisi",
+      "saved": "Avvisi scorte basse salvati",
+      "defaultThreshold": "Punto di riordino predefinito",
+      "defaultThresholdHint": "Usato solo per i ricambi senza un punto di riordino proprio. La \"Qtà min\" del ricambio — impostata modificandolo — ha sempre la precedenza. Lascia 0 per monitorare solo i ricambi configurati singolarmente.",
+      "checkNow": "Controlla ora",
+      "checkDisabled": "Gli avvisi sono disattivati.",
+      "checkNothingNew": "Niente di nuovo: tutti i ricambi bassi sono già stati segnalati.",
+      "checkAlerted": "{count} ricambio/i appena sotto soglia segnalato/i",
+      "checkFailed": "Controllo non riuscito",
+      "configurePerPart": "Imposta la Qtà min di un ricambio nella pagina magazzino →",
+      "readMore": "Scopri di più"
+    }
   }
 }

+ 2 - 1
messages/lt/dashboard.json

@@ -36,7 +36,8 @@
     "activeJobs": "Aktyvūs darbai",
     "pending": "Laukiantys",
     "totalParts": "Iš viso dalių",
-    "totalCustomers": "Iš viso klientų"
+    "totalCustomers": "Iš viso klientų",
+    "lowStock": "Mažos atsargos"
   },
   "customize": "Tinkinti",
   "viewAll": "Peržiūrėti visus",

+ 27 - 3
messages/lt/inventory.json

@@ -18,11 +18,15 @@
   },
   "empty": {
     "noMatch": "Nėra dalių, atitinkančių paiešką.",
-    "noParts": "Inventoriuje dalių dar nėra."
+    "noParts": "Inventoriuje dalių dar nėra.",
+    "noLowStock": "Šiuo metu niekas nėra žemiau užsakymo ribos.",
+    "noReorderPoints": "Užsakymo ribų nenustatyta, todėl jokia dalis negali būti laikoma mažėjančia.",
+    "configureReorderPoints": "Nustatyti numatytąją užsakymo ribą"
   },
   "actions": {
     "edit": "Redaguoti",
-    "delete": "Ištrinti"
+    "delete": "Ištrinti",
+    "details": "Informacija ir istorija"
   },
   "deletePart": {
     "title": "Ištrinti dalį",
@@ -119,5 +123,25 @@
     "saveChanges": "Išsaugoti pakeitimus",
     "addPart": "Pridėti dalį"
   },
-  "openMenu": "Open menu"
+  "openMenu": "Open menu",
+  "history": {
+    "title": "Atsargų istorija",
+    "description": "Visi užfiksuoti {name} pakeitimai, naujausi viršuje.",
+    "empty": "Atsargų judėjimo dar nėra.",
+    "when": "Data",
+    "change": "Pokytis",
+    "balance": "Likutis",
+    "usedOn": "Panaudota",
+    "by": "Kas",
+    "reasons": {
+      "service_record": "Darbo užsakymas",
+      "service_record_deleted": "Užsakymas ištrintas",
+      "quote_conversion": "Pasiūlymas konvertuotas",
+      "manual_adjustment": "Rankinis koregavimas",
+      "bulk_markup": "Masinis antkainis"
+    },
+    "backToInventory": "Sandėlis",
+    "reason": "Priežastis",
+    "allReasons": "Visos priežastys"
+  }
 }

+ 2 - 1
messages/lt/quotes.json

@@ -72,7 +72,8 @@
     "total": "Suma",
     "subtotal": "Dalių tarpinė suma: {amount}",
     "deleteRow": "Delete row",
-    "excludeFromTotal": "Exclude from total"
+    "excludeFromTotal": "Exclude from total",
+    "addFromInventory": "Iš sandėlio"
   },
   "labor": {
     "title": "Darbai ir paslaugos",

+ 31 - 0
messages/lt/settings.json

@@ -110,6 +110,10 @@
       "about": {
         "title": "Apie",
         "description": "Versija ir informacija"
+      },
+      "alerts": {
+        "title": "Įspėjimai",
+        "description": "Pranešimai, kada užsakyti dalis"
       }
     },
     "pro": "PRO"
@@ -1339,5 +1343,32 @@
       "retrying": "Bandoma dar kartą",
       "pending": "Laukiama"
     }
+  },
+  "alerts": {
+    "title": "Įspėjimai",
+    "description": "Pasirinkite, apie kuriuos įvykius ir kaip norite gauti pranešimus.",
+    "lowStock": {
+      "title": "Mažų atsargų įspėjimai",
+      "description": "Įspėja, kai dalis pasiekia užsakymo ribą. Dalis laikoma mažėjančia, kai kiekis nukrenta iki arba žemiau \"Min. kiekis\", arba žemiau numatytosios ribos, jei jos nėra. Kiekviena dalis pranešama vieną kartą per kritimą ir tyli, kol bus papildyta.",
+      "enable": "Įjungti mažų atsargų įspėjimus",
+      "enableHint": "Pagal nutylėjimą išjungta. Stebimos tik dalys su nustatyta užsakymo riba.",
+      "inApp": "Pranešimas programoje",
+      "inAppHint": "Vienas bendras pranešimas varpelyje, ne po vieną kiekvienai daliai.",
+      "email": "El. pašto suvestinė",
+      "emailHint": "Vienas laiškas su visomis ką tik sumažėjusiomis dalimis.",
+      "emailInterval": "Minimalus valandų tarpas tarp laiškų",
+      "emailIntervalHint": "Daugiausia viena suvestinė per tiek valandų. 0 panaikina ribą.",
+      "save": "Išsaugoti įspėjimus",
+      "saved": "Mažų atsargų įspėjimai išsaugoti",
+      "defaultThreshold": "Numatytoji užsakymo riba",
+      "defaultThresholdHint": "Naudojama tik dalims, neturinčioms savo užsakymo ribos. Pačios dalies \"Min. kiekis\" — nustatomas redaguojant dalį — visada svarbesnis. Palikite 0, kad būtų stebimos tik atskirai sukonfigūruotos dalys.",
+      "checkNow": "Tikrinti dabar",
+      "checkDisabled": "Įspėjimai išjungti.",
+      "checkNothingNew": "Nieko naujo — visos mažos atsargos jau praneštos.",
+      "checkAlerted": "Pranešta apie {count} naujai sumažėjusią (-ias) dalį (-is)",
+      "checkFailed": "Patikra nepavyko",
+      "configurePerPart": "Nustatykite dalies min. kiekį sandėlio puslapyje →",
+      "readMore": "Skaityti daugiau"
+    }
   }
 }

+ 2 - 1
messages/nb/dashboard.json

@@ -36,7 +36,8 @@
     "activeJobs": "Aktive jobber",
     "pending": "Ventende",
     "totalParts": "Totale deler",
-    "totalCustomers": "Totalt antall kunder"
+    "totalCustomers": "Totalt antall kunder",
+    "lowStock": "Lavt lager"
   },
   "customize": "Tilpass",
   "viewAll": "Vis alle",

+ 27 - 3
messages/nb/inventory.json

@@ -18,11 +18,15 @@
   },
   "empty": {
     "noMatch": "Ingen deler samsvarer med søket ditt.",
-    "noParts": "Ingen deler i lageret ennå."
+    "noParts": "Ingen deler i lageret ennå.",
+    "noLowStock": "Ingenting er under bestillingspunktet akkurat nå.",
+    "noReorderPoints": "Ingen bestillingspunkter er satt, så ingen deler kan regnes som lave.",
+    "configureReorderPoints": "Sett et standard bestillingspunkt"
   },
   "actions": {
     "edit": "Rediger",
-    "delete": "Slett"
+    "delete": "Slett",
+    "details": "Detaljer og historikk"
   },
   "deletePart": {
     "title": "Slett del",
@@ -119,5 +123,25 @@
     "saveChanges": "Lagre endringer",
     "addPart": "Legg til del"
   },
-  "openMenu": "Open menu"
+  "openMenu": "Open menu",
+  "history": {
+    "title": "Lagerhistorikk",
+    "description": "Alle registrerte endringer for {name}, nyeste først.",
+    "empty": "Ingen lagerbevegelser registrert ennå.",
+    "when": "Dato",
+    "change": "Endring",
+    "balance": "Saldo",
+    "usedOn": "Brukt på",
+    "by": "Av",
+    "reasons": {
+      "service_record": "Arbeidsordre",
+      "service_record_deleted": "Arbeidsordre slettet",
+      "quote_conversion": "Tilbud konvertert",
+      "manual_adjustment": "Manuell justering",
+      "bulk_markup": "Masse-påslag"
+    },
+    "backToInventory": "Lager",
+    "reason": "Årsak",
+    "allReasons": "Alle årsaker"
+  }
 }

+ 2 - 1
messages/nb/quotes.json

@@ -72,7 +72,8 @@
     "total": "Totalt",
     "subtotal": "Deler delsum: {amount}",
     "deleteRow": "Delete row",
-    "excludeFromTotal": "Exclude from total"
+    "excludeFromTotal": "Exclude from total",
+    "addFromInventory": "Fra lager"
   },
   "labor": {
     "title": "Arbeid",

+ 31 - 0
messages/nb/settings.json

@@ -110,6 +110,10 @@
       "about": {
         "title": "Om",
         "description": "Versjon og informasjon"
+      },
+      "alerts": {
+        "title": "Varsler",
+        "description": "Få beskjed når deler må bestilles"
       }
     },
     "pro": "PRO"
@@ -1339,5 +1343,32 @@
       "retrying": "Prøver på nytt",
       "pending": "Venter"
     }
+  },
+  "alerts": {
+    "title": "Varsler",
+    "description": "Velg hvilke hendelser du vil varsles om, og hvordan.",
+    "lowStock": {
+      "title": "Varsler om lavt lager",
+      "description": "Varsler når en del når bestillingspunktet. En del er lav når antallet faller til eller under \"Min. antall\", eller standardverdien under hvis den mangler. Hver del rapporteres én gang per fall og er stille til den er fylt opp igjen.",
+      "enable": "Aktiver varsler om lavt lager",
+      "enableHint": "Av som standard. Bare deler med bestillingspunkt overvåkes.",
+      "inApp": "Varsel i appen",
+      "inAppHint": "Ett samlet varsel i bjella, ikke ett per del.",
+      "email": "Sammendrag på e-post",
+      "emailHint": "Én e-post som lister alle delene som nettopp ble lave.",
+      "emailInterval": "Minimum timer mellom e-poster",
+      "emailIntervalHint": "Maks ett sammendrag per så mange timer. 0 fjerner grensen.",
+      "save": "Lagre varselinnstillinger",
+      "saved": "Varsler om lavt lager lagret",
+      "defaultThreshold": "Standard bestillingspunkt",
+      "defaultThresholdHint": "Brukes bare for deler uten eget bestillingspunkt. Delens egen \"Min. antall\" — satt når du redigerer delen — går alltid foran. La stå på 0 for kun å overvåke deler du har satt opp enkeltvis.",
+      "checkNow": "Sjekk nå",
+      "checkDisabled": "Varsler er slått av.",
+      "checkNothingNew": "Ingenting nytt — alle lave deler er allerede rapportert.",
+      "checkAlerted": "{count} nylig lav(e) del(er) rapportert",
+      "checkFailed": "Sjekken mislyktes",
+      "configurePerPart": "Sett delens min. antall på lagersiden →",
+      "readMore": "Les mer"
+    }
   }
 }

+ 2 - 1
messages/nl/dashboard.json

@@ -36,7 +36,8 @@
     "activeJobs": "Actieve opdrachten",
     "pending": "In afwachting",
     "totalParts": "Totaal onderdelen",
-    "totalCustomers": "Totaal klanten"
+    "totalCustomers": "Totaal klanten",
+    "lowStock": "Lage voorraad"
   },
   "customize": "Aanpassen",
   "viewAll": "Alles bekijken",

+ 27 - 3
messages/nl/inventory.json

@@ -18,11 +18,15 @@
   },
   "empty": {
     "noMatch": "Geen onderdelen gevonden voor uw zoekopdracht.",
-    "noParts": "Nog geen onderdelen in de voorraad."
+    "noParts": "Nog geen onderdelen in de voorraad.",
+    "noLowStock": "Er staat op dit moment niets onder het bestelpunt.",
+    "noReorderPoints": "Er zijn geen bestelpunten ingesteld, dus geen onderdeel kan als laag gelden.",
+    "configureReorderPoints": "Standaard bestelpunt instellen"
   },
   "actions": {
     "edit": "Bewerken",
-    "delete": "Verwijderen"
+    "delete": "Verwijderen",
+    "details": "Details en geschiedenis"
   },
   "deletePart": {
     "title": "Onderdeel verwijderen",
@@ -119,5 +123,25 @@
     "saveChanges": "Wijzigingen opslaan",
     "addPart": "Onderdeel toevoegen"
   },
-  "openMenu": "Open menu"
+  "openMenu": "Open menu",
+  "history": {
+    "title": "Voorraadgeschiedenis",
+    "description": "Alle geregistreerde wijzigingen aan {name}, nieuwste eerst.",
+    "empty": "Nog geen voorraadmutaties geregistreerd.",
+    "when": "Datum",
+    "change": "Wijziging",
+    "balance": "Saldo",
+    "usedOn": "Gebruikt bij",
+    "by": "Door",
+    "reasons": {
+      "service_record": "Werkorder",
+      "service_record_deleted": "Werkorder verwijderd",
+      "quote_conversion": "Offerte omgezet",
+      "manual_adjustment": "Handmatige aanpassing",
+      "bulk_markup": "Bulkopslag"
+    },
+    "backToInventory": "Voorraad",
+    "reason": "Reden",
+    "allReasons": "Alle redenen"
+  }
 }

+ 2 - 1
messages/nl/quotes.json

@@ -72,7 +72,8 @@
     "total": "Totaal",
     "subtotal": "Subtotaal onderdelen: {amount}",
     "deleteRow": "Delete row",
-    "excludeFromTotal": "Exclude from total"
+    "excludeFromTotal": "Exclude from total",
+    "addFromInventory": "Uit voorraad"
   },
   "labor": {
     "title": "Arbeid",

+ 31 - 0
messages/nl/settings.json

@@ -110,6 +110,10 @@
       "about": {
         "title": "Over",
         "description": "Versie en informatie"
+      },
+      "alerts": {
+        "title": "Meldingen",
+        "description": "Bericht wanneer onderdelen bijbesteld moeten worden"
       }
     },
     "pro": "PRO"
@@ -1339,5 +1343,32 @@
       "retrying": "Opnieuw proberen",
       "pending": "In afwachting"
     }
+  },
+  "alerts": {
+    "title": "Meldingen",
+    "description": "Kies over welke gebeurtenissen u bericht wilt, en hoe.",
+    "lowStock": {
+      "title": "Meldingen lage voorraad",
+      "description": "Waarschuwt wanneer een onderdeel zijn bestelpunt bereikt. Een onderdeel is laag als de voorraad daalt tot of onder het \"Min. aantal\", of de standaardwaarde hieronder als het er geen heeft. Elk onderdeel wordt één keer per daling gemeld en blijft stil tot het is aangevuld.",
+      "enable": "Meldingen lage voorraad inschakelen",
+      "enableHint": "Standaard uit. Alleen onderdelen met een bestelpunt worden gevolgd.",
+      "inApp": "Melding in de app",
+      "inAppHint": "Eén gegroepeerde melding in de bel, niet één per onderdeel.",
+      "email": "E-mailoverzicht",
+      "emailHint": "Eén e-mail met alle onderdelen die net onder het bestelpunt kwamen.",
+      "emailInterval": "Minimum uren tussen e-mails",
+      "emailIntervalHint": "Hooguit één overzicht per zoveel uur. 0 schakelt de limiet uit.",
+      "save": "Meldingen opslaan",
+      "saved": "Meldingen lage voorraad opgeslagen",
+      "defaultThreshold": "Standaard bestelpunt",
+      "defaultThresholdHint": "Alleen gebruikt voor onderdelen zonder eigen bestelpunt. De \"Min. aantal\" van een onderdeel — ingesteld bij het bewerken — gaat altijd voor. Laat op 0 om alleen individueel ingestelde onderdelen te volgen.",
+      "checkNow": "Nu controleren",
+      "checkDisabled": "Meldingen staan uit.",
+      "checkNothingNew": "Niets nieuws — alle lage onderdelen zijn al gemeld.",
+      "checkAlerted": "{count} nieuw laag onderdeel/onderdelen gemeld",
+      "checkFailed": "Controle mislukt",
+      "configurePerPart": "Stel het min. aantal van een onderdeel in op de voorraadpagina →",
+      "readMore": "Lees meer"
+    }
   }
 }

+ 2 - 1
messages/pl/dashboard.json

@@ -36,7 +36,8 @@
     "activeJobs": "Aktywne zlecenia",
     "pending": "Oczekujące",
     "totalParts": "Łączna liczba części",
-    "totalCustomers": "Łączna liczba klientów"
+    "totalCustomers": "Łączna liczba klientów",
+    "lowStock": "Niski stan"
   },
   "customize": "Dostosuj",
   "viewAll": "Pokaż wszystkie",

+ 27 - 3
messages/pl/inventory.json

@@ -18,11 +18,15 @@
   },
   "empty": {
     "noMatch": "Żadne części nie pasują do wyszukiwania.",
-    "noParts": "Brak części w magazynie."
+    "noParts": "Brak części w magazynie.",
+    "noLowStock": "Nic nie jest obecnie poniżej progu zamawiania.",
+    "noReorderPoints": "Nie ustawiono progów zamawiania, więc żadna część nie może być uznana za niską.",
+    "configureReorderPoints": "Ustaw domyślny próg zamawiania"
   },
   "actions": {
     "edit": "Edytuj",
-    "delete": "Usuń"
+    "delete": "Usuń",
+    "details": "Szczegóły i historia"
   },
   "deletePart": {
     "title": "Usuń część",
@@ -119,5 +123,25 @@
     "saveChanges": "Zapisz zmiany",
     "addPart": "Dodaj część"
   },
-  "openMenu": "Open menu"
+  "openMenu": "Open menu",
+  "history": {
+    "title": "Historia stanu",
+    "description": "Wszystkie zapisane zmiany dla {name}, od najnowszych.",
+    "empty": "Brak zarejestrowanych ruchów magazynowych.",
+    "when": "Data",
+    "change": "Zmiana",
+    "balance": "Stan",
+    "usedOn": "Użyto w",
+    "by": "Przez",
+    "reasons": {
+      "service_record": "Zlecenie",
+      "service_record_deleted": "Zlecenie usunięte",
+      "quote_conversion": "Wycena przekształcona",
+      "manual_adjustment": "Korekta ręczna",
+      "bulk_markup": "Marża zbiorcza"
+    },
+    "backToInventory": "Magazyn",
+    "reason": "Powód",
+    "allReasons": "Wszystkie powody"
+  }
 }

+ 2 - 1
messages/pl/quotes.json

@@ -72,7 +72,8 @@
     "total": "Suma",
     "subtotal": "Części suma częściowa: {amount}",
     "deleteRow": "Delete row",
-    "excludeFromTotal": "Exclude from total"
+    "excludeFromTotal": "Exclude from total",
+    "addFromInventory": "Z magazynu"
   },
   "labor": {
     "title": "Robocizna",

+ 31 - 0
messages/pl/settings.json

@@ -110,6 +110,10 @@
       "about": {
         "title": "Informacje",
         "description": "Wersja i informacje"
+      },
+      "alerts": {
+        "title": "Alerty",
+        "description": "Powiadomienia o konieczności zamówienia części"
       }
     },
     "pro": "PRO"
@@ -1339,5 +1343,32 @@
       "retrying": "Ponawianie",
       "pending": "Oczekujące"
     }
+  },
+  "alerts": {
+    "title": "Alerty",
+    "description": "Wybierz, o których zdarzeniach chcesz być informowany i w jaki sposób.",
+    "lowStock": {
+      "title": "Alerty niskiego stanu",
+      "description": "Ostrzega, gdy część osiąga próg zamawiania. Część jest niska, gdy jej ilość spadnie do lub poniżej \"Min. ilość\", albo wartości domyślnej poniżej, jeśli jej nie ustawiono. Każda część jest zgłaszana raz na spadek i milczy do czasu uzupełnienia.",
+      "enable": "Włącz alerty niskiego stanu",
+      "enableHint": "Domyślnie wyłączone. Monitorowane są tylko części z ustawionym progiem.",
+      "inApp": "Powiadomienie w aplikacji",
+      "inAppHint": "Jedno zbiorcze powiadomienie w dzwonku, nie jedno na część.",
+      "email": "Podsumowanie e-mail",
+      "emailHint": "Jeden e-mail z listą wszystkich części, których stan właśnie spadł.",
+      "emailInterval": "Minimalne godziny między e-mailami",
+      "emailIntervalHint": "Najwyżej jedno podsumowanie na tyle godzin. 0 wyłącza limit.",
+      "save": "Zapisz alerty",
+      "saved": "Zapisano alerty niskiego stanu",
+      "defaultThreshold": "Domyślny próg zamawiania",
+      "defaultThresholdHint": "Używane tylko dla części bez własnego progu zamawiania. Własna \"Min. ilość\" części — ustawiana podczas jej edycji — zawsze ma pierwszeństwo. Zostaw 0, aby monitorować tylko części skonfigurowane indywidualnie.",
+      "checkNow": "Sprawdź teraz",
+      "checkDisabled": "Alerty są wyłączone.",
+      "checkNothingNew": "Nic nowego — wszystkie niskie części już zgłoszono.",
+      "checkAlerted": "Zgłoszono {count} nowo niskich części",
+      "checkFailed": "Sprawdzenie nie powiodło się",
+      "configurePerPart": "Ustaw min. ilość części na stronie magazynu →",
+      "readMore": "Czytaj więcej"
+    }
   }
 }

+ 2 - 1
messages/pt-BR/dashboard.json

@@ -36,7 +36,8 @@
     "activeJobs": "Trabalhos Ativos",
     "pending": "Pendentes",
     "totalParts": "Total de Peças",
-    "totalCustomers": "Total de Clientes"
+    "totalCustomers": "Total de Clientes",
+    "lowStock": "Estoque baixo"
   },
   "customize": "Personalizar",
   "viewAll": "Ver tudo",

+ 27 - 3
messages/pt-BR/inventory.json

@@ -18,11 +18,15 @@
   },
   "empty": {
     "noMatch": "Nenhuma peça corresponde à sua busca.",
-    "noParts": "Nenhuma peça no inventário ainda."
+    "noParts": "Nenhuma peça no inventário ainda.",
+    "noLowStock": "Nada está abaixo do ponto de reposição no momento.",
+    "noReorderPoints": "Nenhum ponto de reposição foi definido, então nenhuma peça pode ser considerada baixa.",
+    "configureReorderPoints": "Definir um ponto de reposição padrão"
   },
   "actions": {
     "edit": "Editar",
-    "delete": "Excluir"
+    "delete": "Excluir",
+    "details": "Detalhes e histórico"
   },
   "deletePart": {
     "title": "Excluir peça",
@@ -119,5 +123,25 @@
     "saveChanges": "Salvar alterações",
     "addPart": "Adicionar peça"
   },
-  "openMenu": "Open menu"
+  "openMenu": "Open menu",
+  "history": {
+    "title": "Histórico de estoque",
+    "description": "Todas as alterações registradas em {name}, mais recentes primeiro.",
+    "empty": "Nenhuma movimentação de estoque registrada.",
+    "when": "Data",
+    "change": "Alteração",
+    "balance": "Saldo",
+    "usedOn": "Usado em",
+    "by": "Por",
+    "reasons": {
+      "service_record": "Ordem de serviço",
+      "service_record_deleted": "Ordem excluída",
+      "quote_conversion": "Orçamento convertido",
+      "manual_adjustment": "Ajuste manual",
+      "bulk_markup": "Margem em massa"
+    },
+    "backToInventory": "Estoque",
+    "reason": "Motivo",
+    "allReasons": "Todos os motivos"
+  }
 }

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

@@ -72,7 +72,8 @@
     "total": "Total",
     "subtotal": "Subtotal de peças: {amount}",
     "deleteRow": "Delete row",
-    "excludeFromTotal": "Exclude from total"
+    "excludeFromTotal": "Exclude from total",
+    "addFromInventory": "Do estoque"
   },
   "labor": {
     "title": "Mão de obra",

+ 31 - 0
messages/pt-BR/settings.json

@@ -110,6 +110,10 @@
       "about": {
         "title": "Sobre",
         "description": "Versao e informacoes"
+      },
+      "alerts": {
+        "title": "Alertas",
+        "description": "Aviso quando peças precisam ser repostas"
       }
     },
     "pro": "PRO"
@@ -1339,5 +1343,32 @@
       "retrying": "Tentando novamente",
       "pending": "Pendente"
     }
+  },
+  "alerts": {
+    "title": "Alertas",
+    "description": "Escolha sobre quais eventos deseja ser avisado e como.",
+    "lowStock": {
+      "title": "Alertas de estoque baixo",
+      "description": "Avisa quando uma peça atinge seu ponto de reposição. Uma peça está baixa quando a quantidade cai para ou abaixo da \"Qtd. mín.\", ou do padrão abaixo se não tiver. Cada peça é informada uma vez por queda e fica em silêncio até ser reposta.",
+      "enable": "Ativar alertas de estoque baixo",
+      "enableHint": "Desativado por padrão. Só peças com ponto de reposição são monitoradas.",
+      "inApp": "Notificação no app",
+      "inAppHint": "Uma notificação agrupada no sino, não uma por peça.",
+      "email": "Resumo por e-mail",
+      "emailHint": "Um único e-mail listando todas as peças que acabaram de ficar baixas.",
+      "emailInterval": "Horas mínimas entre e-mails",
+      "emailIntervalHint": "No máximo um resumo a cada tantas horas. 0 desativa o limite.",
+      "save": "Salvar alertas",
+      "saved": "Alertas de estoque baixo salvos",
+      "defaultThreshold": "Ponto de reposição padrão",
+      "defaultThresholdHint": "Usado apenas para peças sem ponto de reposição próprio. A \"Qtd. mín.\" da peça — definida ao editá-la — sempre prevalece. Deixe em 0 para monitorar só as peças configuradas individualmente.",
+      "checkNow": "Verificar agora",
+      "checkDisabled": "Os alertas estão desativados.",
+      "checkNothingNew": "Nada novo — todas as peças baixas já foram informadas.",
+      "checkAlerted": "{count} peça(s) recém-baixa(s) informada(s)",
+      "checkFailed": "Falha na verificação",
+      "configurePerPart": "Defina a Qtd. mín. de uma peça na página de estoque →",
+      "readMore": "Saiba mais"
+    }
   }
 }

+ 2 - 1
messages/ru/dashboard.json

@@ -36,7 +36,8 @@
     "activeJobs": "Активные работы",
     "pending": "Ожидающие",
     "totalParts": "Всего запчастей",
-    "totalCustomers": "Всего клиентов"
+    "totalCustomers": "Всего клиентов",
+    "lowStock": "Мало на складе"
   },
   "customize": "Настроить",
   "viewAll": "Показать все",

+ 27 - 3
messages/ru/inventory.json

@@ -18,11 +18,15 @@
   },
   "empty": {
     "noMatch": "Нет запчастей, соответствующих поиску.",
-    "noParts": "На складе пока нет запчастей."
+    "noParts": "На складе пока нет запчастей.",
+    "noLowStock": "Сейчас ничего не ниже точки заказа.",
+    "noReorderPoints": "Точки заказа не заданы, поэтому ни одна деталь не может считаться заканчивающейся.",
+    "configureReorderPoints": "Задать точку заказа по умолчанию"
   },
   "actions": {
     "edit": "Редактировать",
-    "delete": "Удалить"
+    "delete": "Удалить",
+    "details": "Детали и история"
   },
   "deletePart": {
     "title": "Удалить запчасть",
@@ -119,5 +123,25 @@
     "saveChanges": "Сохранить изменения",
     "addPart": "Добавить запчасть"
   },
-  "openMenu": "Open menu"
+  "openMenu": "Open menu",
+  "history": {
+    "title": "История склада",
+    "description": "Все изменения по «{name}», сначала новые.",
+    "empty": "Движений по складу пока нет.",
+    "when": "Дата",
+    "change": "Изменение",
+    "balance": "Остаток",
+    "usedOn": "Использовано в",
+    "by": "Кем",
+    "reasons": {
+      "service_record": "Заказ-наряд",
+      "service_record_deleted": "Заказ-наряд удалён",
+      "quote_conversion": "Предложение конвертировано",
+      "manual_adjustment": "Ручная корректировка",
+      "bulk_markup": "Массовая наценка"
+    },
+    "backToInventory": "Склад",
+    "reason": "Причина",
+    "allReasons": "Все причины"
+  }
 }

+ 2 - 1
messages/ru/quotes.json

@@ -72,7 +72,8 @@
     "total": "Итого",
     "subtotal": "Подитог запчастей: {amount}",
     "deleteRow": "Delete row",
-    "excludeFromTotal": "Exclude from total"
+    "excludeFromTotal": "Exclude from total",
+    "addFromInventory": "Со склада"
   },
   "labor": {
     "title": "Работа",

+ 31 - 0
messages/ru/settings.json

@@ -110,6 +110,10 @@
       "about": {
         "title": "О программе",
         "description": "Версия и информация"
+      },
+      "alerts": {
+        "title": "Оповещения",
+        "description": "Уведомления, когда пора заказывать детали"
       }
     },
     "pro": "PRO"
@@ -1339,5 +1343,32 @@
       "retrying": "Повтор",
       "pending": "Ожидает"
     }
+  },
+  "alerts": {
+    "title": "Оповещения",
+    "description": "Выберите, о каких событиях и каким способом вас уведомлять.",
+    "lowStock": {
+      "title": "Оповещения о низком запасе",
+      "description": "Предупреждает, когда деталь достигает точки заказа. Деталь считается заканчивающейся, когда количество падает до или ниже \"Мин. кол-во\", либо значения по умолчанию ниже, если оно не задано. Каждая деталь сообщается один раз за спад и молчит до пополнения.",
+      "enable": "Включить оповещения о низком запасе",
+      "enableHint": "По умолчанию выключено. Отслеживаются только детали с заданной точкой заказа.",
+      "inApp": "Уведомление в приложении",
+      "inAppHint": "Одно сгруппированное уведомление в колокольчике, а не по одному на деталь.",
+      "email": "Сводка по эл. почте",
+      "emailHint": "Одно письмо со списком всех деталей, запас которых только что упал.",
+      "emailInterval": "Минимум часов между письмами",
+      "emailIntervalHint": "Не более одной сводки за столько часов. 0 снимает ограничение.",
+      "save": "Сохранить оповещения",
+      "saved": "Оповещения о низком запасе сохранены",
+      "defaultThreshold": "Точка заказа по умолчанию",
+      "defaultThresholdHint": "Применяется только к деталям без собственной точки заказа. Собственный \"Мин. кол-во\" детали — задаётся при её редактировании — всегда имеет приоритет. Оставьте 0, чтобы отслеживать только настроенные вручную детали.",
+      "checkNow": "Проверить сейчас",
+      "checkDisabled": "Оповещения выключены.",
+      "checkNothingNew": "Ничего нового — все детали с низким запасом уже отмечены.",
+      "checkAlerted": "Сообщено о {count} новых деталях с низким запасом",
+      "checkFailed": "Ошибка проверки",
+      "configurePerPart": "Задайте мин. количество детали на странице склада →",
+      "readMore": "Подробнее"
+    }
   }
 }

+ 2 - 1
messages/tr/dashboard.json

@@ -36,7 +36,8 @@
     "activeJobs": "Aktif İşler",
     "pending": "Beklemede",
     "totalParts": "Toplam Parça",
-    "totalCustomers": "Toplam Müşteriler"
+    "totalCustomers": "Toplam Müşteriler",
+    "lowStock": "Düşük stok"
   },
   "customize": "Özelleştir",
   "viewAll": "Tümünü Gör",

+ 27 - 3
messages/tr/inventory.json

@@ -18,11 +18,15 @@
   },
   "empty": {
     "noMatch": "Aramanızla eşleşen parça bulunamadı.",
-    "noParts": "Envanterde henüz parça yok."
+    "noParts": "Envanterde henüz parça yok.",
+    "noLowStock": "Şu anda hiçbir şey sipariş noktasının altında değil.",
+    "noReorderPoints": "Sipariş noktası tanımlanmadığı için hiçbir parça düşük sayılamaz.",
+    "configureReorderPoints": "Varsayılan sipariş noktası ayarla"
   },
   "actions": {
     "edit": "Düzenle",
-    "delete": "Sil"
+    "delete": "Sil",
+    "details": "Ayrıntılar ve geçmiş"
   },
   "deletePart": {
     "title": "Parçayı sil",
@@ -119,5 +123,25 @@
     "saveChanges": "Değişiklikleri kaydet",
     "addPart": "Parça ekle"
   },
-  "openMenu": "Open menu"
+  "openMenu": "Open menu",
+  "history": {
+    "title": "Stok geçmişi",
+    "description": "{name} için kaydedilen tüm değişiklikler, en yeniden.",
+    "empty": "Henüz stok hareketi kaydedilmedi.",
+    "when": "Tarih",
+    "change": "Değişim",
+    "balance": "Bakiye",
+    "usedOn": "Kullanıldığı iş",
+    "by": "Yapan",
+    "reasons": {
+      "service_record": "İş emri",
+      "service_record_deleted": "İş emri silindi",
+      "quote_conversion": "Teklif dönüştürüldü",
+      "manual_adjustment": "Manuel düzeltme",
+      "bulk_markup": "Toplu kâr marjı"
+    },
+    "backToInventory": "Stok",
+    "reason": "Neden",
+    "allReasons": "Tüm nedenler"
+  }
 }

+ 2 - 1
messages/tr/quotes.json

@@ -72,7 +72,8 @@
     "total": "Toplam",
     "subtotal": "Parçalar Ara Toplam: {amount}",
     "deleteRow": "Delete row",
-    "excludeFromTotal": "Exclude from total"
+    "excludeFromTotal": "Exclude from total",
+    "addFromInventory": "Stoktan"
   },
   "labor": {
     "title": "İşçilik",

+ 31 - 0
messages/tr/settings.json

@@ -110,6 +110,10 @@
       "about": {
         "title": "Hakkında",
         "description": "Sürüm & bilgi"
+      },
+      "alerts": {
+        "title": "Uyarılar",
+        "description": "Parça sipariş etmeniz gerektiğinde haber alın"
       }
     },
     "pro": "PRO"
@@ -1339,5 +1343,32 @@
       "retrying": "Yeniden deneniyor",
       "pending": "Beklemede"
     }
+  },
+  "alerts": {
+    "title": "Uyarılar",
+    "description": "Hangi olaylardan nasıl haberdar olmak istediğinizi seçin.",
+    "lowStock": {
+      "title": "Düşük stok uyarıları",
+      "description": "Bir parça sipariş noktasına ulaştığında uyarır. Bir parça, miktarı \"Min. miktar\" değerine veya altına düştüğünde düşüktür; tanımlı değilse aşağıdaki varsayılan kullanılır. Her parça düşüş başına bir kez bildirilir ve stok yenilenene kadar sessiz kalır.",
+      "enable": "Düşük stok uyarılarını etkinleştir",
+      "enableHint": "Varsayılan olarak kapalı. Yalnızca sipariş noktası tanımlı parçalar izlenir.",
+      "inApp": "Uygulama içi bildirim",
+      "inAppHint": "Zilde tek bir toplu bildirim, parça başına bir tane değil.",
+      "email": "E-posta özeti",
+      "emailHint": "Yeni düşen tüm parçaları listeleyen tek bir e-posta.",
+      "emailInterval": "E-postalar arası minimum saat",
+      "emailIntervalHint": "Bu kadar saatte en fazla bir özet. 0 sınırı kaldırır.",
+      "save": "Uyarı ayarlarını kaydet",
+      "saved": "Düşük stok uyarıları kaydedildi",
+      "defaultThreshold": "Varsayılan sipariş noktası",
+      "defaultThresholdHint": "Yalnızca kendi sipariş noktası olmayan parçalar için kullanılır. Parçanın kendi \"Min. miktar\" değeri — parçayı düzenlerken ayarlanır — her zaman önceliklidir. Sadece tek tek ayarladığınız parçaları izlemek için 0 bırakın.",
+      "checkNow": "Şimdi kontrol et",
+      "checkDisabled": "Uyarılar kapalı.",
+      "checkNothingNew": "Yeni bir şey yok — düşük olan tüm parçalar zaten bildirildi.",
+      "checkAlerted": "{count} yeni düşük parça bildirildi",
+      "checkFailed": "Kontrol başarısız",
+      "configurePerPart": "Bir parçanın min. miktarını stok sayfasında ayarlayın →",
+      "readMore": "Devamını oku"
+    }
   }
 }

+ 117 - 0
prisma/migrations/20260727200000_inventory_ledger_and_alerts/migration.sql

@@ -0,0 +1,117 @@
+-- Inventory: stock movement ledger, quote→stock linkage, barcode uniqueness
+-- and low-stock alerting.
+--
+-- Ordering matters. The barcode data is normalised and de-duplicated BEFORE the
+-- unique index is created, because existing installations may hold duplicate,
+-- blank or mixed-case barcodes and a bare CREATE UNIQUE INDEX would abort the
+-- upgrade for them.
+
+-- ---------------------------------------------------------------------------
+-- 1. New columns (nullable, no default — instant, no table rewrite)
+-- ---------------------------------------------------------------------------
+
+-- Records that a low-stock alert has already been sent for the current dip
+-- below the reorder point. Cleared when stock recovers, so a part alerts once
+-- per descent instead of on every check.
+ALTER TABLE "inventory_parts" ADD COLUMN IF NOT EXISTS "lowStockAlertedAt" TIMESTAMP(3);
+
+-- Lets a quote line remember which stocked part it was picked from, so
+-- converting the quote to a job deducts the right inventory item.
+ALTER TABLE "quote_parts" ADD COLUMN IF NOT EXISTS "inventoryPartId" TEXT;
+
+-- ---------------------------------------------------------------------------
+-- 2. Barcode clean-up, then uniqueness
+--
+-- Runs before the index so an upgrade can never fail on pre-existing data.
+-- Nothing is deleted: at worst a duplicated barcode is cleared from the newer
+-- of the two parts, and the part itself is untouched.
+-- ---------------------------------------------------------------------------
+
+-- Blank / whitespace-only barcodes mean "no barcode".
+UPDATE "inventory_parts"
+SET "barcode" = NULL
+WHERE "barcode" IS NOT NULL AND btrim("barcode") = '';
+
+-- Canonical form: trimmed and upper-cased. Scanners emit upper-case, and the
+-- application now normalises identically on write and on lookup, so an exact
+-- match is enough to resolve a scan to exactly one part.
+UPDATE "inventory_parts"
+SET "barcode" = upper(btrim("barcode"))
+WHERE "barcode" IS NOT NULL
+  AND "barcode" <> upper(btrim("barcode"));
+
+-- Resolve any remaining collisions by keeping the earliest-created part in each
+-- (organizationId, barcode) group and clearing the barcode on the others. Such
+-- a barcode was already ambiguous — it could not reliably identify a part — and
+-- the owner can re-scan the affected part to restore it.
+WITH ranked AS (
+    SELECT
+        "id",
+        row_number() OVER (
+            PARTITION BY "organizationId", "barcode"
+            ORDER BY "createdAt" ASC, "id" ASC
+        ) AS rn
+    FROM "inventory_parts"
+    WHERE "barcode" IS NOT NULL
+)
+UPDATE "inventory_parts" p
+SET "barcode" = NULL
+FROM ranked r
+WHERE p."id" = r."id" AND r.rn > 1;
+
+-- Postgres treats NULLs as distinct in a unique index, so any number of parts
+-- may have no barcode at all.
+CREATE UNIQUE INDEX IF NOT EXISTS "inventory_parts_organizationId_barcode_key"
+    ON "inventory_parts"("organizationId", "barcode");
+
+-- ---------------------------------------------------------------------------
+-- 3. Stock movement ledger
+--
+-- Append-only record of every change to InventoryPart.quantity, written inside
+-- the same transaction as the stock change itself so the two cannot drift.
+-- ---------------------------------------------------------------------------
+
+CREATE TABLE IF NOT EXISTS "stock_movements" (
+    "id" TEXT NOT NULL,
+    "delta" INTEGER NOT NULL,
+    "quantityAfter" INTEGER NOT NULL,
+    "reason" TEXT NOT NULL,
+    "note" TEXT,
+    "inventoryPartId" TEXT NOT NULL,
+    "serviceRecordId" TEXT,
+    "serviceRecordLabel" TEXT,
+    "userId" TEXT,
+    "organizationId" TEXT NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+    CONSTRAINT "stock_movements_pkey" PRIMARY KEY ("id")
+);
+
+-- Matches the history query exactly (one part, newest first), so paging stays
+-- an index scan with no sort step however long the ledger grows.
+CREATE INDEX IF NOT EXISTS "stock_movements_inventoryPartId_createdAt_idx"
+    ON "stock_movements"("inventoryPartId", "createdAt" DESC);
+
+CREATE INDEX IF NOT EXISTS "stock_movements_organizationId_createdAt_idx"
+    ON "stock_movements"("organizationId", "createdAt" DESC);
+
+CREATE INDEX IF NOT EXISTS "stock_movements_serviceRecordId_idx"
+    ON "stock_movements"("serviceRecordId");
+
+-- Deleting a part removes its history; deleting the job or the user only nulls
+-- the link, so "where was this used" survives them.
+ALTER TABLE "stock_movements" ADD CONSTRAINT "stock_movements_inventoryPartId_fkey"
+    FOREIGN KEY ("inventoryPartId") REFERENCES "inventory_parts"("id")
+    ON DELETE CASCADE ON UPDATE CASCADE;
+
+ALTER TABLE "stock_movements" ADD CONSTRAINT "stock_movements_serviceRecordId_fkey"
+    FOREIGN KEY ("serviceRecordId") REFERENCES "service_records"("id")
+    ON DELETE SET NULL ON UPDATE CASCADE;
+
+ALTER TABLE "stock_movements" ADD CONSTRAINT "stock_movements_userId_fkey"
+    FOREIGN KEY ("userId") REFERENCES "users"("id")
+    ON DELETE SET NULL ON UPDATE CASCADE;
+
+ALTER TABLE "stock_movements" ADD CONSTRAINT "stock_movements_organizationId_fkey"
+    FOREIGN KEY ("organizationId") REFERENCES "organizations"("id")
+    ON DELETE CASCADE ON UPDATE CASCADE;

+ 65 - 6
prisma/schema.prisma

@@ -35,6 +35,7 @@ model User {
   organizationMembers    OrganizationMember[]
   auditLogs              AuditLog[]
   technicians            Technician[]
+  stockMovements         StockMovement[]
 
   @@map("users")
 }
@@ -227,11 +228,12 @@ model ServiceRecord {
   technician   Technician? @relation(fields: [technicianId], references: [id], onDelete: SetNull)
   sortOrder    Int         @default(0)
 
-  partItems     ServicePart[]
-  laborItems    ServiceLabor[]
-  attachments   ServiceAttachment[]
-  payments      Payment[]
-  statusReports StatusReport[]
+  partItems      ServicePart[]
+  laborItems     ServiceLabor[]
+  attachments    ServiceAttachment[]
+  payments       Payment[]
+  statusReports  StatusReport[]
+  stockMovements StockMovement[]
 
   discoveredFindings VehicleFinding[] @relation("FindingDiscoveredIn")
   resolvedFindings   VehicleFinding[] @relation("FindingResolvedIn")
@@ -482,6 +484,13 @@ model InventoryPart {
   imageUrl      String?
   location      String?
   isArchived    Boolean  @default(false)
+
+  /// Set when a low-stock alert has been sent for the current dip below
+  /// `minQuantity`, and cleared once stock climbs back above it. This
+  /// hysteresis is what stops a part that sits at zero from re-alerting on
+  /// every check — it alerts once per descent, not once per run.
+  lowStockAlertedAt DateTime?
+
   createdAt     DateTime @default(now())
   updatedAt     DateTime @updatedAt
 
@@ -491,12 +500,57 @@ model InventoryPart {
   organizationId String?
   organization   Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
 
-  gallery StoredImage[] @relation("InventoryPartImages")
+  gallery   StoredImage[]   @relation("InventoryPartImages")
+  movements StockMovement[]
 
+  @@unique([organizationId, barcode])
   @@index([organizationId])
   @@map("inventory_parts")
 }
 
+/// Append-only audit trail of every change to InventoryPart.quantity.
+///
+/// Written inside the same transaction as the stock mutation itself, so the
+/// ledger can never drift from the balance it describes. `quantityAfter` is
+/// captured from the atomic `UPDATE ... RETURNING`, giving an exact running
+/// balance even under concurrent writes.
+model StockMovement {
+  id String @id @default(cuid())
+
+  /// Signed change in whole units. Negative = consumed, positive = restocked.
+  delta Int
+  /// Part balance immediately after this movement was applied.
+  quantityAfter Int
+  /// Why the stock moved — see StockMovementReason in src/features/inventory/Lib/stockMovement.ts
+  reason String
+  note   String?
+
+  inventoryPartId String
+  inventoryPart   InventoryPart @relation(fields: [inventoryPartId], references: [id], onDelete: Cascade)
+
+  /// The job the part was used on. Kept on SetNull so history survives
+  /// deletion of the service record — "where was this used" outlives the job.
+  serviceRecordId String?
+  serviceRecord   ServiceRecord? @relation(fields: [serviceRecordId], references: [id], onDelete: SetNull)
+
+  /// Denormalised so history still reads correctly after a service record is
+  /// deleted and serviceRecordId goes null.
+  serviceRecordLabel String?
+
+  userId String?
+  user   User?   @relation(fields: [userId], references: [id], onDelete: SetNull)
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  createdAt DateTime @default(now())
+
+  @@index([inventoryPartId, createdAt(sort: Desc)])
+  @@index([organizationId, createdAt(sort: Desc)])
+  @@index([serviceRecordId])
+  @@map("stock_movements")
+}
+
 model StoredImage {
   id          String   @id @default(cuid())
   url         String
@@ -587,6 +641,10 @@ model QuotePart {
   total      Float   @default(0)
   excluded   Boolean @default(false)
 
+  /// Optional link to the stocked part this line was picked from. Carried into
+  /// ServicePart on conversion so the job deducts the right inventory item.
+  inventoryPartId String?
+
   quoteId String
   quote   Quote  @relation(fields: [quoteId], references: [id], onDelete: Cascade)
 
@@ -676,6 +734,7 @@ model Organization {
   statusReports          StatusReport[]
   reportSchedules        ReportSchedule[]
   webhooks               Webhook[]
+  stockMovements         StockMovement[]
 
   @@map("organizations")
 }

+ 158 - 0
src/__tests__/features/inventory/barcode-conflict.test.ts

@@ -0,0 +1,158 @@
+/**
+ * Tests for duplicate-barcode detection and the message the user actually sees.
+ *
+ * This exists because the detection broke silently once. The original check
+ * read `meta.target`, which is populated by classic Prisma but left undefined
+ * by Prisma 7 with the pg driver adapter — so a duplicate barcode fell through
+ * and surfaced a raw "Unique constraint failed on the fields: (...)" instead of
+ * a usable message. Nothing failed loudly; the error was simply worse.
+ *
+ * The error shapes below are captured verbatim from real Prisma failures, so a
+ * future client upgrade that moves the field again fails here rather than in
+ * front of a user.
+ */
+
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("@/lib/db", () => ({
+  db: { inventoryPart: { findFirst: vi.fn() } },
+}));
+
+import { db } from "@/lib/db";
+import {
+  normalizeBarcode,
+  uniqueViolationFields,
+  withBarcodeConflictMessage,
+} from "@/features/inventory/Lib/barcode";
+
+const ORG = "org-1";
+
+/** Prisma 7 + @prisma/adapter-pg. `meta.target` is absent here. */
+const driverAdapterError = {
+  name: "PrismaClientKnownRequestError",
+  code: "P2002",
+  meta: {
+    modelName: "InventoryPart",
+    driverAdapterError: {
+      name: "DriverAdapterError",
+      cause: {
+        originalCode: "23505",
+        originalMessage:
+          'duplicate key value violates unique constraint "inventory_parts_organizationId_barcode_key"',
+        kind: "UniqueConstraintViolation",
+        constraint: { fields: ['"organizationId"', "barcode"] },
+      },
+    },
+  },
+};
+
+/** Classic Prisma shape, still supported. */
+const classicError = {
+  name: "PrismaClientKnownRequestError",
+  code: "P2002",
+  meta: { target: ["organizationId", "barcode"] },
+};
+
+describe("normalizeBarcode", () => {
+  it("trims and upper-cases so scans and typed input agree", () => {
+    expect(normalizeBarcode("  abc-1 ")).toBe("ABC-1");
+    expect(normalizeBarcode("AbC-1")).toBe("ABC-1");
+  });
+
+  it("treats blank input as no barcode", () => {
+    expect(normalizeBarcode("")).toBeNull();
+    expect(normalizeBarcode("   ")).toBeNull();
+    expect(normalizeBarcode(null)).toBeNull();
+    expect(normalizeBarcode(undefined)).toBeNull();
+  });
+});
+
+describe("uniqueViolationFields", () => {
+  it("finds the columns in the driver-adapter shape", () => {
+    expect(uniqueViolationFields(driverAdapterError)).toContain("barcode");
+  });
+
+  it("finds the columns in the classic shape", () => {
+    expect(uniqueViolationFields(classicError)).toContain("barcode");
+  });
+
+  it("ignores errors that are not unique violations", () => {
+    expect(uniqueViolationFields({ code: "P2025", meta: {} })).toBe("");
+  });
+
+  it("ignores non-errors", () => {
+    expect(uniqueViolationFields(null)).toBe("");
+    expect(uniqueViolationFields("boom")).toBe("");
+  });
+
+  it("does not claim a violation on an unrelated unique constraint", () => {
+    const other = {
+      code: "P2002",
+      meta: { target: ["organizationId", "key"] },
+    };
+    expect(uniqueViolationFields(other)).not.toContain("barcode");
+  });
+});
+
+describe("withBarcodeConflictMessage", () => {
+  beforeEach(() => vi.clearAllMocks());
+
+  it("passes the result through when nothing conflicts", async () => {
+    const result = await withBarcodeConflictMessage(ORG, "ABC-1", async () => "created");
+    expect(result).toBe("created");
+    expect(db.inventoryPart.findFirst).not.toHaveBeenCalled();
+  });
+
+  it("names the conflicting part (driver-adapter shape)", async () => {
+    vi.mocked(db.inventoryPart.findFirst).mockResolvedValue({
+      name: "Brake pad",
+      partNumber: "BP-1",
+    } as never);
+
+    await expect(
+      withBarcodeConflictMessage(ORG, "ABC-1", async () => {
+        throw driverAdapterError;
+      }),
+    ).rejects.toThrow("Barcode ABC-1 is already used by Brake pad (BP-1).");
+  });
+
+  it("names the conflicting part (classic shape)", async () => {
+    vi.mocked(db.inventoryPart.findFirst).mockResolvedValue({
+      name: "Brake pad",
+      partNumber: null,
+    } as never);
+
+    await expect(
+      withBarcodeConflictMessage(ORG, "ABC-1", async () => {
+        throw classicError;
+      }),
+    ).rejects.toThrow("Barcode ABC-1 is already used by Brake pad.");
+  });
+
+  it("still explains the collision when the other part cannot be found", async () => {
+    vi.mocked(db.inventoryPart.findFirst).mockResolvedValue(null as never);
+
+    await expect(
+      withBarcodeConflictMessage(ORG, "ABC-1", async () => {
+        throw driverAdapterError;
+      }),
+    ).rejects.toThrow("Barcode ABC-1 is already used by another part.");
+  });
+
+  it("never swallows an unrelated error", async () => {
+    const other = new Error("connection reset");
+    await expect(
+      withBarcodeConflictMessage(ORG, "ABC-1", async () => {
+        throw other;
+      }),
+    ).rejects.toThrow("connection reset");
+  });
+
+  it("does not rewrite a unique violation on a different column", async () => {
+    await expect(
+      withBarcodeConflictMessage(ORG, "ABC-1", async () => {
+        throw { code: "P2002", meta: { target: ["organizationId", "key"] } };
+      }),
+    ).rejects.not.toThrow(/Barcode/);
+  });
+});

+ 199 - 0
src/__tests__/features/inventory/low-stock-alerts.test.ts

@@ -0,0 +1,199 @@
+/**
+ * Tests for the low-stock alert decision logic.
+ *
+ * These exist mainly to pin the anti-spam behaviour. The failure mode this
+ * feature must never have is "a part that is out of stock emails me every hour
+ * forever", so the transition rules and the email throttle are covered
+ * explicitly rather than incidentally.
+ */
+
+import { describe, it, expect } from "vitest";
+import {
+  buildAlertSummary,
+  canSendDigestEmail,
+  decideLowStockAlerts,
+  effectiveThreshold,
+  formatLowStockLine,
+  isLow,
+  type LowStockCandidate,
+} from "@/features/inventory/Lib/lowStockAlerts";
+
+function part(over: Partial<LowStockCandidate> = {}): LowStockCandidate {
+  return {
+    id: "p1",
+    name: "Brake pad",
+    partNumber: "BP-1",
+    quantity: 1,
+    minQuantity: 5,
+    lowStockAlertedAt: null,
+    ...over,
+  };
+}
+
+describe("isLow", () => {
+  it("is low at or below the reorder point", () => {
+    expect(isLow({ quantity: 5, minQuantity: 5 })).toBe(true);
+    expect(isLow({ quantity: 4, minQuantity: 5 })).toBe(true);
+    expect(isLow({ quantity: 0, minQuantity: 5 })).toBe(true);
+  });
+
+  it("is not low above the reorder point", () => {
+    expect(isLow({ quantity: 6, minQuantity: 5 })).toBe(false);
+  });
+
+  it("never flags parts with no reorder point configured", () => {
+    // minQuantity 0 means "not tracked" — 0 on hand is not an alert.
+    expect(isLow({ quantity: 0, minQuantity: 0 })).toBe(false);
+  });
+});
+
+describe("effectiveThreshold — org-wide default", () => {
+  it("prefers the part's own reorder point over the default", () => {
+    expect(effectiveThreshold({ minQuantity: 5 }, 3)).toBe(5);
+  });
+
+  it("falls back to the org default when the part has none", () => {
+    expect(effectiveThreshold({ minQuantity: 0 }, 3)).toBe(3);
+  });
+
+  it("is off when neither is set", () => {
+    expect(effectiveThreshold({ minQuantity: 0 }, 0)).toBe(0);
+  });
+
+  it("watches unconfigured parts once a default is set", () => {
+    // Without a default this part is invisible to alerting; with one it is not.
+    expect(isLow({ quantity: 2, minQuantity: 0 })).toBe(false);
+    expect(isLow({ quantity: 2, minQuantity: 0 }, 3)).toBe(true);
+  });
+
+  it("lets a part opt out of a strict org default with a higher own value", () => {
+    expect(isLow({ quantity: 4, minQuantity: 10 }, 2)).toBe(true);
+    expect(isLow({ quantity: 4, minQuantity: 2 }, 10)).toBe(false);
+  });
+
+  it("routes the default through the alert decision", () => {
+    const { newlyLow } = decideLowStockAlerts(
+      [part({ id: "unconfigured", quantity: 1, minQuantity: 0, lowStockAlertedAt: null })],
+      3,
+    );
+    expect(newlyLow.map((p) => p.id)).toEqual(["unconfigured"]);
+  });
+
+  it("quotes the threshold actually applied in the digest line", () => {
+    expect(formatLowStockLine(part({ quantity: 1, minQuantity: 0 }), 3)).toBe(
+      "Brake pad (BP-1): 1 left, reorder at 3",
+    );
+  });
+});
+
+describe("decideLowStockAlerts — no-spam guarantees", () => {
+  it("alerts the first time a part drops below its reorder point", () => {
+    const { newlyLow } = decideLowStockAlerts([part({ lowStockAlertedAt: null })]);
+    expect(newlyLow.map((p) => p.id)).toEqual(["p1"]);
+  });
+
+  it("stays silent on later runs while the part is still low", () => {
+    // The critical case: a part sitting at zero must not re-alert every hour.
+    const { newlyLow, toRearm } = decideLowStockAlerts([
+      part({ quantity: 0, lowStockAlertedAt: new Date("2026-07-01") }),
+    ]);
+    expect(newlyLow).toHaveLength(0);
+    expect(toRearm).toHaveLength(0);
+  });
+
+  it("re-arms once stock climbs back above the reorder point", () => {
+    const { newlyLow, toRearm } = decideLowStockAlerts([
+      part({ quantity: 10, lowStockAlertedAt: new Date("2026-07-01") }),
+    ]);
+    expect(newlyLow).toHaveLength(0);
+    expect(toRearm).toEqual(["p1"]);
+  });
+
+  it("alerts again after a genuine restock-then-drop cycle", () => {
+    // Recovered: marker cleared.
+    const recovered = decideLowStockAlerts([
+      part({ quantity: 10, lowStockAlertedAt: new Date("2026-07-01") }),
+    ]);
+    expect(recovered.toRearm).toEqual(["p1"]);
+
+    // Dropped again with the marker now null: a second, real event.
+    const droppedAgain = decideLowStockAlerts([
+      part({ quantity: 2, lowStockAlertedAt: null }),
+    ]);
+    expect(droppedAgain.newlyLow.map((p) => p.id)).toEqual(["p1"]);
+  });
+
+  it("does not re-arm a healthy part that was never alerted", () => {
+    const { toRearm } = decideLowStockAlerts([
+      part({ quantity: 10, lowStockAlertedAt: null }),
+    ]);
+    expect(toRearm).toHaveLength(0);
+  });
+
+  it("separates newly low from already-alerted across a mixed inventory", () => {
+    const { newlyLow, toRearm } = decideLowStockAlerts([
+      part({ id: "new", quantity: 1, lowStockAlertedAt: null }),
+      part({ id: "still-low", quantity: 0, lowStockAlertedAt: new Date() }),
+      part({ id: "recovered", quantity: 99, lowStockAlertedAt: new Date() }),
+      part({ id: "healthy", quantity: 99, lowStockAlertedAt: null }),
+      part({ id: "untracked", quantity: 0, minQuantity: 0, lowStockAlertedAt: null }),
+    ]);
+    expect(newlyLow.map((p) => p.id)).toEqual(["new"]);
+    expect(toRearm).toEqual(["recovered"]);
+  });
+});
+
+describe("canSendDigestEmail — throttle", () => {
+  const now = new Date("2026-07-27T12:00:00Z");
+
+  it("allows the first ever digest", () => {
+    expect(canSendDigestEmail(null, now, 24)).toBe(true);
+  });
+
+  it("blocks a second digest inside the minimum interval", () => {
+    const twoHoursAgo = new Date("2026-07-27T10:00:00Z");
+    expect(canSendDigestEmail(twoHoursAgo, now, 24)).toBe(false);
+  });
+
+  it("allows a digest once the interval has elapsed", () => {
+    const yesterday = new Date("2026-07-26T11:00:00Z");
+    expect(canSendDigestEmail(yesterday, now, 24)).toBe(true);
+  });
+
+  it("treats a zero or negative interval as no throttle", () => {
+    const oneMinuteAgo = new Date("2026-07-27T11:59:00Z");
+    expect(canSendDigestEmail(oneMinuteAgo, now, 0)).toBe(true);
+  });
+});
+
+describe("digest formatting", () => {
+  it("states the shortfall and the reorder point", () => {
+    expect(formatLowStockLine(part({ quantity: 2, minQuantity: 5 }))).toBe(
+      "Brake pad (BP-1): 2 left, reorder at 5",
+    );
+  });
+
+  it("omits an absent part number", () => {
+    expect(formatLowStockLine(part({ partNumber: null, quantity: 0 }))).toBe(
+      "Brake pad: 0 left, reorder at 5",
+    );
+  });
+
+  it("groups many parts into a single notification", () => {
+    const summary = buildAlertSummary([
+      part({ id: "a", name: "A" }),
+      part({ id: "b", name: "B" }),
+      part({ id: "c", name: "C" }),
+      part({ id: "d", name: "D" }),
+      part({ id: "e", name: "E" }),
+    ]);
+    expect(summary.title).toBe("5 parts low on stock");
+    expect(summary.message).toBe("A, B, C and 2 more");
+  });
+
+  it("names the part directly when only one is low", () => {
+    const summary = buildAlertSummary([part({ quantity: 1, minQuantity: 5 })]);
+    expect(summary.title).toBe("Low stock");
+    expect(summary.message).toContain("Brake pad");
+  });
+});

+ 152 - 0
src/__tests__/features/inventory/low-stock-email-isolation.test.ts

@@ -0,0 +1,152 @@
+/**
+ * Tests that a broken email configuration cannot damage anything else.
+ *
+ * Two distinct failures are guarded here, and the second is the dangerous one:
+ *
+ *  1. A mail error must not fail the user's operation. Saving a work order that
+ *     happens to take stock below a reorder point must succeed even if SMTP is
+ *     misconfigured — the alert is a side effect, not part of the save.
+ *
+ *  2. A mail error must not prevent parts being marked as alerted. Marking is
+ *     what makes the system quiet: if an exception escaped before it, the same
+ *     parts would be re-reported on every subsequent run, so a bad SMTP setting
+ *     would silently turn into in-app notification spam.
+ */
+
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("@/lib/db", () => ({
+  db: {
+    appSetting: { findMany: vi.fn(), findFirst: vi.fn(), upsert: vi.fn() },
+    inventoryPart: { updateMany: vi.fn() },
+    organization: { findUnique: vi.fn() },
+    organizationMember: { findMany: vi.fn(), findFirst: vi.fn() },
+    notification: { create: vi.fn() },
+    $queryRaw: vi.fn(),
+  },
+}));
+
+vi.mock("@/lib/email", () => ({
+  sendOrgMail: vi.fn(),
+  getOrgFromAddress: vi.fn(),
+}));
+
+vi.mock("@/lib/notify", () => ({ notify: vi.fn() }));
+
+import { db } from "@/lib/db";
+import { sendOrgMail, getOrgFromAddress } from "@/lib/email";
+import { notify } from "@/lib/notify";
+import { processOrgLowStock } from "@/lib/cron/low-stock-alerts";
+import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
+
+const ORG = "org-1";
+
+/** Alerts on, email channel on, no throttle in the way. */
+function enableAlerts() {
+  vi.mocked(db.appSetting.findMany).mockResolvedValue([
+    { key: SETTING_KEYS.LOW_STOCK_ALERTS_ENABLED, value: "true" },
+    { key: SETTING_KEYS.LOW_STOCK_ALERTS_IN_APP, value: "true" },
+    { key: SETTING_KEYS.LOW_STOCK_ALERTS_EMAIL, value: "true" },
+  ] as never);
+}
+
+/** One part that has just gone low and has never been alerted. */
+function oneNewlyLowPart() {
+  vi.mocked(db.$queryRaw).mockResolvedValue([
+    {
+      id: "p1",
+      name: "Brake pad",
+      partNumber: "BP-1",
+      quantity: 1,
+      minQuantity: 5,
+      lowStockAlertedAt: null,
+    },
+  ] as never);
+}
+
+function markedIds() {
+  return vi
+    .mocked(db.inventoryPart.updateMany)
+    .mock.calls.filter((c) => (c[0] as never as { data: { lowStockAlertedAt?: Date } }).data.lowStockAlertedAt)
+    .flatMap((c) => (c[0] as never as { where: { id: { in: string[] } } }).where.id.in);
+}
+
+describe("low-stock alerts — email failure isolation", () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    enableAlerts();
+    oneNewlyLowPart();
+    vi.mocked(db.inventoryPart.updateMany).mockResolvedValue({ count: 1 } as never);
+    vi.mocked(db.organization.findUnique).mockResolvedValue({ name: "Shop" } as never);
+    vi.mocked(db.organizationMember.findMany).mockResolvedValue([
+      { user: { email: "owner@example.com", name: "Owner" } },
+    ] as never);
+    vi.mocked(db.organizationMember.findFirst).mockResolvedValue({ userId: "u1" } as never);
+    vi.mocked(db.appSetting.upsert).mockResolvedValue({} as never);
+    vi.mocked(getOrgFromAddress).mockResolvedValue("shop@example.com");
+    vi.mocked(sendOrgMail).mockResolvedValue(undefined as never);
+  });
+
+  it("still resolves when the mail server throws", async () => {
+    vi.mocked(sendOrgMail).mockRejectedValue(new Error("ECONNREFUSED"));
+    // Must not reject — the caller is a work-order save.
+    await expect(processOrgLowStock(ORG)).resolves.toBeDefined();
+  });
+
+  it("still marks parts as alerted when the mail server throws", async () => {
+    vi.mocked(sendOrgMail).mockRejectedValue(new Error("ECONNREFUSED"));
+    await processOrgLowStock(ORG);
+    // Without this, the same part re-alerts on every run: spam via a bad SMTP.
+    expect(markedIds()).toContain("p1");
+  });
+
+  it("still marks parts when the sender address cannot be resolved", async () => {
+    vi.mocked(getOrgFromAddress).mockRejectedValue(new Error("bad email config"));
+    const result = await processOrgLowStock(ORG);
+    expect(markedIds()).toContain("p1");
+    expect(result).toMatchObject({ emailed: false });
+  });
+
+  it("skips the digest rather than inventing a sender", async () => {
+    vi.mocked(getOrgFromAddress).mockRejectedValue(new Error("bad email config"));
+    await processOrgLowStock(ORG);
+    expect(sendOrgMail).not.toHaveBeenCalled();
+  });
+
+  it("still delivers the in-app notification when email is broken", async () => {
+    vi.mocked(sendOrgMail).mockRejectedValue(new Error("ECONNREFUSED"));
+    await processOrgLowStock(ORG);
+    // The channels are independent; a mail outage must not silence the bell.
+    expect(notify).toHaveBeenCalledTimes(1);
+  });
+
+  it("reports emailed:false when delivery failed", async () => {
+    vi.mocked(sendOrgMail).mockRejectedValue(new Error("ECONNREFUSED"));
+    const result = await processOrgLowStock(ORG);
+    expect(result).toMatchObject({ alerted: 1, emailed: false });
+  });
+
+  it("does not fail when recording the last-send time throws", async () => {
+    vi.mocked(db.appSetting.upsert).mockRejectedValue(new Error("db write failed"));
+    await expect(processOrgLowStock(ORG)).resolves.toBeDefined();
+    expect(markedIds()).toContain("p1");
+  });
+
+  it("still marks parts when the recipient lookup itself throws", async () => {
+    // This is the path that actually escapes sendDigest: sendOrgMail failures
+    // are caught per-recipient inside it, but a failure resolving *who* to mail
+    // propagates. Without the isolating try/catch around the digest call, this
+    // aborts before marking and the part re-alerts on every run.
+    vi.mocked(db.organizationMember.findMany).mockRejectedValue(
+      new Error("db unavailable"),
+    );
+    await expect(processOrgLowStock(ORG)).resolves.toBeDefined();
+    expect(markedIds()).toContain("p1");
+  });
+
+  it("reports emailed:true on the happy path", async () => {
+    const result = await processOrgLowStock(ORG);
+    expect(sendOrgMail).toHaveBeenCalledTimes(1);
+    expect(result).toMatchObject({ alerted: 1, emailed: true });
+  });
+});

+ 159 - 59
src/__tests__/features/inventory/reconcile-stock.test.ts

@@ -5,96 +5,137 @@
  * The helper must apply the NET delta between a record's previous and next
  * linked parts: adding/increasing consumes stock, removing/reducing restocks,
  * and unchanged quantities touch nothing. Every write is scoped to the
- * caller's organization and applied atomically via `{ decrement }`.
+ * caller's organization and applied atomically via `UPDATE ... RETURNING`,
+ * and every applied movement is mirrored into the StockMovement ledger.
  */
 
 import { describe, it, expect, vi } from "vitest";
-import { reconcileInventoryForParts } from "@/features/inventory/Lib/reconcileStock";
+import {
+  reconcileInventoryForParts,
+  type StockMovementContext,
+} from "@/features/inventory/Lib/reconcileStock";
 
 const ORG = "org-1";
 
-/** A fake transaction client that records inventoryPart.updateMany calls. */
-function makeTx() {
-  const updateMany = vi.fn().mockResolvedValue({ count: 1 });
+const CTX: StockMovementContext = {
+  reason: "service_record",
+  userId: "user-1",
+  serviceRecordId: "svc-1",
+  serviceRecordLabel: "INV-1001",
+};
+
+/**
+ * A fake transaction client that records the atomic stock updates and the
+ * ledger writes.
+ *
+ * `$queryRaw` is a tagged template: params arrive as (strings, ...values),
+ * where the values are [decrement, inventoryPartId, organizationId] in the
+ * order they appear in the SQL. It returns a post-write balance so the helper
+ * can populate `quantityAfter`.
+ */
+function makeTx(balanceFor: (partId: string) => number | null = () => 100) {
+  const updates: { id: string; organizationId: string; decrement: number }[] = [];
+
+  const $queryRaw = vi.fn(
+    async (_strings: TemplateStringsArray, ...values: unknown[]) => {
+      const [decrement, id, organizationId] = values as [number, string, string];
+      const balance = balanceFor(id);
+      // Simulate "no row matched" (unknown part / other organization).
+      if (balance === null) return [];
+      updates.push({ id, organizationId, decrement });
+      return [{ quantity: balance }];
+    },
+  );
+
+  const createMany = vi.fn().mockResolvedValue({ count: 0 });
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  const tx = { inventoryPart: { updateMany } } as any;
-  return { tx, updateMany };
+  const tx = { $queryRaw, stockMovement: { createMany } } as any;
+  return { tx, updates, createMany, $queryRaw };
 }
 
-/** Extract {id, decrement} pairs from recorded updateMany calls. */
+/** The ledger rows the helper attempted to persist. */
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
-function movements(updateMany: any) {
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  return updateMany.mock.calls.map((c: any[]) => ({
-    id: c[0].where.id,
-    organizationId: c[0].where.organizationId,
-    decrement: c[0].data.quantity.decrement,
-  }));
+function ledger(createMany: any) {
+  if (createMany.mock.calls.length === 0) return [];
+  return createMany.mock.calls[0][0].data;
 }
 
 describe("reconcileInventoryForParts", () => {
   it("deducts stock for newly added linked parts (delta from empty)", async () => {
-    const { tx, updateMany } = makeTx();
-    await reconcileInventoryForParts(tx, ORG, [], [{ inventoryPartId: "p1", quantity: 3 }]);
-    expect(movements(updateMany)).toEqual([{ id: "p1", organizationId: ORG, decrement: 3 }]);
+    const { tx, updates } = makeTx();
+    await reconcileInventoryForParts(tx, ORG, [], [{ inventoryPartId: "p1", quantity: 3 }], CTX);
+    expect(updates).toEqual([{ id: "p1", organizationId: ORG, decrement: 3 }]);
   });
 
   it("ignores free-text parts that are not linked to inventory", async () => {
-    const { tx, updateMany } = makeTx();
-    await reconcileInventoryForParts(tx, ORG, [], [
-      { inventoryPartId: null, quantity: 5 },
-      { quantity: 2 },
-    ]);
-    expect(updateMany).not.toHaveBeenCalled();
+    const { tx, updates, createMany } = makeTx();
+    await reconcileInventoryForParts(
+      tx,
+      ORG,
+      [],
+      [{ inventoryPartId: null, quantity: 5 }, { quantity: 2 }],
+      CTX,
+    );
+    expect(updates).toHaveLength(0);
+    expect(createMany).not.toHaveBeenCalled();
   });
 
   it("restocks fully when a part is removed (delete work order)", async () => {
-    const { tx, updateMany } = makeTx();
-    await reconcileInventoryForParts(tx, ORG, [{ inventoryPartId: "p1", quantity: 4 }], []);
+    const { tx, updates } = makeTx();
+    await reconcileInventoryForParts(tx, ORG, [{ inventoryPartId: "p1", quantity: 4 }], [], CTX);
     // Negative decrement = increment = restock.
-    expect(movements(updateMany)).toEqual([{ id: "p1", organizationId: ORG, decrement: -4 }]);
+    expect(updates).toEqual([{ id: "p1", organizationId: ORG, decrement: -4 }]);
   });
 
   it("applies only the delta when a quantity increases", async () => {
-    const { tx, updateMany } = makeTx();
+    const { tx, updates } = makeTx();
     await reconcileInventoryForParts(
       tx,
       ORG,
       [{ inventoryPartId: "p1", quantity: 2 }],
       [{ inventoryPartId: "p1", quantity: 5 }],
+      CTX,
     );
-    expect(movements(updateMany)).toEqual([{ id: "p1", organizationId: ORG, decrement: 3 }]);
+    expect(updates).toEqual([{ id: "p1", organizationId: ORG, decrement: 3 }]);
   });
 
   it("restocks the delta when a quantity decreases", async () => {
-    const { tx, updateMany } = makeTx();
+    const { tx, updates } = makeTx();
     await reconcileInventoryForParts(
       tx,
       ORG,
       [{ inventoryPartId: "p1", quantity: 5 }],
       [{ inventoryPartId: "p1", quantity: 2 }],
+      CTX,
     );
-    expect(movements(updateMany)).toEqual([{ id: "p1", organizationId: ORG, decrement: -3 }]);
+    expect(updates).toEqual([{ id: "p1", organizationId: ORG, decrement: -3 }]);
   });
 
   it("does nothing when the linked parts are unchanged (no double-count on re-save)", async () => {
-    const { tx, updateMany } = makeTx();
+    const { tx, updates, createMany } = makeTx();
     const same = [{ inventoryPartId: "p1", quantity: 3 }];
-    await reconcileInventoryForParts(tx, ORG, same, [...same]);
-    expect(updateMany).not.toHaveBeenCalled();
+    await reconcileInventoryForParts(tx, ORG, same, [...same], CTX);
+    expect(updates).toHaveLength(0);
+    expect(createMany).not.toHaveBeenCalled();
   });
 
   it("aggregates multiple lines that reference the same inventory part", async () => {
-    const { tx, updateMany } = makeTx();
-    await reconcileInventoryForParts(tx, ORG, [], [
-      { inventoryPartId: "p1", quantity: 2 },
-      { inventoryPartId: "p1", quantity: 3 },
-    ]);
-    expect(movements(updateMany)).toEqual([{ id: "p1", organizationId: ORG, decrement: 5 }]);
+    const { tx, updates } = makeTx();
+    await reconcileInventoryForParts(
+      tx,
+      ORG,
+      [],
+      [
+        { inventoryPartId: "p1", quantity: 2 },
+        { inventoryPartId: "p1", quantity: 3 },
+      ],
+      CTX,
+    );
+    expect(updates).toEqual([{ id: "p1", organizationId: ORG, decrement: 5 }]);
   });
 
   it("handles several different parts changing at once", async () => {
-    const { tx, updateMany } = makeTx();
+    const { tx, updates } = makeTx();
     await reconcileInventoryForParts(
       tx,
       ORG,
@@ -107,30 +148,89 @@ describe("reconcileInventoryForParts", () => {
         { inventoryPartId: "p3", quantity: 4 }, // new -> deduct 4
         // p2 removed -> restock 1
       ],
+      CTX,
     );
-    const moves = movements(updateMany);
-    expect(moves).toContainEqual({ id: "p2", organizationId: ORG, decrement: -1 });
-    expect(moves).toContainEqual({ id: "p3", organizationId: ORG, decrement: 4 });
-    expect(moves.find((m: { id: string }) => m.id === "p1")).toBeUndefined();
-    expect(moves).toHaveLength(2);
+    expect(updates).toContainEqual({ id: "p2", organizationId: ORG, decrement: -1 });
+    expect(updates).toContainEqual({ id: "p3", organizationId: ORG, decrement: 4 });
+    expect(updates.find((m) => m.id === "p1")).toBeUndefined();
+    expect(updates).toHaveLength(2);
   });
 
   it("rounds fractional quantities to whole stock units", async () => {
-    const { tx, updateMany } = makeTx();
-    await reconcileInventoryForParts(tx, ORG, [], [
-      { inventoryPartId: "p1", quantity: 2.4 },
-      { inventoryPartId: "p2", quantity: 2.6 },
-    ]);
-    const moves = movements(updateMany);
-    expect(moves).toContainEqual({ id: "p1", organizationId: ORG, decrement: 2 });
-    expect(moves).toContainEqual({ id: "p2", organizationId: ORG, decrement: 3 });
+    const { tx, updates } = makeTx();
+    await reconcileInventoryForParts(
+      tx,
+      ORG,
+      [],
+      [
+        { inventoryPartId: "p1", quantity: 2.4 },
+        { inventoryPartId: "p2", quantity: 2.6 },
+      ],
+      CTX,
+    );
+    expect(updates).toContainEqual({ id: "p1", organizationId: ORG, decrement: 2 });
+    expect(updates).toContainEqual({ id: "p2", organizationId: ORG, decrement: 3 });
   });
 
   it("always scopes writes to the caller's organization", async () => {
-    const { tx, updateMany } = makeTx();
-    await reconcileInventoryForParts(tx, ORG, [], [{ inventoryPartId: "p1", quantity: 1 }]);
-    expect(updateMany).toHaveBeenCalledWith(
-      expect.objectContaining({ where: expect.objectContaining({ organizationId: ORG }) }),
-    );
+    const { tx, updates } = makeTx();
+    await reconcileInventoryForParts(tx, ORG, [], [{ inventoryPartId: "p1", quantity: 1 }], CTX);
+    expect(updates.every((m) => m.organizationId === ORG)).toBe(true);
+  });
+
+  // --- Ledger -------------------------------------------------------------
+
+  it("records a signed ledger entry with the post-write balance", async () => {
+    const { tx, createMany } = makeTx(() => 97);
+    await reconcileInventoryForParts(tx, ORG, [], [{ inventoryPartId: "p1", quantity: 3 }], CTX);
+    expect(ledger(createMany)).toEqual([
+      {
+        inventoryPartId: "p1",
+        organizationId: ORG,
+        delta: -3, // consumed
+        quantityAfter: 97,
+        reason: "service_record",
+        userId: "user-1",
+        serviceRecordId: "svc-1",
+        serviceRecordLabel: "INV-1001",
+        note: null,
+      },
+    ]);
+  });
+
+  it("records a positive delta when stock is returned", async () => {
+    const { tx, createMany } = makeTx(() => 104);
+    await reconcileInventoryForParts(tx, ORG, [{ inventoryPartId: "p1", quantity: 4 }], [], CTX);
+    const rows = ledger(createMany);
+    expect(rows).toHaveLength(1);
+    expect(rows[0].delta).toBe(4);
+    expect(rows[0].quantityAfter).toBe(104);
+  });
+
+  it("writes no ledger row when the part matched no row (wrong organization)", async () => {
+    // balanceFor returns null -> UPDATE matched nothing.
+    const { tx, createMany, updates } = makeTx(() => null);
+    await reconcileInventoryForParts(tx, ORG, [], [{ inventoryPartId: "ghost", quantity: 2 }], CTX);
+    expect(updates).toHaveLength(0);
+    expect(createMany).not.toHaveBeenCalled();
+  });
+
+  it("carries the caller-supplied reason and provenance onto every row", async () => {
+    const { tx, createMany } = makeTx(() => 5);
+    await reconcileInventoryForParts(tx, ORG, [], [{ inventoryPartId: "p1", quantity: 1 }], {
+      reason: "quote_conversion",
+      userId: "user-9",
+      serviceRecordId: "svc-9",
+      serviceRecordLabel: "Q-42",
+      note: "converted from quote",
+    });
+    const rows = ledger(createMany);
+    expect(rows[0]).toMatchObject({
+      reason: "quote_conversion",
+      userId: "user-9",
+      serviceRecordId: "svc-9",
+      serviceRecordLabel: "Q-42",
+      note: "converted from quote",
+    });
   });
 });

+ 222 - 0
src/__tests__/features/quotes/convert-quote-inventory.test.ts

@@ -0,0 +1,222 @@
+/**
+ * Tests for convertQuoteToServiceRecord — the point at which a quote's parts
+ * become real consumption.
+ *
+ * A quote is only an estimate, so it never moves stock. Converting it to a job
+ * is the moment the parts leave the shelf, so the conversion must:
+ *   - carry each line's `inventoryPartId` onto the resulting ServicePart, and
+ *   - deduct stock exactly once, inside the same transaction.
+ *
+ * Before this was wired up, converting a quote created the job but left
+ * inventory untouched, so stock silently never went down for any workshop that
+ * quoted first.
+ */
+
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("@/lib/cached-session", () => ({
+  getCachedSession: vi.fn(),
+  getCachedMembership: vi.fn(),
+}));
+
+vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
+
+vi.mock("@/lib/invoice-utils", () => ({
+  resolveInvoicePrefix: vi.fn((p: string) => p),
+}));
+
+vi.mock("@/lib/db", () => ({
+  db: {
+    user: { findUnique: vi.fn() },
+    appSetting: { findMany: vi.fn() },
+    quote: { findFirst: vi.fn() },
+    vehicle: { findFirst: vi.fn() },
+    organization: { findUnique: vi.fn() },
+    serviceRecord: { findFirst: vi.fn() },
+    $transaction: vi.fn(),
+  },
+}));
+
+import { getCachedSession, getCachedMembership } from "@/lib/cached-session";
+import { db } from "@/lib/db";
+import { convertQuoteToServiceRecord } from "@/features/quotes/Actions/quoteActions";
+
+const ORG = "org-1";
+const USER_ID = "user-1";
+const VEHICLE_ID = "veh-1";
+const QUOTE_ID = "quote-1";
+
+function setupAuth() {
+  vi.mocked(getCachedSession).mockResolvedValue({
+    user: { id: USER_ID, email: "user@example.com" },
+  } as any);
+  vi.mocked(getCachedMembership).mockResolvedValue({
+    organizationId: ORG,
+    role: "owner",
+    roleId: null,
+    customRole: null,
+  } as any);
+  vi.mocked(db.user.findUnique).mockResolvedValue({ isSuperAdmin: false } as any);
+}
+
+/** Wire up a quote with the given part lines and capture what conversion does. */
+function setupConversion(partItems: any[]) {
+  vi.mocked(db.quote.findFirst).mockResolvedValue({
+    id: QUOTE_ID,
+    quoteNumber: "Q-1",
+    title: "Brake job",
+    description: null,
+    subtotal: 100,
+    taxRate: 0,
+    taxAmount: 0,
+    taxInclusive: false,
+    totalAmount: 100,
+    discountType: null,
+    discountValue: 0,
+    discountAmount: 0,
+    partItems,
+    laborItems: [],
+    attachments: [],
+  } as any);
+  vi.mocked(db.vehicle.findFirst).mockResolvedValue({ id: VEHICLE_ID } as any);
+  vi.mocked(db.appSetting.findMany).mockResolvedValue([] as any);
+  vi.mocked(db.organization.findUnique).mockResolvedValue({ name: "Shop" } as any);
+  vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null as any);
+
+  const servicePartCreateMany = vi.fn().mockResolvedValue({ count: partItems.length });
+  const stockMovementCreateMany = vi.fn().mockResolvedValue({ count: 1 });
+  const queryRaw = vi.fn().mockResolvedValue([{ quantity: 7 }]);
+
+  vi.mocked(db.$transaction).mockImplementation(async (fn: any) =>
+    fn({
+      serviceRecord: {
+        create: vi.fn().mockResolvedValue({
+          id: "sr-1",
+          title: "Brake job",
+          invoiceNumber: "1001",
+          vehicleId: VEHICLE_ID,
+        }),
+      },
+      servicePart: { createMany: servicePartCreateMany },
+      serviceLabor: { createMany: vi.fn() },
+      serviceAttachment: { createMany: vi.fn() },
+      quote: { update: vi.fn() },
+      stockMovement: { createMany: stockMovementCreateMany },
+      $queryRaw: queryRaw,
+    }),
+  );
+
+  return { servicePartCreateMany, stockMovementCreateMany, queryRaw };
+}
+
+describe("convertQuoteToServiceRecord — inventory", () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    setupAuth();
+  });
+
+  it("deducts stock for quote lines linked to inventory", async () => {
+    const { queryRaw } = setupConversion([
+      {
+        name: "Brake pad",
+        partNumber: "BP-1",
+        quantity: 2,
+        unitPrice: 50,
+        total: 100,
+        excluded: false,
+        inventoryPartId: "inv-1",
+      },
+    ]);
+
+    await convertQuoteToServiceRecord(QUOTE_ID, VEHICLE_ID);
+
+    expect(queryRaw).toHaveBeenCalledTimes(1);
+    const [, decrement, partId, orgId] = queryRaw.mock.calls[0];
+    expect(decrement).toBe(2);
+    expect(partId).toBe("inv-1");
+    expect(orgId).toBe(ORG);
+  });
+
+  it("carries inventoryPartId onto the created service parts", async () => {
+    const { servicePartCreateMany } = setupConversion([
+      {
+        name: "Brake pad",
+        partNumber: "BP-1",
+        quantity: 2,
+        unitPrice: 50,
+        total: 100,
+        excluded: false,
+        inventoryPartId: "inv-1",
+      },
+    ]);
+
+    await convertQuoteToServiceRecord(QUOTE_ID, VEHICLE_ID);
+
+    expect(servicePartCreateMany).toHaveBeenCalledWith({
+      data: [expect.objectContaining({ inventoryPartId: "inv-1", serviceRecordId: "sr-1" })],
+    });
+  });
+
+  it("records the movement against the new job in the ledger", async () => {
+    const { stockMovementCreateMany } = setupConversion([
+      {
+        name: "Brake pad",
+        quantity: 2,
+        unitPrice: 50,
+        total: 100,
+        excluded: false,
+        inventoryPartId: "inv-1",
+      },
+    ]);
+
+    await convertQuoteToServiceRecord(QUOTE_ID, VEHICLE_ID);
+
+    expect(stockMovementCreateMany).toHaveBeenCalledWith({
+      data: [
+        expect.objectContaining({
+          inventoryPartId: "inv-1",
+          organizationId: ORG,
+          delta: -2,
+          quantityAfter: 7,
+          reason: "quote_conversion",
+          serviceRecordId: "sr-1",
+        }),
+      ],
+    });
+  });
+
+  it("leaves stock alone for free-text lines with no inventory link", async () => {
+    const { queryRaw, stockMovementCreateMany } = setupConversion([
+      {
+        name: "Shop supplies",
+        quantity: 1,
+        unitPrice: 10,
+        total: 10,
+        excluded: false,
+        inventoryPartId: null,
+      },
+    ]);
+
+    await convertQuoteToServiceRecord(QUOTE_ID, VEHICLE_ID);
+
+    expect(queryRaw).not.toHaveBeenCalled();
+    expect(stockMovementCreateMany).not.toHaveBeenCalled();
+  });
+
+  it("does not consume stock for lines excluded from the quote total", async () => {
+    const { queryRaw } = setupConversion([
+      {
+        name: "Optional wiper",
+        quantity: 3,
+        unitPrice: 20,
+        total: 60,
+        excluded: true,
+        inventoryPartId: "inv-9",
+      },
+    ]);
+
+    await convertQuoteToServiceRecord(QUOTE_ID, VEHICLE_ID);
+
+    expect(queryRaw).not.toHaveBeenCalled();
+  });
+});

+ 38 - 15
src/__tests__/features/workorders/create-workorder.test.ts

@@ -257,14 +257,19 @@ describe("createServiceRecord — parts and inventory", () => {
 
     const mockCreate = vi.fn().mockResolvedValue({ id: "sr-inv" });
     const mockInvFind = vi.fn().mockResolvedValue({ id: "inv-1", quantity: 10, organizationId: ORG });
-    const mockInvUpdateMany = vi.fn().mockResolvedValue({ count: 1 });
+    // Stock moves via an atomic `UPDATE ... RETURNING`; params arrive as
+    // (strings, decrement, inventoryPartId, organizationId).
+    const mockQueryRaw = vi.fn().mockResolvedValue([{ quantity: 9 }]);
+    const mockMovementCreateMany = vi.fn().mockResolvedValue({ count: 1 });
     vi.mocked(db.$transaction).mockImplementation(async (fn: any) =>
       fn({
         serviceRecord: { create: mockCreate },
         servicePart: { createMany: vi.fn() },
         serviceLabor: { createMany: vi.fn() },
         serviceAttachment: { createMany: vi.fn() },
-        inventoryPart: { findFirst: mockInvFind, updateMany: mockInvUpdateMany },
+        inventoryPart: { findFirst: mockInvFind },
+        stockMovement: { createMany: mockMovementCreateMany },
+        $queryRaw: mockQueryRaw,
       })
     );
 
@@ -281,12 +286,25 @@ describe("createServiceRecord — parts and inventory", () => {
     });
 
     // Stock is deducted atomically and scoped to the org.
-    expect(mockInvUpdateMany).toHaveBeenCalledWith(
-      expect.objectContaining({
-        where: { id: "inv-1", organizationId: ORG },
-        data: { quantity: { decrement: 1 } },
-      })
-    );
+    expect(mockQueryRaw).toHaveBeenCalledTimes(1);
+    const [, decrement, partId, orgId] = mockQueryRaw.mock.calls[0];
+    expect(decrement).toBe(1);
+    expect(partId).toBe("inv-1");
+    expect(orgId).toBe(ORG);
+
+    // ...and the movement is mirrored into the audit ledger.
+    expect(mockMovementCreateMany).toHaveBeenCalledWith({
+      data: [
+        expect.objectContaining({
+          inventoryPartId: "inv-1",
+          organizationId: ORG,
+          delta: -1,
+          quantityAfter: 9,
+          reason: "service_record",
+          serviceRecordId: "sr-inv",
+        }),
+      ],
+    });
   });
 
   it("allows stock to go negative when consuming more than on hand (reversible over-consumption)", async () => {
@@ -295,14 +313,18 @@ describe("createServiceRecord — parts and inventory", () => {
 
     const mockCreate = vi.fn().mockResolvedValue({ id: "sr-neg" });
     const mockInvFind = vi.fn().mockResolvedValue({ id: "inv-2", quantity: 1, organizationId: ORG });
-    const mockInvUpdateMany = vi.fn().mockResolvedValue({ count: 1 });
+    // 1 on hand, 5 consumed -> the DB returns the resulting negative balance.
+    const mockQueryRaw = vi.fn().mockResolvedValue([{ quantity: -4 }]);
+    const mockMovementCreateMany = vi.fn().mockResolvedValue({ count: 1 });
     vi.mocked(db.$transaction).mockImplementation(async (fn: any) =>
       fn({
         serviceRecord: { create: mockCreate },
         servicePart: { createMany: vi.fn() },
         serviceLabor: { createMany: vi.fn() },
         serviceAttachment: { createMany: vi.fn() },
-        inventoryPart: { findFirst: mockInvFind, updateMany: mockInvUpdateMany },
+        inventoryPart: { findFirst: mockInvFind },
+        stockMovement: { createMany: mockMovementCreateMany },
+        $queryRaw: mockQueryRaw,
       })
     );
 
@@ -321,11 +343,12 @@ describe("createServiceRecord — parts and inventory", () => {
     // Decrement the full 5 (1 on hand -> -4). Clamping at 0 would corrupt the
     // count once the record is later edited or deleted, so the true signed
     // value is kept.
-    expect(mockInvUpdateMany).toHaveBeenCalledWith(
-      expect.objectContaining({
-        data: { quantity: { decrement: 5 } },
-      })
-    );
+    const [, decrement] = mockQueryRaw.mock.calls[0];
+    expect(decrement).toBe(5);
+    // The ledger records the true signed balance, including going negative.
+    expect(mockMovementCreateMany).toHaveBeenCalledWith({
+      data: [expect.objectContaining({ delta: -5, quantityAfter: -4 })],
+    });
   });
 });
 

+ 103 - 0
src/__tests__/hooks/use-debounced-search.test.ts

@@ -0,0 +1,103 @@
+/**
+ * Tests for useDebouncedSearch — the live-search input used by every list page.
+ *
+ * The subtle case this guards is a race between typing and navigation: the
+ * committed term round-trips through the server and comes back as the `initial`
+ * prop. If the hook adopted that echo blindly it would overwrite whatever the
+ * user typed in the meantime, and the input would visibly drop characters.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { act, renderHook } from "@testing-library/react";
+import { useDebouncedSearch } from "@/hooks/use-debounced-search";
+
+const DELAY = 350;
+
+describe("useDebouncedSearch", () => {
+  beforeEach(() => vi.useFakeTimers());
+  afterEach(() => vi.useRealTimers());
+
+  it("commits once after typing pauses", () => {
+    const onCommit = vi.fn();
+    const { result } = renderHook(() => useDebouncedSearch("", onCommit, DELAY));
+
+    act(() => result.current.setValue("bra"));
+    act(() => result.current.setValue("brak"));
+    act(() => result.current.setValue("brake"));
+    expect(onCommit).not.toHaveBeenCalled();
+
+    act(() => void vi.advanceTimersByTime(DELAY));
+    expect(onCommit).toHaveBeenCalledTimes(1);
+    expect(onCommit).toHaveBeenCalledWith("brake");
+  });
+
+  it("does not clobber characters typed while the search navigates", () => {
+    const onCommit = vi.fn();
+    const { result, rerender } = renderHook(
+      ({ initial }) => useDebouncedSearch(initial, onCommit, DELAY),
+      { initialProps: { initial: "" } },
+    );
+
+    act(() => result.current.setValue("brak"));
+    act(() => void vi.advanceTimersByTime(DELAY));
+    expect(onCommit).toHaveBeenCalledWith("brak");
+
+    // User keeps typing before the server responds...
+    act(() => result.current.setValue("brake"));
+    // ...and the committed term now arrives back as a prop.
+    rerender({ initial: "brak" });
+
+    // The extra character must survive.
+    expect(result.current.value).toBe("brake");
+  });
+
+  it("still adopts a genuinely external URL change (back button, reset)", () => {
+    const onCommit = vi.fn();
+    const { result, rerender } = renderHook(
+      ({ initial }) => useDebouncedSearch(initial, onCommit, DELAY),
+      { initialProps: { initial: "brake" } },
+    );
+
+    expect(result.current.value).toBe("brake");
+    rerender({ initial: "" });
+    expect(result.current.value).toBe("");
+  });
+
+  it("does not re-commit the term the server already applied", () => {
+    const onCommit = vi.fn();
+    renderHook(() => useDebouncedSearch("brake", onCommit, DELAY));
+    act(() => void vi.advanceTimersByTime(DELAY * 2));
+    expect(onCommit).not.toHaveBeenCalled();
+  });
+
+  it("commitNow fires immediately and cancels the pending debounce", () => {
+    const onCommit = vi.fn();
+    const { result } = renderHook(() => useDebouncedSearch("", onCommit, DELAY));
+
+    act(() => result.current.setValue("brake"));
+    act(() => result.current.commitNow());
+    expect(onCommit).toHaveBeenCalledTimes(1);
+
+    // The already-scheduled timer must not fire a duplicate navigation.
+    act(() => void vi.advanceTimersByTime(DELAY * 2));
+    expect(onCommit).toHaveBeenCalledTimes(1);
+  });
+
+  it("clearing the input commits undefined so the param is removed", () => {
+    const onCommit = vi.fn();
+    const { result } = renderHook(() => useDebouncedSearch("brake", onCommit, DELAY));
+
+    act(() => result.current.setValue(""));
+    act(() => void vi.advanceTimersByTime(DELAY));
+    expect(onCommit).toHaveBeenCalledWith(undefined);
+  });
+
+  it("treats a trailing space as the same search", () => {
+    const onCommit = vi.fn();
+    const { result } = renderHook(() => useDebouncedSearch("brake", onCommit, DELAY));
+
+    act(() => result.current.setValue("brake "));
+    act(() => void vi.advanceTimersByTime(DELAY * 2));
+    expect(onCommit).not.toHaveBeenCalled();
+  });
+});

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

@@ -1,6 +1,8 @@
 "use client";
 
-import { useState, useCallback, useTransition } from "react";
+
+import { useDebouncedSearch } from '@/hooks/use-debounced-search';
+import { useCallback, useTransition } from "react";
 import Link from "next/link";
 import { useRouter, usePathname, useSearchParams } from "next/navigation";
 import { useTranslations } from "next-intl";
@@ -93,7 +95,6 @@ export default function BillingClient({
   const pathname = usePathname();
   const searchParams = useSearchParams();
   const [isPending, startTransition] = useTransition();
-  const [searchValue, setSearchValue] = useState(search);
 
   const createQueryString = useCallback(
     (params: Record<string, string>) => {
@@ -121,17 +122,22 @@ export default function BillingClient({
     });
   };
 
-  const handleSearch = (e: React.FormEvent) => {
-    e.preventDefault();
+  // Live search: filters as you type, no Enter required. Submitting the
+  // form (Enter) commits immediately, bypassing the debounce.
+  const {
+    value: searchValue,
+    setValue: setSearchValue,
+    commitNow: handleSearch,
+  } = useDebouncedSearch(search, (term) => {
     startTransition(() => {
       router.push(
         `${pathname}?${createQueryString({
-          search: searchValue,
+          search: term ?? "",
           page: "1",
         })}`
       );
     });
-  };
+  });
 
   const handleNavigate = (params: Record<string, string | number | undefined>) => {
     const merged: Record<string, string> = {};

+ 9 - 8
src/app/(authenticated)/customers/customers-client.tsx

@@ -1,5 +1,7 @@
 "use client";
 
+import { useDebouncedSearch } from '@/hooks/use-debounced-search';
+
 import { useState, useCallback, useTransition, useEffect } from "react";
 import { useRouter, usePathname, useSearchParams } from "next/navigation";
 import { useTranslations } from "next-intl";
@@ -72,7 +74,6 @@ export function CustomersClient({
   const pathname = usePathname();
   const searchParams = useSearchParams();
   const [isPending, startTransition] = useTransition();
-  const [searchInput, setSearchInput] = useState(search);
   const [showForm, setShowForm] = useState(false);
   const [showImport, setShowImport] = useState(false);
   const [editCustomer, setEditCustomer] = useState<Customer | null>(null);
@@ -111,13 +112,13 @@ export function CustomersClient({
     [router, pathname, searchParams]
   );
 
-  const handleSearch = useCallback(
-    (e: React.FormEvent) => {
-      e.preventDefault();
-      navigate({ search: searchInput || undefined });
-    },
-    [navigate, searchInput]
-  );
+  // Live search: filters as you type, no Enter required. Submitting the
+  // form (Enter) commits immediately, bypassing the debounce.
+  const {
+    value: searchInput,
+    setValue: setSearchInput,
+    commitNow: handleSearch,
+  } = useDebouncedSearch(search, (term) => navigate({ search: term }));
 
   const toggleSelect = (id: string) => {
     setSelected((prev) => {

+ 15 - 1
src/app/(authenticated)/dashboard-client.tsx

@@ -73,6 +73,8 @@ interface DashboardStats {
   activeJobs: number;
   pendingJobs: number;
   totalParts: number;
+  /** Parts at or below their reorder point (minQuantity). */
+  lowStockParts: number;
   totalCustomers: number;
   todaysServices: ServiceItem[];
   recentServices: ServiceItem[];
@@ -299,7 +301,7 @@ export function DashboardClient({
     <TooltipProvider>
     <div className="space-y-4">
       {/* Quick stats row */}
-      <div className={`grid grid-cols-2 gap-2 ${stats.isAdmin ? "sm:grid-cols-4" : "sm:grid-cols-2"}`}>
+      <div className={`grid grid-cols-2 gap-2 ${stats.isAdmin ? (stats.lowStockParts > 0 ? "sm:grid-cols-5" : "sm:grid-cols-4") : "sm:grid-cols-2"}`}>
         <Link href="/work-orders?status=active" className="rounded-lg border border-0 shadow-sm bg-card px-3 py-2 transition-colors hover:bg-muted/50">
           <div className="flex items-center justify-between">
             <span className="text-[11px] text-muted-foreground">{t("stats.activeJobs")}</span>
@@ -323,6 +325,18 @@ export function DashboardClient({
             <p className="text-lg font-bold">{stats.totalParts}</p>
           </Link>
         )}
+        {stats.isAdmin && stats.lowStockParts > 0 && (
+          <Link
+            href="/inventory?lowStock=1"
+            className="rounded-lg border-0 bg-amber-50 px-3 py-2 shadow-sm transition-colors hover:bg-amber-100 dark:bg-amber-950/40 dark:hover:bg-amber-950/60"
+          >
+            <div className="flex items-center justify-between">
+              <span className="text-[11px] text-amber-700 dark:text-amber-500">{t("stats.lowStock")}</span>
+              <AlertTriangle className="h-3.5 w-3.5 text-amber-600/70" />
+            </div>
+            <p className="text-lg font-bold text-amber-700 dark:text-amber-500">{stats.lowStockParts}</p>
+          </Link>
+        )}
         {stats.isAdmin && (
           <Link href="/customers" className="rounded-lg border border-0 shadow-sm bg-card px-3 py-2 transition-colors hover:bg-muted/50">
             <div className="flex items-center justify-between">

+ 9 - 8
src/app/(authenticated)/inspections/inspections-client.tsx

@@ -1,5 +1,7 @@
 "use client";
 
+import { useDebouncedSearch } from '@/hooks/use-debounced-search';
+
 import { useState, useCallback, useTransition } from "react";
 import { useRouter, usePathname, useSearchParams } from "next/navigation";
 import { useFormatDate } from "@/lib/use-format-date";
@@ -102,7 +104,6 @@ export function InspectionsClient({
   const pathname = usePathname();
   const searchParams = useSearchParams();
   const [isPending, startTransition] = useTransition();
-  const [searchInput, setSearchInput] = useState(search);
   const [showNewDialog, setShowNewDialog] = useState(false);
 
   const navigate = useCallback(
@@ -123,13 +124,13 @@ export function InspectionsClient({
     [router, pathname, searchParams]
   );
 
-  const handleSearch = useCallback(
-    (e: React.FormEvent) => {
-      e.preventDefault();
-      navigate({ search: searchInput || undefined });
-    },
-    [navigate, searchInput]
-  );
+  // Live search: filters as you type, no Enter required. Submitting the
+  // form (Enter) commits immediately, bypassing the debounce.
+  const {
+    value: searchInput,
+    setValue: setSearchInput,
+    commitNow: handleSearch,
+  } = useDebouncedSearch(search, (term) => navigate({ search: term }));
 
   return (
     <div className="space-y-4">

+ 99 - 0
src/app/(authenticated)/inventory/[id]/page.tsx

@@ -0,0 +1,99 @@
+import { notFound } from "next/navigation";
+import {
+  getInventoryPart,
+  getStockMovementsPaginated,
+} from "@/features/inventory/Actions/getStockMovements";
+import { getInventoryCategories } from "@/features/inventory/Actions/inventoryActions";
+import { getSettings } from "@/features/settings/Actions/settingsActions";
+import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
+import { PageHeader } from "@/components/page-header";
+import { formatDateTime } from "@/lib/format";
+import { PartDetailClient } from "./part-detail-client";
+
+const PAGE_SIZE = 50;
+
+export default async function InventoryPartDetailPage({
+  params,
+  searchParams,
+}: {
+  params: Promise<{ id: string }>;
+  searchParams: Promise<{ page?: string; reason?: string }>;
+}) {
+  const { id } = await params;
+  const sp = await searchParams;
+  const page = Math.max(1, Number(sp.page) || 1);
+
+  const [partResult, movementsResult, categoriesResult, settingsResult] =
+    await Promise.all([
+      getInventoryPart(id),
+      getStockMovementsPaginated({
+        inventoryPartId: id,
+        page,
+        pageSize: PAGE_SIZE,
+        reason: sp.reason,
+      }),
+      getInventoryCategories(),
+      getSettings([
+        SETTING_KEYS.CURRENCY_CODE,
+        SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER,
+        SETTING_KEYS.DATE_FORMAT,
+        SETTING_KEYS.TIME_FORMAT,
+        SETTING_KEYS.TIMEZONE,
+        SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD,
+      ]),
+    ]);
+
+  // Covers both "no such part" and "belongs to another organization", since
+  // the action scopes by organizationId.
+  if (!partResult.success || !partResult.data) {
+    notFound();
+  }
+
+  const settings = settingsResult.success ? (settingsResult.data ?? {}) : {};
+  const movements = movementsResult.data ?? {
+    movements: [],
+    total: 0,
+    page,
+    pageSize: PAGE_SIZE,
+    totalPages: 1,
+  };
+
+  // Timestamps are formatted here, on the server, and shipped as strings.
+  // Formatting them in the client component instead would render the server's
+  // timezone during SSR and the browser's after hydration — a guaranteed
+  // mismatch for any viewer not on UTC, which React reports as a hydration
+  // error. The org's configured timezone (when set) is authoritative for a
+  // workshop's records anyway.
+  const movementsWithLabels = movements.movements.map((m) => ({
+    ...m,
+    createdAtLabel: formatDateTime(
+      m.createdAt,
+      settings[SETTING_KEYS.DATE_FORMAT],
+      (settings[SETTING_KEYS.TIME_FORMAT] as "12h" | "24h") || undefined,
+      settings[SETTING_KEYS.TIMEZONE] || undefined,
+    ),
+  }));
+
+  return (
+    <>
+      <PageHeader />
+      <PartDetailClient
+        part={partResult.data}
+        movements={movementsWithLabels}
+        total={movements.total}
+        page={movements.page}
+        pageSize={movements.pageSize}
+        totalPages={movements.totalPages}
+        reason={sp.reason ?? ""}
+        currencyCode={settings[SETTING_KEYS.CURRENCY_CODE] || "USD"}
+        markupMultiplier={
+          Number(settings[SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER]) || 1
+        }
+        categories={categoriesResult.data ?? []}
+        lowStockDefault={
+          Number(settings[SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD]) || 0
+        }
+      />
+    </>
+  );
+}

+ 286 - 0
src/app/(authenticated)/inventory/[id]/part-detail-client.tsx

@@ -0,0 +1,286 @@
+"use client";
+
+import { useCallback, useState } from "react";
+import Link from "next/link";
+import { useRouter, usePathname, useSearchParams } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from "@/components/ui/table";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import { DataTablePagination } from "@/components/data-table-pagination";
+import { InventoryPartForm } from "@/features/inventory/Components/InventoryPartForm";
+import { STOCK_MOVEMENT_REASONS } from "@/features/inventory/Lib/stockMovementReasons";
+import { isLow as isLowStock } from "@/features/inventory/Lib/lowStockAlerts";
+import { useFormatCurrency } from "@/components/currency-settings-context";
+import { ArrowLeft, ArrowUpRight, Pencil } from "lucide-react";
+
+interface Movement {
+  id: string;
+  delta: number;
+  quantityAfter: number;
+  reason: string;
+  note: string | null;
+  createdAt: string;
+  /** Pre-formatted on the server so SSR and hydration agree. */
+  createdAtLabel: string;
+  userName: string | null;
+  serviceRecordId: string | null;
+  vehicleId: string | null;
+  label: string | null;
+  vehicle: string | null;
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type Part = any;
+
+export function PartDetailClient({
+  part,
+  movements,
+  total,
+  page,
+  pageSize,
+  totalPages,
+  reason,
+  currencyCode,
+  markupMultiplier,
+  categories,
+  lowStockDefault,
+}: {
+  part: Part;
+  movements: Movement[];
+  total: number;
+  page: number;
+  pageSize: number;
+  totalPages: number;
+  reason: string;
+  currencyCode: string;
+  markupMultiplier: number;
+  categories: string[];
+  lowStockDefault: number;
+}) {
+  const t = useTranslations("inventory");
+  const router = useRouter();
+  const pathname = usePathname();
+  const searchParams = useSearchParams();
+  const formatCurrency = useFormatCurrency();
+  const [showForm, setShowForm] = useState(false);
+
+  const navigate = useCallback(
+    (params: Record<string, string | number | undefined>) => {
+      const next = new URLSearchParams(searchParams.toString());
+      for (const [key, value] of Object.entries(params)) {
+        if (value === undefined || value === "") next.delete(key);
+        else next.set(key, String(value));
+      }
+      // Changing the filter invalidates the current page offset.
+      if (!("page" in params) && "reason" in params) next.delete("page");
+      const qs = next.toString();
+      router.push(qs ? `${pathname}?${qs}` : pathname);
+    },
+    [router, pathname, searchParams],
+  );
+
+  // Same rule as the alert engine, so the badge never disagrees with alerts.
+  const isLow = isLowStock(part, lowStockDefault);
+
+  const reasonLabel = (value: string) => {
+    switch (value) {
+      case "service_record":
+        return t("history.reasons.service_record");
+      case "service_record_deleted":
+        return t("history.reasons.service_record_deleted");
+      case "quote_conversion":
+        return t("history.reasons.quote_conversion");
+      case "manual_adjustment":
+        return t("history.reasons.manual_adjustment");
+      case "bulk_markup":
+        return t("history.reasons.bulk_markup");
+      default:
+        // A row written by a newer build may carry a reason this one has no
+        // translation for; show the raw value rather than an empty cell.
+        return value;
+    }
+  };
+
+  return (
+    <div className="space-y-6 p-4 pt-0">
+      <div className="flex flex-wrap items-start justify-between gap-3">
+        <div className="space-y-1">
+          <Button variant="ghost" size="sm" asChild className="-ml-2 h-7">
+            <Link href="/inventory">
+              <ArrowLeft className="mr-1 h-3.5 w-3.5" />
+              {t("history.backToInventory")}
+            </Link>
+          </Button>
+          <h1 className="text-2xl font-bold tracking-tight">{part.name}</h1>
+          <p className="text-sm text-muted-foreground">
+            {[part.partNumber, part.category, part.location]
+              .filter(Boolean)
+              .join(" · ") || "—"}
+          </p>
+        </div>
+        <Button onClick={() => setShowForm(true)}>
+          <Pencil className="mr-2 h-4 w-4" />
+          {t("actions.edit")}
+        </Button>
+      </div>
+
+      {/* Summary — the context you need while reading the ledger below. */}
+      <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
+        <div className="rounded-lg border bg-card px-3 py-2">
+          <p className="text-[11px] text-muted-foreground">{t("table.inStock")}</p>
+          <p className={`text-lg font-bold ${isLow ? "text-destructive" : ""}`}>
+            {part.quantity}
+            {isLow && (
+              <Badge variant="destructive" className="ml-2 px-1.5 py-0 text-[10px]">
+                {t("table.low")}
+              </Badge>
+            )}
+          </p>
+        </div>
+        <div className="rounded-lg border bg-card px-3 py-2">
+          <p className="text-[11px] text-muted-foreground">{t("form.minQty")}</p>
+          <p className="text-lg font-bold">{part.minQuantity}</p>
+        </div>
+        <div className="rounded-lg border bg-card px-3 py-2">
+          <p className="text-[11px] text-muted-foreground">{t("table.unitCost")}</p>
+          <p className="text-lg font-bold">{formatCurrency(part.unitCost, currencyCode)}</p>
+        </div>
+        <div className="rounded-lg border bg-card px-3 py-2">
+          <p className="text-[11px] text-muted-foreground">{t("table.sellPrice")}</p>
+          <p className="text-lg font-bold">{formatCurrency(part.sellPrice, currencyCode)}</p>
+        </div>
+      </div>
+
+      <div className="space-y-3">
+        <div className="flex flex-wrap items-center justify-between gap-2">
+          <div>
+            <h2 className="text-lg font-semibold">{t("history.title")}</h2>
+            <p className="text-sm text-muted-foreground">
+              {t("history.description", { name: part.name })}
+            </p>
+          </div>
+          <Select
+            value={reason || "all"}
+            onValueChange={(v) => navigate({ reason: v === "all" ? undefined : v })}
+          >
+            <SelectTrigger className="w-[220px]">
+              <SelectValue />
+            </SelectTrigger>
+            <SelectContent>
+              <SelectItem value="all">{t("history.allReasons")}</SelectItem>
+              {STOCK_MOVEMENT_REASONS.map((r) => (
+                <SelectItem key={r} value={r}>
+                  {reasonLabel(r)}
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+        </div>
+
+        <div className="rounded-lg border">
+          <Table>
+            <TableHeader>
+              <TableRow>
+                <TableHead>{t("history.when")}</TableHead>
+                <TableHead>{t("history.change")}</TableHead>
+                <TableHead>{t("history.balance")}</TableHead>
+                <TableHead>{t("history.usedOn")}</TableHead>
+                <TableHead>{t("history.reason")}</TableHead>
+                <TableHead>{t("history.by")}</TableHead>
+              </TableRow>
+            </TableHeader>
+            <TableBody>
+              {movements.length === 0 ? (
+                <TableRow>
+                  <TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
+                    {t("history.empty")}
+                  </TableCell>
+                </TableRow>
+              ) : (
+                movements.map((m) => (
+                  <TableRow key={m.id}>
+                    <TableCell className="whitespace-nowrap text-muted-foreground">
+                      {m.createdAtLabel}
+                    </TableCell>
+                    <TableCell>
+                      <Badge
+                        variant="outline"
+                        className={
+                          m.delta < 0
+                            ? "border-destructive/40 text-destructive"
+                            : "border-green-500/40 text-green-600"
+                        }
+                      >
+                        {m.delta > 0 ? `+${m.delta}` : m.delta}
+                      </Badge>
+                    </TableCell>
+                    <TableCell className="font-medium">{m.quantityAfter}</TableCell>
+                    <TableCell>
+                      {m.serviceRecordId && m.vehicleId ? (
+                        <Link
+                          href={`/vehicles/${m.vehicleId}/service/${m.serviceRecordId}`}
+                          className="inline-flex items-center gap-1 text-primary hover:underline"
+                        >
+                          {m.label}
+                          <ArrowUpRight className="h-3 w-3" />
+                        </Link>
+                      ) : (
+                        <span className="text-muted-foreground">{m.label ?? "—"}</span>
+                      )}
+                      {m.vehicle && (
+                        <span className="block text-xs text-muted-foreground">{m.vehicle}</span>
+                      )}
+                      {m.note && (
+                        <span className="block text-xs text-muted-foreground">{m.note}</span>
+                      )}
+                    </TableCell>
+                    <TableCell className="text-muted-foreground">
+                      {reasonLabel(m.reason)}
+                    </TableCell>
+                    <TableCell className="text-muted-foreground">{m.userName ?? "—"}</TableCell>
+                  </TableRow>
+                ))
+              )}
+            </TableBody>
+          </Table>
+        </div>
+
+        <DataTablePagination
+          total={total}
+          page={page}
+          pageSize={pageSize}
+          totalPages={totalPages}
+          onNavigate={navigate}
+        />
+      </div>
+
+      {/* Same form the list uses for quick edits — one component, two places. */}
+      <InventoryPartForm
+        key={part.id}
+        open={showForm}
+        onOpenChange={(open) => {
+          setShowForm(open);
+          if (!open) router.refresh();
+        }}
+        part={part}
+        markupMultiplier={markupMultiplier}
+        categories={categories}
+      />
+    </div>
+  );
+}

+ 65 - 11
src/app/(authenticated)/inventory/inventory-client.tsx

@@ -1,11 +1,15 @@
 "use client";
 
+import { useDebouncedSearch } from '@/hooks/use-debounced-search';
+import { isLow as isLowStock } from '@/features/inventory/Lib/lowStockAlerts';
+
 import { useState, useCallback, useTransition, useEffect } from "react";
 import { BarcodeScannerDialog } from '@/components/barcode-scanner-dialog';
 import { BarcodeScanActionDialog } from '@/features/inventory/Components/BarcodeScanActionDialog';
 import { useHardwareScanner } from '@/hooks/use-hardware-scanner';
 import { lookupPartByBarcode } from '@/features/inventory/Actions/lookupPartByBarcode';
 import { useTranslations } from "next-intl";
+import Link from "next/link";
 import { useRouter, usePathname, useSearchParams } from "next/navigation";
 import { Button } from "@/components/ui/button";
 import { Input } from "@/components/ui/input";
@@ -51,7 +55,9 @@ import {
   ArrowDown,
   ArrowUp,
   ArrowUpDown,
+  AlertTriangle,
   ExternalLink,
+  History,
   Loader2,
   MoreVertical,
   Pencil,
@@ -104,6 +110,9 @@ export function InventoryClient({
   markupMultiplier: initialMarkup = 1.0,
   sortBy: initialSortBy = "updatedAt",
   sortOrder: initialSortOrder = "desc",
+  lowStockDefault = 0,
+  lowStockOnly = false,
+  hasAnyReorderPoint = false,
 }: {
   data: PaginatedData;
   search: string;
@@ -113,6 +122,12 @@ export function InventoryClient({
   markupMultiplier?: number;
   sortBy?: string;
   sortOrder?: "asc" | "desc";
+  /** Org-wide fallback reorder point for parts with no minQuantity. */
+  lowStockDefault?: number;
+  /** Whether the list is currently filtered to low-stock parts. */
+  lowStockOnly?: boolean;
+  /** False when no part and no org default defines a reorder point. */
+  hasAnyReorderPoint?: boolean;
 }) {
   const formatCurrency = useFormatCurrency();
   const router = useRouter();
@@ -120,7 +135,6 @@ export function InventoryClient({
   const searchParams = useSearchParams();
   const t = useTranslations('inventory');
   const [isPending, startTransition] = useTransition();
-  const [searchInput, setSearchInput] = useState(search);
   const [showForm, setShowForm] = useState(false);
   const [editPart, setEditPart] = useState<InventoryPart | null>(null);
   const [showMarkup, setShowMarkup] = useState(false);
@@ -178,7 +192,7 @@ export function InventoryClient({
           newParams.set(key, String(value));
         }
       }
-      if (!("page" in params) && ("search" in params || "category" in params)) {
+      if (!("page" in params) && ("search" in params || "category" in params || "lowStock" in params)) {
         newParams.delete("page");
       }
       startTransition(() => {
@@ -203,13 +217,13 @@ export function InventoryClient({
       : <ArrowDown className="ml-1 h-3 w-3" />;
   };
 
-  const handleSearch = useCallback(
-    (e: React.FormEvent) => {
-      e.preventDefault();
-      navigate({ search: searchInput || undefined });
-    },
-    [navigate, searchInput]
-  );
+  // Live search: filters as you type, no Enter required. Submitting the
+  // form (Enter) commits immediately, bypassing the debounce.
+  const {
+    value: searchInput,
+    setValue: setSearchInput,
+    commitNow: handleSearch,
+  } = useDebouncedSearch(search, (term) => navigate({ search: term }));
 
   const handleDelete = async (id: string, name: string) => {
     const ok = await confirm({
@@ -331,6 +345,18 @@ export function InventoryClient({
                 ))}
               </SelectContent>
             </Select>
+            {/* Toggle rather than a third dropdown: low stock is a yes/no lens
+                on the list, and it needs to be reachable from the page itself
+                (the dashboard card links straight here with ?lowStock=1). */}
+            <Button
+              size="sm"
+              variant={lowStockOnly ? "default" : "outline"}
+              onClick={() => navigate({ lowStock: lowStockOnly ? undefined : "1" })}
+              aria-pressed={lowStockOnly}
+            >
+              <AlertTriangle className="mr-1 h-3.5 w-3.5" />
+              {t('table.low')}
+            </Button>
             {isPending && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
           </div>
         )}
@@ -427,12 +453,34 @@ export function InventoryClient({
             {data.parts.length === 0 ? (
               <TableRow>
                 <TableCell colSpan={11} className="h-32 text-center text-muted-foreground">
-                  {search || category ? t('empty.noMatch') : t('empty.noParts')}
+                  {lowStockOnly ? (
+                    // An empty Low view is ambiguous on its own: it means either
+                    // "nothing is running out" (good) or "nothing is being
+                    // watched" (needs setup). Say which, and link to the fix.
+                    hasAnyReorderPoint ? (
+                      t('empty.noLowStock')
+                    ) : (
+                      <div className="space-y-2">
+                        <p>{t('empty.noReorderPoints')}</p>
+                        <Link
+                          href="/settings/alerts"
+                          className="inline-flex items-center gap-1 text-primary hover:underline"
+                        >
+                          {t('empty.configureReorderPoints')}
+                          <ExternalLink className="h-3 w-3" />
+                        </Link>
+                      </div>
+                    )
+                  ) : search || category ? (
+                    t('empty.noMatch')
+                  ) : (
+                    t('empty.noParts')
+                  )}
                 </TableCell>
               </TableRow>
             ) : (
               data.parts.map((part) => {
-                const isLow = part.minQuantity > 0 && part.quantity <= part.minQuantity;
+                const isLow = isLowStock(part, lowStockDefault);
                 return (
                   <TableRow
                     key={part.id}
@@ -553,6 +601,12 @@ export function InventoryClient({
                             <Pencil className="mr-2 h-4 w-4" />
                             {t('actions.edit')}
                           </DropdownMenuItem>
+                          <DropdownMenuItem asChild>
+                            <Link href={`/inventory/${part.id}`}>
+                              <History className="mr-2 h-4 w-4" />
+                              {t('actions.details')}
+                            </Link>
+                          </DropdownMenuItem>
                           <DropdownMenuItem
                             className="text-destructive"
                             onClick={() => handleDelete(part.id, part.name)}

+ 15 - 4
src/app/(authenticated)/inventory/page.tsx

@@ -1,4 +1,4 @@
-import { getInventoryPartsPaginated, getInventoryCategories } from "@/features/inventory/Actions/inventoryActions";
+import { getInventoryPartsPaginated, getInventoryCategories, hasAnyReorderPoint } from "@/features/inventory/Actions/inventoryActions";
 import { getSettings } from "@/features/settings/Actions/settingsActions";
 import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
 import { InventoryClient } from "./inventory-client";
@@ -7,10 +7,10 @@ import { PageHeader } from "@/components/page-header";
 export default async function InventoryPage({
   searchParams,
 }: {
-  searchParams: Promise<{ page?: string; pageSize?: string; search?: string; category?: string; sortBy?: string; sortOrder?: string }>;
+  searchParams: Promise<{ page?: string; pageSize?: string; search?: string; category?: string; sortBy?: string; sortOrder?: string; lowStock?: string }>;
 }) {
   const params = await searchParams;
-  const [result, categoriesResult, settingsResult] = await Promise.all([
+  const [result, categoriesResult, settingsResult, reorderResult] = await Promise.all([
     getInventoryPartsPaginated({
       page: params.page ? parseInt(params.page) : 1,
       pageSize: params.pageSize ? parseInt(params.pageSize) : 20,
@@ -18,9 +18,15 @@ export default async function InventoryPage({
       category: params.category,
       sortBy: params.sortBy,
       sortOrder: (params.sortOrder as "asc" | "desc") || undefined,
+      lowStock: params.lowStock === "1",
     }),
     getInventoryCategories(),
-    getSettings([SETTING_KEYS.CURRENCY_CODE, SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER]),
+    getSettings([
+      SETTING_KEYS.CURRENCY_CODE,
+      SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER,
+      SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD,
+    ]),
+    hasAnyReorderPoint(),
   ]);
 
   if (!result.success || !result.data) {
@@ -54,6 +60,11 @@ export default async function InventoryPage({
           markupMultiplier={markupMultiplier}
           sortBy={params.sortBy || "updatedAt"}
           sortOrder={(params.sortOrder as "asc" | "desc") || "desc"}
+          lowStockDefault={
+            Number(settings[SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD]) || 0
+          }
+          lowStockOnly={params.lowStock === "1"}
+          hasAnyReorderPoint={reorderResult.data ?? false}
         />
       </div>
     </>

+ 9 - 8
src/app/(authenticated)/labor-presets/labor-presets-client.tsx

@@ -1,5 +1,7 @@
 "use client";
 
+import { useDebouncedSearch } from '@/hooks/use-debounced-search';
+
 import { useState, useCallback, useTransition } from "react";
 import { useTranslations } from "next-intl";
 import { useRouter, usePathname, useSearchParams } from "next/navigation";
@@ -62,7 +64,6 @@ export function LaborPresetsClient({
   const searchParams = useSearchParams();
   const t = useTranslations("laborPresets");
   const [isPending, startTransition] = useTransition();
-  const [searchInput, setSearchInput] = useState(search);
   const [showForm, setShowForm] = useState(false);
   const [editPreset, setEditPreset] = useState<{
     id: string;
@@ -93,13 +94,13 @@ export function LaborPresetsClient({
     [router, pathname, searchParams]
   );
 
-  const handleSearch = useCallback(
-    (e: React.FormEvent) => {
-      e.preventDefault();
-      navigate({ search: searchInput || undefined });
-    },
-    [navigate, searchInput]
-  );
+  // Live search: filters as you type, no Enter required. Submitting the
+  // form (Enter) commits immediately, bypassing the debounce.
+  const {
+    value: searchInput,
+    setValue: setSearchInput,
+    commitNow: handleSearch,
+  } = useDebouncedSearch(search, (term) => navigate({ search: term }));
 
   const handleSort = useCallback(
     (column: string) => {

+ 6 - 1
src/app/(authenticated)/quotes/[id]/page.tsx

@@ -2,6 +2,7 @@ import { getQuote } from '@/features/quotes/Actions/quoteActions'
 import { getSettings } from '@/features/settings/Actions/settingsActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { getLaborPresetsList } from '@/features/labor-presets/Actions/laborPresetActions'
+import { getInventoryPartsList } from '@/features/inventory/Actions/inventoryActions'
 import { getAuthContext } from '@/lib/get-auth-context'
 import { getFeatures } from '@/lib/features'
 import { PageHeader } from '@/components/page-header'
@@ -9,7 +10,7 @@ import { QuotePageClient } from '@/features/quotes/Components/QuotePageClient'
 
 export default async function QuoteDetailPage({ params }: { params: Promise<{ id: string }> }) {
   const { id } = await params
-  const [result, settingsResult, presetsResult, authContext] = await Promise.all([
+  const [result, settingsResult, presetsResult, inventoryResult, authContext] = await Promise.all([
     getQuote(id),
     getSettings([
       SETTING_KEYS.CURRENCY_CODE,
@@ -18,8 +19,11 @@ export default async function QuoteDetailPage({ params }: { params: Promise<{ id
       SETTING_KEYS.DEFAULT_LABOR_RATE,
     ]),
     getLaborPresetsList(),
+    getInventoryPartsList(),
     getAuthContext(),
   ])
+  const inventoryParts =
+    inventoryResult.success && inventoryResult.data ? inventoryResult.data : []
 
   const orgId = authContext?.organizationId
   const features = orgId ? await getFeatures(orgId) : null
@@ -66,6 +70,7 @@ export default async function QuoteDetailPage({ params }: { params: Promise<{ id
         taxEnabled={taxEnabled}
         defaultLaborRate={defaultLaborRate}
         laborPresets={laborPresets}
+        inventoryParts={inventoryParts}
         smsEnabled={features?.sms ?? false}
         emailEnabled={features?.smtp ?? false}
       />

+ 9 - 8
src/app/(authenticated)/quotes/quotes-client.tsx

@@ -1,5 +1,7 @@
 'use client'
 
+import { useDebouncedSearch } from '@/hooks/use-debounced-search';
+
 import { useState, useCallback, useTransition, useEffect } from 'react'
 import { useRouter, usePathname, useSearchParams } from 'next/navigation'
 import { useTranslations } from 'next-intl'
@@ -87,7 +89,6 @@ export function QuotesClient({
   const pathname = usePathname()
   const searchParams = useSearchParams()
   const [isPending, startTransition] = useTransition()
-  const [searchInput, setSearchInput] = useState(search)
   const t = useTranslations('quotes')
 
   // New quote dialog state
@@ -125,13 +126,13 @@ export function QuotesClient({
     [router, pathname, searchParams]
   )
 
-  const handleSearch = useCallback(
-    (e: React.FormEvent) => {
-      e.preventDefault()
-      navigate({ search: searchInput || undefined })
-    },
-    [navigate, searchInput]
-  )
+  // Live search: filters as you type, no Enter required. Submitting the
+  // form (Enter) commits immediately, bypassing the debounce.
+  const {
+    value: searchInput,
+    setValue: setSearchInput,
+    commitNow: handleSearch,
+  } = useDebouncedSearch(search, (term) => navigate({ search: term }));
 
   const openNewDialog = () => {
     setNewTitle('')

+ 241 - 0
src/app/(authenticated)/settings/alerts/alerts-settings.tsx

@@ -0,0 +1,241 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Button } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
+import { Switch } from "@/components/ui/switch";
+import { toast } from "sonner";
+import { setSettings } from "@/features/settings/Actions/settingsActions";
+import { runLowStockCheck } from "@/features/inventory/Actions/runLowStockCheck";
+import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
+import { Loader2, PackageSearch, PlayCircle, Save } from "lucide-react";
+import { ReadOnlyBanner, SaveButton, ReadOnlyWrapper } from "../read-only-guard";
+
+/**
+ * Alert preferences.
+ *
+ * Each alert type is a self-contained card owning its own state and save, so
+ * adding another (overdue invoices, expiring warranties, upcoming reminders)
+ * means dropping in a sibling card rather than reworking this page.
+ */
+export function AlertsSettings({ settings }: { settings: Record<string, string> }) {
+  const t = useTranslations("settings");
+
+  return (
+    <div className="space-y-6">
+      <ReadOnlyBanner />
+      <div>
+        <h2 className="text-lg font-semibold">{t("alerts.title")}</h2>
+        <p className="text-sm text-muted-foreground">{t("alerts.description")}</p>
+      </div>
+      <ReadOnlyWrapper>
+        <div className="space-y-6">
+          <LowStockAlertCard settings={settings} />
+        </div>
+      </ReadOnlyWrapper>
+    </div>
+  );
+}
+
+function LowStockAlertCard({ settings }: { settings: Record<string, string> }) {
+  const router = useRouter();
+  const t = useTranslations("settings");
+  const [saving, setSaving] = useState(false);
+  const [checking, setChecking] = useState(false);
+
+  const [enabled, setEnabled] = useState(
+    settings[SETTING_KEYS.LOW_STOCK_ALERTS_ENABLED] === "true",
+  );
+  const [inApp, setInApp] = useState(
+    settings[SETTING_KEYS.LOW_STOCK_ALERTS_IN_APP] !== "false",
+  );
+  const [email, setEmail] = useState(
+    settings[SETTING_KEYS.LOW_STOCK_ALERTS_EMAIL] === "true",
+  );
+  const [interval, setInterval] = useState(
+    settings[SETTING_KEYS.LOW_STOCK_ALERTS_EMAIL_MIN_INTERVAL_HOURS] || "24",
+  );
+  const [defaultThreshold, setDefaultThreshold] = useState(
+    settings[SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD] || "0",
+  );
+
+  const handleSave = async () => {
+    setSaving(true);
+    await setSettings({
+      [SETTING_KEYS.LOW_STOCK_ALERTS_ENABLED]: enabled ? "true" : "false",
+      [SETTING_KEYS.LOW_STOCK_ALERTS_IN_APP]: inApp ? "true" : "false",
+      [SETTING_KEYS.LOW_STOCK_ALERTS_EMAIL]: email ? "true" : "false",
+      [SETTING_KEYS.LOW_STOCK_ALERTS_EMAIL_MIN_INTERVAL_HOURS]: String(
+        Math.max(0, Number(interval) || 24),
+      ),
+      [SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD]: String(
+        Math.max(0, Number(defaultThreshold) || 0),
+      ),
+    });
+    setSaving(false);
+    router.refresh();
+    toast.success(t("alerts.lowStock.saved"));
+  };
+
+  // Runs the same evaluation the schedule does, so the operator can confirm
+  // the setup works instead of waiting for the next sweep.
+  const handleCheckNow = async () => {
+    setChecking(true);
+    const result = await runLowStockCheck();
+    setChecking(false);
+    if (!result.success || !result.data) {
+      toast.error(result.error ?? t("alerts.lowStock.checkFailed"));
+      return;
+    }
+    if (!result.data.enabled) {
+      toast.error(t("alerts.lowStock.checkDisabled"));
+      return;
+    }
+    if (result.data.alerted === 0) {
+      toast.success(t("alerts.lowStock.checkNothingNew"));
+    } else {
+      toast.success(
+        t("alerts.lowStock.checkAlerted", { count: result.data.alerted }),
+      );
+      router.refresh();
+    }
+  };
+
+  return (
+    <Card className="border-0 shadow-sm">
+      <CardHeader className="flex flex-row items-center justify-between pb-4">
+        <div className="flex items-center gap-3">
+          <PackageSearch className="h-5 w-5 text-muted-foreground" />
+          <CardTitle className="text-lg">{t("alerts.lowStock.title")}</CardTitle>
+        </div>
+        <a
+          href="https://torqvoice.com/docs/features/low-stock-alerts"
+          target="_blank"
+          rel="noopener noreferrer"
+          className="text-[11px] text-muted-foreground hover:text-foreground transition-colors"
+        >
+          {t("alerts.lowStock.readMore")} →
+        </a>
+      </CardHeader>
+      <CardContent className="space-y-6">
+        <p className="text-sm text-muted-foreground">
+          {t("alerts.lowStock.description")}
+        </p>
+
+        <div className="flex items-center justify-between">
+          <div className="space-y-0.5 pr-4">
+            <Label htmlFor="lowStockEnabled">{t("alerts.lowStock.enable")}</Label>
+            <p className="text-xs text-muted-foreground">
+              {t("alerts.lowStock.enableHint")}
+            </p>
+          </div>
+          <Switch id="lowStockEnabled" checked={enabled} onCheckedChange={setEnabled} />
+        </div>
+
+        {enabled && (
+          <>
+            <Separator />
+
+            <div className="space-y-2">
+              <Label htmlFor="lowStockThreshold">
+                {t("alerts.lowStock.defaultThreshold")}
+              </Label>
+              <Input
+                id="lowStockThreshold"
+                type="number"
+                min="0"
+                value={defaultThreshold}
+                onChange={(e) => setDefaultThreshold(e.target.value)}
+                className="max-w-[160px]"
+              />
+              <p className="text-xs text-muted-foreground">
+                {t("alerts.lowStock.defaultThresholdHint")}
+              </p>
+              {/* At 0 nothing org-wide is watched, so point at where the
+                  per-part value actually lives instead of leaving the operator
+                  to work it out. */}
+              {Number(defaultThreshold) === 0 && (
+                <p className="text-xs text-muted-foreground">
+                  <Link href="/inventory" className="text-primary hover:underline">
+                    {t("alerts.lowStock.configurePerPart")}
+                  </Link>
+                </p>
+              )}
+            </div>
+
+            <Separator />
+
+            <div className="flex items-center justify-between">
+              <div className="space-y-0.5 pr-4">
+                <Label htmlFor="lowStockInApp">{t("alerts.lowStock.inApp")}</Label>
+                <p className="text-xs text-muted-foreground">
+                  {t("alerts.lowStock.inAppHint")}
+                </p>
+              </div>
+              <Switch id="lowStockInApp" checked={inApp} onCheckedChange={setInApp} />
+            </div>
+
+            <div className="flex items-center justify-between">
+              <div className="space-y-0.5 pr-4">
+                <Label htmlFor="lowStockEmail">{t("alerts.lowStock.email")}</Label>
+                <p className="text-xs text-muted-foreground">
+                  {t("alerts.lowStock.emailHint")}
+                </p>
+              </div>
+              <Switch id="lowStockEmail" checked={email} onCheckedChange={setEmail} />
+            </div>
+
+            {email && (
+              <div className="space-y-2">
+                <Label htmlFor="lowStockInterval">
+                  {t("alerts.lowStock.emailInterval")}
+                </Label>
+                <Input
+                  id="lowStockInterval"
+                  type="number"
+                  min="0"
+                  max="168"
+                  value={interval}
+                  onChange={(e) => setInterval(e.target.value)}
+                  className="max-w-[160px]"
+                />
+                <p className="text-xs text-muted-foreground">
+                  {t("alerts.lowStock.emailIntervalHint")}
+                </p>
+              </div>
+            )}
+          </>
+        )}
+
+        <SaveButton>
+          <div className="flex flex-wrap gap-2">
+          <Button onClick={handleSave} disabled={saving}>
+            {saving ? (
+              <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+            ) : (
+              <Save className="mr-2 h-4 w-4" />
+            )}
+            {t("alerts.lowStock.save")}
+          </Button>
+          {enabled && (
+            <Button variant="outline" onClick={handleCheckNow} disabled={checking}>
+              {checking ? (
+                <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+              ) : (
+                <PlayCircle className="mr-2 h-4 w-4" />
+              )}
+              {t("alerts.lowStock.checkNow")}
+            </Button>
+          )}
+          </div>
+        </SaveButton>
+      </CardContent>
+    </Card>
+  );
+}

+ 16 - 0
src/app/(authenticated)/settings/alerts/page.tsx

@@ -0,0 +1,16 @@
+import { getSettings } from "@/features/settings/Actions/settingsActions";
+import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
+import { AlertsSettings } from "./alerts-settings";
+
+export default async function AlertsSettingsPage() {
+  const result = await getSettings([
+    SETTING_KEYS.LOW_STOCK_ALERTS_ENABLED,
+    SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD,
+    SETTING_KEYS.LOW_STOCK_ALERTS_IN_APP,
+    SETTING_KEYS.LOW_STOCK_ALERTS_EMAIL,
+    SETTING_KEYS.LOW_STOCK_ALERTS_EMAIL_MIN_INTERVAL_HOURS,
+  ]);
+  const settings = result.success && result.data ? result.data : {};
+
+  return <AlertsSettings settings={settings} />;
+}

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

@@ -33,6 +33,7 @@ import {
   UsersRound,
   Webhook,
   Wrench,
+  BellRing,
 } from 'lucide-react'
 
 type SettingsNavItem = {
@@ -88,6 +89,7 @@ const settingsCategories: SettingsCategory[] = [
     items: [
       { key: 'workshop', href: '/settings/workshop', icon: Wrench },
       { key: 'maintenance', href: '/settings/maintenance', icon: Gauge },
+      { key: 'alerts', href: '/settings/alerts', icon: BellRing },
       { key: 'ai', href: '/settings/ai', icon: Sparkles, gate: 'ai' },
       { key: 'reportSchedule', href: '/settings/report-schedule', icon: CalendarClock, gate: 'reports' },
     ],

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

@@ -1,5 +1,7 @@
 "use client";
 
+import { useDebouncedSearch } from '@/hooks/use-debounced-search';
+
 import { useRouter, usePathname, useSearchParams } from "next/navigation";
 import { useCallback, useState, useTransition } from "react";
 import Link from "next/link";
@@ -108,7 +110,6 @@ export function ServiceRecordsTable({
   const pathname = usePathname();
   const searchParams = useSearchParams();
   const [isPending, startTransition] = useTransition();
-  const [searchInput, setSearchInput] = useState(search);
   const [navigatingId, setNavigatingId] = useState<string | null>(null);
   const [isExporting, setIsExporting] = useState(false);
   const t = useTranslations("vehicles.services");
@@ -144,13 +145,13 @@ export function ServiceRecordsTable({
     [router, createUrl]
   );
 
-  const handleSearch = useCallback(
-    (e: React.FormEvent) => {
-      e.preventDefault();
-      navigate({ search: searchInput || undefined });
-    },
-    [navigate, searchInput]
-  );
+  // Live search: filters as you type, no Enter required. Submitting the
+  // form (Enter) commits immediately, bypassing the debounce.
+  const {
+    value: searchInput,
+    setValue: setSearchInput,
+    commitNow: handleSearch,
+  } = useDebouncedSearch(search, (term) => navigate({ search: term }));
 
   const handlePageSizeChange = useCallback(
     (newSize: string) => {

+ 9 - 8
src/app/(authenticated)/work-orders/work-orders-client.tsx

@@ -1,5 +1,7 @@
 "use client";
 
+import { useDebouncedSearch } from '@/hooks/use-debounced-search';
+
 import { useState, useCallback, useTransition } from "react";
 import { useRouter, usePathname, useSearchParams } from "next/navigation";
 import { toast } from "sonner";
@@ -150,7 +152,6 @@ export function WorkOrdersClient({
   const searchParams = useSearchParams();
   const [isPending, startTransition] = useTransition();
   const t = useTranslations("workOrders.list");
-  const [searchInput, setSearchInput] = useState(search);
   const [navigatingId, setNavigatingId] = useState<string | null>(null);
   const [showPicker, setShowPicker] = useState(false);
   const [showNotifyDialog, setShowNotifyDialog] = useState(false);
@@ -193,13 +194,13 @@ export function WorkOrdersClient({
       : <ArrowDown className="ml-1 h-3 w-3" />;
   };
 
-  const handleSearch = useCallback(
-    (e: React.FormEvent) => {
-      e.preventDefault();
-      navigate({ search: searchInput || undefined });
-    },
-    [navigate, searchInput]
-  );
+  // Live search: filters as you type, no Enter required. Submitting the
+  // form (Enter) commits immediately, bypassing the debounce.
+  const {
+    value: searchInput,
+    setValue: setSearchInput,
+    commitNow: handleSearch,
+  } = useDebouncedSearch(search, (term) => navigate({ search: term }));
 
   const handleStatusChange = async (workOrder: WorkOrder, newStatus: string) => {
     await updateServiceStatus(workOrder.id, newStatus);

+ 1 - 0
src/cronTasks.ts

@@ -5,3 +5,4 @@ export { cleanupPortalSessions } from './lib/cron/cleanup-portal-sessions'
 export { cleanupAuditLogs } from './lib/cron/cleanup-audit-logs'
 export { processReportSchedules } from './lib/cron/report-schedules'
 export { processWebhookDeliveries, cleanupWebhookDeliveries } from './lib/cron/webhook-deliveries'
+export { checkLowStock } from './lib/cron/low-stock-alerts'

+ 10 - 9
src/features/admin/Components/admin-organizations.tsx

@@ -1,6 +1,8 @@
 "use client";
 
-import { useState, useCallback, useTransition } from "react";
+import { useDebouncedSearch } from '@/hooks/use-debounced-search';
+
+import { useCallback, useTransition } from "react";
 import { useTranslations } from "next-intl";
 import { useRouter, usePathname, useSearchParams } from "next/navigation";
 import { toast } from "sonner";
@@ -58,7 +60,6 @@ export function AdminOrganizations({
   const confirm = useConfirm();
   const t = useTranslations("admin");
   const [isPending, startTransition] = useTransition();
-  const [searchInput, setSearchInput] = useState(search);
 
   const navigate = useCallback(
     (params: Record<string, string | number | undefined>) => {
@@ -80,13 +81,13 @@ export function AdminOrganizations({
     [router, pathname, searchParams],
   );
 
-  const handleSearch = useCallback(
-    (e: React.FormEvent) => {
-      e.preventDefault();
-      navigate({ search: searchInput || undefined });
-    },
-    [navigate, searchInput],
-  );
+  // Live search: filters as you type, no Enter required. Submitting the
+  // form (Enter) commits immediately, bypassing the debounce.
+  const {
+    value: searchInput,
+    setValue: setSearchInput,
+    commitNow: handleSearch,
+  } = useDebouncedSearch(search, (term) => navigate({ search: term }));
 
   const handleDelete = async (org: OrgRow) => {
     const confirmed = await confirm({

+ 10 - 9
src/features/admin/Components/admin-users.tsx

@@ -1,6 +1,8 @@
 'use client'
 
-import { useState, useCallback, useTransition } from 'react'
+import { useDebouncedSearch } from '@/hooks/use-debounced-search';
+
+import { useCallback, useTransition } from 'react'
 import { useRouter, usePathname, useSearchParams } from 'next/navigation'
 import { useTranslations } from 'next-intl'
 import { toast } from 'sonner'
@@ -88,7 +90,6 @@ export function AdminUsers({
   const confirm = useConfirm()
   const { formatDate, formatDateTime } = useFormatDate()
   const [isPending, startTransition] = useTransition()
-  const [searchInput, setSearchInput] = useState(search)
 
   const navigate = useCallback(
     (params: Record<string, string | number | undefined>) => {
@@ -110,13 +111,13 @@ export function AdminUsers({
     [router, pathname, searchParams]
   )
 
-  const handleSearch = useCallback(
-    (e: React.FormEvent) => {
-      e.preventDefault()
-      navigate({ search: searchInput || undefined })
-    },
-    [navigate, searchInput]
-  )
+  // Live search: filters as you type, no Enter required. Submitting the
+  // form (Enter) commits immediately, bypassing the debounce.
+  const {
+    value: searchInput,
+    setValue: setSearchInput,
+    commitNow: handleSearch,
+  } = useDebouncedSearch(search, (term) => navigate({ search: term }));
 
   const handleSort = useCallback(
     (column: string) => {

+ 166 - 0
src/features/inventory/Actions/getStockMovements.ts

@@ -0,0 +1,166 @@
+"use server";
+
+import { db } from "@/lib/db";
+import { withAuth } from "@/lib/with-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { STOCK_MOVEMENT_REASONS } from "../Lib/stockMovementReasons";
+
+const DEFAULT_PAGE_SIZE = 50;
+const MAX_PAGE_SIZE = 200;
+
+/**
+ * Paginated movement history for a single part — the answer to "where was this
+ * used, and when did the count change?".
+ *
+ * Ordering is newest-first, which matches the
+ * `(inventoryPartId, createdAt DESC)` index exactly: the query is an index
+ * scan with no sort step, so paging stays fast however long the ledger grows.
+ *
+ * Rows are scoped by `organizationId` as well as part id, so a movement
+ * belonging to another tenant can never be returned even with a guessed id.
+ */
+export async function getStockMovementsPaginated(params: {
+  inventoryPartId: string;
+  page?: number;
+  pageSize?: number;
+  reason?: string;
+}) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const page = Math.max(1, params.page ?? 1);
+      const pageSize = Math.min(
+        Math.max(params.pageSize ?? DEFAULT_PAGE_SIZE, 1),
+        MAX_PAGE_SIZE,
+      );
+
+      const where = {
+        inventoryPartId: params.inventoryPartId,
+        organizationId,
+        // Ignore an unrecognised reason rather than returning an empty page.
+        ...(params.reason &&
+        (STOCK_MOVEMENT_REASONS as readonly string[]).includes(params.reason)
+          ? { reason: params.reason }
+          : {}),
+      };
+
+      const [rows, total] = await Promise.all([
+        db.stockMovement.findMany({
+          where,
+          orderBy: { createdAt: "desc" },
+          skip: (page - 1) * pageSize,
+          take: pageSize,
+          select: {
+            id: true,
+            delta: true,
+            quantityAfter: true,
+            reason: true,
+            note: true,
+            createdAt: true,
+            serviceRecordLabel: true,
+            user: { select: { name: true } },
+            serviceRecord: {
+              select: {
+                id: true,
+                title: true,
+                invoiceNumber: true,
+                vehicleId: true,
+                vehicle: {
+                  select: { make: true, model: true, licensePlate: true },
+                },
+              },
+            },
+          },
+        }),
+        db.stockMovement.count({ where }),
+      ]);
+
+      return {
+        movements: rows.map((m) => ({
+          id: m.id,
+          delta: m.delta,
+          quantityAfter: m.quantityAfter,
+          reason: m.reason,
+          note: m.note,
+          createdAt: m.createdAt.toISOString(),
+          userName: m.user?.name ?? null,
+          // Present only while the job still exists; the label below outlives it.
+          serviceRecordId: m.serviceRecord?.id ?? null,
+          vehicleId: m.serviceRecord?.vehicleId ?? null,
+          label:
+            m.serviceRecord?.invoiceNumber ||
+            m.serviceRecord?.title ||
+            m.serviceRecordLabel ||
+            null,
+          vehicle: m.serviceRecord?.vehicle
+            ? [
+                m.serviceRecord.vehicle.make,
+                m.serviceRecord.vehicle.model,
+                m.serviceRecord.vehicle.licensePlate
+                  ? `(${m.serviceRecord.vehicle.licensePlate})`
+                  : null,
+              ]
+                .filter(Boolean)
+                .join(" ")
+            : null,
+        })),
+        total,
+        page,
+        pageSize,
+        totalPages: Math.max(1, Math.ceil(total / pageSize)),
+      };
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.INVENTORY },
+      ],
+    },
+  );
+}
+
+/**
+ * The part itself, for the detail page. Selects the full shape
+ * `InventoryPartForm` expects so the same form powers the dialog and the page.
+ * Org-scoped.
+ */
+export async function getInventoryPart(id: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      return db.inventoryPart.findFirst({
+        where: { id, organizationId },
+        select: {
+          id: true,
+          name: true,
+          partNumber: true,
+          barcode: true,
+          description: true,
+          category: true,
+          quantity: true,
+          minQuantity: true,
+          unitCost: true,
+          sellPrice: true,
+          supplier: true,
+          supplierPhone: true,
+          supplierEmail: true,
+          supplierUrl: true,
+          imageUrl: true,
+          location: true,
+          gallery: {
+            select: {
+              id: true,
+              url: true,
+              fileName: true,
+              description: true,
+              sortOrder: true,
+            },
+            orderBy: { sortOrder: "asc" },
+          },
+        },
+      });
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.INVENTORY },
+      ],
+    },
+  );
+}

+ 73 - 13
src/features/inventory/Actions/inventoryActions.ts

@@ -3,12 +3,14 @@
 import { db } from "@/lib/db";
 import { withAuth } from "@/lib/with-auth";
 import { createInventoryPartSchema, updateInventoryPartSchema, adjustStockSchema } from "../Schema/inventorySchema";
-import { revalidatePath } from "next/cache";
+import { onInventoryChanged } from "../Lib/onInventoryChanged";
 import { Prisma } from "@/generated/prisma/client";
 import { z } from "zod";
 import { unlink } from "fs/promises";
 import { resolveUploadPath } from "@/lib/resolve-upload-path";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { normalizeBarcode, withBarcodeConflictMessage } from "../Lib/barcode";
+import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
 
 export async function getInventoryPartsPaginated(params: {
   page?: number;
@@ -17,6 +19,8 @@ export async function getInventoryPartsPaginated(params: {
   category?: string;
   sortBy?: string;
   sortOrder?: "asc" | "desc";
+  /** Restrict to parts at or below their reorder point. */
+  lowStock?: boolean;
 }) {
   return withAuth(async ({ userId, organizationId }) => {
     const page = params.page || 1;
@@ -43,6 +47,32 @@ export async function getInventoryPartsPaginated(params: {
       where.category = params.category;
     }
 
+    if (params.lowStock) {
+      // Prisma cannot compare two columns of the same row in a `where`, so the
+      // matching ids are resolved in SQL first. The low-stock set is small by
+      // definition (parts at or below their reorder point), so the extra query
+      // stays cheap and pagination/sorting below is unaffected.
+      // Same rule as isLow() in Lib/lowStockAlerts and the dashboard count:
+      // the part's own reorder point wins, else the org-wide default.
+      const thresholdRow = await db.appSetting.findFirst({
+        where: { organizationId, key: SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD },
+        select: { value: true },
+      });
+      const parsedDefault = Number(thresholdRow?.value);
+      const lowStockDefault =
+        Number.isFinite(parsedDefault) && parsedDefault > 0 ? parsedDefault : 0;
+
+      const lowRows = await db.$queryRaw<{ id: string }[]>`
+        SELECT "id"
+        FROM "inventory_parts"
+        WHERE "organizationId" = ${organizationId}
+          AND "isArchived" = false
+          AND COALESCE(NULLIF("minQuantity", 0), ${lowStockDefault}) > 0
+          AND "quantity" <= COALESCE(NULLIF("minQuantity", 0), ${lowStockDefault})
+      `;
+      where.id = { in: lowRows.map((r) => r.id) };
+    }
+
     const dir = params.sortOrder || "desc";
     const sortableColumns = ["name", "partNumber", "category", "quantity", "unitCost", "sellPrice", "supplier", "location", "updatedAt"];
     const sortColumn = params.sortBy && sortableColumns.includes(params.sortBy) ? params.sortBy : "updatedAt";
@@ -83,11 +113,13 @@ export async function createInventoryPart(input: unknown) {
   return withAuth(async ({ userId, organizationId }) => {
     const data = createInventoryPartSchema.parse(input);
     const { gallery, ...rest } = data;
-    const part = await db.inventoryPart.create({
+    const barcode = normalizeBarcode(rest.barcode);
+    const part = await withBarcodeConflictMessage(organizationId, barcode, () =>
+      db.inventoryPart.create({
       data: {
         ...rest,
         partNumber: rest.partNumber || undefined,
-        barcode: rest.barcode || undefined,
+        barcode: barcode ?? undefined,
         description: rest.description || undefined,
         category: rest.category || undefined,
         supplier: rest.supplier || undefined,
@@ -99,7 +131,8 @@ export async function createInventoryPart(input: unknown) {
         userId,
         organizationId,
       },
-    });
+      }),
+    );
 
     if (gallery && gallery.length > 0) {
       await db.storedImage.createMany({
@@ -113,7 +146,7 @@ export async function createInventoryPart(input: unknown) {
       });
     }
 
-    revalidatePath("/inventory");
+    await onInventoryChanged(organizationId);
     return part;
   }, {
     requiredPermissions: [{ action: PermissionAction.CREATE, subject: PermissionSubject.INVENTORY }],
@@ -162,12 +195,15 @@ export async function updateInventoryPart(input: unknown) {
       });
     }
 
-    const result = await db.inventoryPart.updateMany({
+    const nextBarcode =
+      updateData.barcode !== undefined ? normalizeBarcode(updateData.barcode) : undefined;
+    const result = await withBarcodeConflictMessage(organizationId, nextBarcode ?? null, () =>
+      db.inventoryPart.updateMany({
       where: { id, organizationId },
       data: {
         ...updateData,
         partNumber: updateData.partNumber !== undefined ? (updateData.partNumber || null) : undefined,
-        barcode: updateData.barcode !== undefined ? (updateData.barcode || null) : undefined,
+        barcode: nextBarcode,
         description: updateData.description !== undefined ? (updateData.description || null) : undefined,
         category: updateData.category !== undefined ? (updateData.category || null) : undefined,
         supplier: updateData.supplier !== undefined ? (updateData.supplier || null) : undefined,
@@ -176,9 +212,10 @@ export async function updateInventoryPart(input: unknown) {
         supplierUrl: updateData.supplierUrl !== undefined ? (updateData.supplierUrl || null) : undefined,
         location: updateData.location !== undefined ? (updateData.location || null) : undefined,
       },
-    });
+      }),
+    );
     if (result.count === 0) throw new Error("Part not found");
-    revalidatePath("/inventory");
+    await onInventoryChanged(organizationId);
     return { updated: true, partId: id };
   }, {
     requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.INVENTORY }],
@@ -216,7 +253,7 @@ export async function deleteInventoryPart(partId: string) {
       }
     }
 
-    revalidatePath("/inventory");
+    await onInventoryChanged(organizationId);
     return { deleted: true, partId };
   }, {
     requiredPermissions: [{ action: PermissionAction.DELETE, subject: PermissionSubject.INVENTORY }],
@@ -254,7 +291,7 @@ export async function deleteInventoryParts(partIds: string[]) {
       try { await unlink(resolveUploadPath(url)); } catch { /* already gone */ }
     }
 
-    revalidatePath("/inventory");
+    await onInventoryChanged(organizationId);
     return { deleted: result.count };
   }, {
     requiredPermissions: [{ action: PermissionAction.DELETE, subject: PermissionSubject.INVENTORY }],
@@ -283,7 +320,7 @@ export async function adjustInventoryStock(input: unknown) {
       where: { id, organizationId },
       data: { quantity: newQuantity },
     });
-    revalidatePath("/inventory");
+    await onInventoryChanged(organizationId);
     return { quantity: newQuantity };
   }, { requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.INVENTORY }] });
 }
@@ -342,7 +379,7 @@ export async function applyMarkupToAll(input: unknown) {
         ${overrideExisting ? Prisma.sql`` : Prisma.sql`AND ("sellPrice" = 0 OR "sellPrice" IS NULL)`}
     `;
 
-    revalidatePath("/inventory");
+    await onInventoryChanged(organizationId);
     return { updated: result };
   }, { requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.INVENTORY }] });
 }
@@ -361,3 +398,26 @@ export async function deleteOrphanedUploads(fileUrls: string[]) {
     return { success: true };
   });
 }
+
+/**
+ * Whether low-stock tracking can produce results at all: either the org has a
+ * default reorder point, or at least one part defines its own.
+ *
+ * Used to tell "nothing is low right now" apart from "nothing is being
+ * watched", so the Low filter can explain an empty result instead of just
+ * showing a blank table.
+ */
+export async function hasAnyReorderPoint() {
+  return withAuth(async ({ organizationId }) => {
+    const setting = await db.appSetting.findFirst({
+      where: { organizationId, key: SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD },
+      select: { value: true },
+    });
+    if ((Number(setting?.value) || 0) > 0) return true;
+
+    const configured = await db.inventoryPart.count({
+      where: { organizationId, isArchived: false, minQuantity: { gt: 0 } },
+    });
+    return configured > 0;
+  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.INVENTORY }] });
+}

+ 9 - 1
src/features/inventory/Actions/lookupPartByBarcode.ts

@@ -3,13 +3,21 @@
 import { db } from "@/lib/db"
 import { withAuth } from "@/lib/with-auth"
 import { PermissionAction, PermissionSubject } from "@/lib/permissions"
+import { normalizeBarcode } from "../Lib/barcode"
 
 export async function lookupPartByBarcode(barcode: string) {
   return withAuth(async ({ organizationId }) => {
+    // Scanned input goes through the same normalisation as stored barcodes, so
+    // an exact match is enough. Combined with the (organizationId, barcode)
+    // unique index this resolves to at most one part — previously a
+    // case-insensitive `findFirst` could pick arbitrarily between duplicates.
+    const normalized = normalizeBarcode(barcode)
+    if (!normalized) return null
+
     const part = await db.inventoryPart.findFirst({
       where: {
         organizationId,
-        barcode: { equals: barcode, mode: "insensitive" },
+        barcode: normalized,
         isArchived: false,
       },
       select: {

+ 38 - 0
src/features/inventory/Actions/runLowStockCheck.ts

@@ -0,0 +1,38 @@
+"use server";
+
+import { withAuth } from "@/lib/with-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { processOrgLowStock } from "@/lib/cron/low-stock-alerts";
+
+/**
+ * Run the low-stock evaluation on demand.
+ *
+ * Backs the "Check now" button in Settings → Alerts. Without it the only way to
+ * confirm the feature works is to change stock and wait for the sweep, which
+ * makes a correctly-configured system look broken.
+ *
+ * Uses the same code path as the schedule, so what you see here is exactly what
+ * the automated run does — including the hysteresis, which is why a second
+ * click usually reports 0: everything currently low has already been reported.
+ */
+export async function runLowStockCheck() {
+  return withAuth(
+    async ({ organizationId }) => {
+      const result = await processOrgLowStock(organizationId);
+      if (result.skipped) {
+        return { enabled: false as const, alerted: 0, rearmed: 0, emailed: false };
+      }
+      return {
+        enabled: true as const,
+        alerted: result.alerted,
+        rearmed: result.rearmed,
+        emailed: result.emailed,
+      };
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
+      ],
+    },
+  );
+}

+ 18 - 4
src/features/inventory/Components/InventoryPartForm.tsx

@@ -2,6 +2,7 @@
 
 import { useState, useRef, useCallback, useEffect } from "react";
 import { useTranslations } from "next-intl";
+import Link from "next/link";
 import { useRouter } from "next/navigation";
 import { Button } from "@/components/ui/button";
 import { Input } from "@/components/ui/input";
@@ -18,7 +19,7 @@ import { toast } from "sonner";
 import { useGlassModal } from "@/components/glass-modal";
 import { createInventoryPart, updateInventoryPart, deleteOrphanedUploads } from "../Actions/inventoryActions";
 import { aiAnalyzePartImage } from "../Actions/aiAnalyzePartImage";
-import { Camera, Check, ChevronsUpDown, ExternalLink, ImageIcon, Loader2, Plus, Sparkles, Upload, X } from "lucide-react";
+import { Camera, Check, ChevronsUpDown, ExternalLink, History, ImageIcon, Loader2, Plus, Sparkles, Upload, X } from "lucide-react";
 import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
 import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
 import { cn } from "@/lib/utils";
@@ -331,9 +332,22 @@ export function InventoryPartForm({ open, onOpenChange, part, markupMultiplier,
     <Dialog open={open} onOpenChange={handleOpenChange}>
       <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl">
         <DialogHeader>
-          <DialogTitle>
-            {part ? t('form.editPart') : t('form.addNewPart')}
-          </DialogTitle>
+          <div className="flex items-center justify-between gap-4 pr-6">
+            <DialogTitle>
+              {part ? t('form.editPart') : t('form.addNewPart')}
+            </DialogTitle>
+            {/* Only meaningful for a saved part — a new one has no history yet.
+                Opens on top of this dialog rather than replacing it, so
+                in-progress edits and uploads are not discarded. */}
+            {part && (
+              <Button type="button" variant="outline" size="sm" asChild>
+                <Link href={`/inventory/${part.id}`}>
+                  <History className="mr-2 h-4 w-4" />
+                  {t('actions.details')}
+                </Link>
+              </Button>
+            )}
+          </div>
           <DialogDescription className="sr-only">
             {part ? t('form.editPart') : t('form.addNewPart')}
           </DialogDescription>

+ 107 - 0
src/features/inventory/Lib/barcode.ts

@@ -0,0 +1,107 @@
+import { db } from "@/lib/db";
+
+/**
+ * Canonical form for a stored barcode.
+ *
+ * Barcodes are unique per organization, so scanning must resolve to exactly one
+ * part. Normalising on every write — and applying the same transform to scanned
+ * input — makes the match deterministic: "  abc-1 " typed by hand and "ABC-1"
+ * from a scanner are the same barcode, and only one part can hold it.
+ *
+ * Returns null for blank input, which the unique index treats as "no barcode"
+ * (Postgres considers NULLs distinct, so any number of parts may have none).
+ */
+export function normalizeBarcode(
+  barcode: string | null | undefined,
+): string | null {
+  if (barcode == null) return null;
+  const trimmed = barcode.trim().toUpperCase();
+  return trimmed.length > 0 ? trimmed : null;
+}
+
+/** Prisma's unique-constraint violation code. */
+const UNIQUE_VIOLATION = "P2002";
+
+/**
+ * Collect every place Prisma might name the violated columns.
+ *
+ * The shape is not stable across Prisma versions or driver setups. Classic
+ * Prisma puts the columns in `meta.target`; Prisma 7 with the pg driver adapter
+ * leaves `target` undefined and reports them under
+ * `meta.driverAdapterError.cause.constraint.fields`, alongside the raw Postgres
+ * message naming the index.
+ *
+ * Reading all of them means a future shape change degrades to "we did not
+ * recognise it" rather than silently leaking a raw Prisma error at the user —
+ * which is exactly what happened when only `target` was checked.
+ */
+export function uniqueViolationFields(error: unknown): string {
+  if (typeof error !== "object" || error === null) return "";
+  const e = error as {
+    code?: string;
+    meta?: {
+      target?: unknown;
+      driverAdapterError?: {
+        cause?: {
+          originalMessage?: string;
+          constraint?: { fields?: unknown; index?: unknown };
+        };
+      };
+    };
+  };
+  if (e.code !== UNIQUE_VIOLATION) return "";
+
+  const parts: string[] = [];
+
+  const target = e.meta?.target;
+  if (Array.isArray(target)) parts.push(target.join(","));
+  else if (target != null) parts.push(String(target));
+
+  const cause = e.meta?.driverAdapterError?.cause;
+  const fields = cause?.constraint?.fields;
+  if (Array.isArray(fields)) parts.push(fields.join(","));
+  if (cause?.constraint?.index != null) parts.push(String(cause.constraint.index));
+  if (cause?.originalMessage) parts.push(cause.originalMessage);
+
+  return parts.join("|");
+}
+
+function isBarcodeUniqueViolation(error: unknown): boolean {
+  return uniqueViolationFields(error).includes("barcode");
+}
+
+/**
+ * Run an inventory write, converting a barcode collision into a message that
+ * names the part already using it, rather than leaking a raw Prisma error.
+ *
+ * `withAuth` turns a thrown Error into `{ success: false, error }`, which the
+ * client surfaces as a toast.
+ */
+export async function withBarcodeConflictMessage<T>(
+  organizationId: string,
+  barcode: string | null,
+  operation: () => Promise<T>,
+): Promise<T> {
+  try {
+    return await operation();
+  } catch (error) {
+    if (!isBarcodeUniqueViolation(error)) throw error;
+
+    const existing = barcode
+      ? await db.inventoryPart.findFirst({
+          where: { organizationId, barcode },
+          select: { name: true, partNumber: true },
+        })
+      : null;
+
+    const label = existing
+      ? `${existing.name}${existing.partNumber ? ` (${existing.partNumber})` : ""}`
+      : null;
+
+    throw new Error(
+      label
+        ? `Barcode ${barcode} is already used by ${label}.`
+        : `Barcode ${barcode} is already used by another part.`,
+    );
+  }
+}

+ 151 - 0
src/features/inventory/Lib/lowStockAlerts.ts

@@ -0,0 +1,151 @@
+/**
+ * Decision logic for low-stock alerting, kept free of I/O so the anti-spam
+ * guarantees can be tested directly.
+ *
+ * The whole design rests on alerting for a *transition* rather than a *state*.
+ * A naive implementation asks "which parts are low?" on every run and alerts on
+ * the answer — which means a part sitting at zero for a month produces an alert
+ * every single run. Instead each part carries `lowStockAlertedAt`:
+ *
+ *   - NULL          → armed; a dip below the reorder point will alert once.
+ *   - not NULL      → already alerted for this dip; stays silent.
+ *
+ * The marker is cleared only when stock climbs back *above* the reorder point,
+ * so recovering and dipping again is a genuine second event and alerts again.
+ */
+
+export interface LowStockCandidate {
+  id: string;
+  name: string;
+  partNumber: string | null;
+  quantity: number;
+  minQuantity: number;
+  lowStockAlertedAt: Date | null;
+}
+
+export interface LowStockDecision {
+  /** Parts that just crossed below their reorder point — alert on these. */
+  newlyLow: LowStockCandidate[];
+  /** Parts that recovered; clear their marker so a future dip alerts again. */
+  toRearm: string[];
+}
+
+/**
+ * The reorder point actually in force for a part.
+ *
+ * A part's own `minQuantity` always wins. When it is unset (0), the
+ * organization-wide default applies — without that fallback the feature only
+ * covers parts someone remembered to configure individually, which on a real
+ * inventory means almost none of them.
+ *
+ * A default of 0 means "no org-wide threshold", so only explicitly configured
+ * parts are watched. That is the out-of-the-box behaviour.
+ */
+export function effectiveThreshold(
+  part: { minQuantity: number },
+  defaultThreshold = 0,
+): number {
+  return part.minQuantity > 0 ? part.minQuantity : Math.max(0, defaultThreshold);
+}
+
+/**
+ * A part counts as low at or below its effective reorder point.
+ *
+ * This is the single definition of "low" — the alert engine, the dashboard
+ * count, the list badge and the inventory filter all resolve through it (or
+ * through SQL mirroring it), so the numbers can never disagree.
+ */
+export function isLow(
+  part: { quantity: number; minQuantity: number },
+  defaultThreshold = 0,
+): boolean {
+  const threshold = effectiveThreshold(part, defaultThreshold);
+  return threshold > 0 && part.quantity <= threshold;
+}
+
+/**
+ * Split parts into "alert now" and "re-arm", based on the transition rules.
+ *
+ * Deliberately ignores parts that are low and already marked: that silence is
+ * the entire point.
+ */
+export function decideLowStockAlerts(
+  parts: readonly LowStockCandidate[],
+  defaultThreshold = 0,
+): LowStockDecision {
+  const newlyLow: LowStockCandidate[] = [];
+  const toRearm: string[] = [];
+
+  for (const part of parts) {
+    if (isLow(part, defaultThreshold)) {
+      // Already alerted for this dip — stay quiet.
+      if (part.lowStockAlertedAt === null) newlyLow.push(part);
+    } else if (part.lowStockAlertedAt !== null) {
+      // Back above the reorder point: arm it for the next dip.
+      toRearm.push(part.id);
+    }
+  }
+
+  return { newlyLow, toRearm };
+}
+
+/**
+ * Whether a digest email may be sent now.
+ *
+ * A second, independent throttle to the per-part hysteresis: even if many parts
+ * go low in quick succession across separate runs, the mailbox sees at most one
+ * message per `minIntervalHours`. In-app notifications are not throttled this
+ * way — they are cheap and non-intrusive, and are already deduplicated by the
+ * per-part marker.
+ */
+export function canSendDigestEmail(
+  lastSentAt: Date | null,
+  now: Date,
+  minIntervalHours: number,
+): boolean {
+  if (!lastSentAt) return true;
+  if (minIntervalHours <= 0) return true;
+  const elapsedMs = now.getTime() - lastSentAt.getTime();
+  return elapsedMs >= minIntervalHours * 60 * 60 * 1000;
+}
+
+/** One-line summary per part for the digest body. */
+export function formatLowStockLine(
+  part: LowStockCandidate,
+  defaultThreshold = 0,
+): string {
+  const ref = part.partNumber ? ` (${part.partNumber})` : "";
+  const threshold = effectiveThreshold(part, defaultThreshold);
+  return `${part.name}${ref}: ${part.quantity} left, reorder at ${threshold}`;
+}
+
+/**
+ * Title/message for the single grouped notification. Grouping matters: five
+ * parts going low should be one bell item, not five.
+ */
+export function buildAlertSummary(
+  parts: readonly LowStockCandidate[],
+  defaultThreshold = 0,
+): {
+  title: string;
+  message: string;
+} {
+  if (parts.length === 1) {
+    const p = parts[0];
+    return {
+      title: "Low stock",
+      message: formatLowStockLine(p, defaultThreshold),
+    };
+  }
+
+  const preview = parts
+    .slice(0, 3)
+    .map((p) => p.name)
+    .join(", ");
+  const remainder = parts.length - Math.min(3, parts.length);
+
+  return {
+    title: `${parts.length} parts low on stock`,
+    message: remainder > 0 ? `${preview} and ${remainder} more` : preview,
+  };
+}

+ 30 - 0
src/features/inventory/Lib/onInventoryChanged.ts

@@ -0,0 +1,30 @@
+import { revalidateInventory } from "./revalidateInventory";
+import { processOrgLowStock } from "@/lib/cron/low-stock-alerts";
+
+/**
+ * Call after anything changes stock: a job consuming parts, a quote being
+ * converted, a manual edit, a bulk markup.
+ *
+ * Does two things:
+ *
+ *  1. Invalidates the cached inventory views (see revalidateInventory).
+ *  2. Evaluates low-stock alerts immediately.
+ *
+ * The second is why alerts feel instant. The hourly sweep alone meant a part
+ * dropping below its reorder point at 09:20 produced nothing until 10:15 —
+ * long enough that it reads as broken. The check is deliberately cheap: it
+ * only loads parts that are currently low or still carrying an alert marker,
+ * which are the only rows whose state can change.
+ *
+ * Alerting must never break the user's actual operation, so failures are
+ * logged and swallowed — the hourly sweep remains the backstop.
+ */
+export async function onInventoryChanged(organizationId: string) {
+  revalidateInventory();
+
+  try {
+    await processOrgLowStock(organizationId);
+  } catch (error) {
+    console.error("[low-stock] immediate check failed:", error);
+  }
+}

+ 125 - 14
src/features/inventory/Lib/reconcileStock.ts

@@ -9,6 +9,35 @@ export interface StockPartLine {
   quantity: number;
 }
 
+/**
+ * Why stock moved. Persisted verbatim in `StockMovement.reason`, so these
+ * values are part of the stored data contract — add new ones rather than
+ * renaming existing ones.
+ */
+export type StockMovementReason =
+  | "service_record"
+  | "service_record_deleted"
+  | "quote_conversion"
+  | "manual_adjustment"
+  | "bulk_markup";
+
+/**
+ * Provenance recorded alongside every movement, so the ledger can answer
+ * "where was this part used, and who did it?".
+ */
+export interface StockMovementContext {
+  reason: StockMovementReason;
+  userId?: string | null;
+  /** The job the parts were used on, when there is one. */
+  serviceRecordId?: string | null;
+  /**
+   * Human-readable job label, denormalised so history stays meaningful after
+   * the service record is deleted and `serviceRecordId` is nulled out.
+   */
+  serviceRecordLabel?: string | null;
+  note?: string | null;
+}
+
 /**
  * Convert a (possibly fractional) part quantity into whole stock units.
  *
@@ -24,7 +53,7 @@ function stockUnits(quantity: number): number {
 
 /**
  * Apply the net inventory movement for a service record whose linked parts
- * change from `previous` to `next`.
+ * change from `previous` to `next`, and record each movement in the ledger.
  *
  * Semantics:
  *   - A part present in `next` but not `previous` (or with a larger quantity)
@@ -33,28 +62,31 @@ function stockUnits(quantity: number): number {
  *   - Unchanged quantities produce a zero delta and touch nothing.
  *
  * The movement is computed per `inventoryPartId` as the sum of `next`
- * quantities minus the sum of `previous` quantities, then applied with a
- * single atomic `{ decrement }` per part (a negative delta increments, i.e.
- * restocks). Because it is atomic and never reads-then-writes, concurrent
- * deploys/requests cannot lose updates.
+ * quantities minus the sum of `previous` quantities, then applied with a single
+ * atomic `UPDATE ... RETURNING` per part. Reading the balance back from
+ * RETURNING (rather than a separate SELECT) keeps the write atomic while still
+ * yielding an exact `quantityAfter` for the ledger — concurrent requests can
+ * neither lose an update nor record a stale balance.
  *
  * Every write is scoped to `organizationId`, so a stale or cross-organization
- * `inventoryPartId` simply matches no rows and is skipped — it can never
- * mutate another tenant's inventory.
+ * `inventoryPartId` simply matches no rows, records no movement, and is
+ * skipped — it can never mutate another tenant's inventory.
  *
  * Stock is allowed to go negative: over-consuming is real information
  * ("you used more than you had on hand") and, crucially, keeps the operation
  * reversible. Clamping at zero would corrupt the count as soon as the part is
  * later removed or the record deleted.
  *
- * MUST be called inside a transaction so the stock movement commits or rolls
- * back together with the ServicePart changes that caused it.
+ * MUST be called inside a transaction so the stock movement, the ledger rows,
+ * and the ServicePart changes that caused them all commit or roll back
+ * together.
  */
 export async function reconcileInventoryForParts(
   tx: Prisma.TransactionClient,
   organizationId: string,
   previous: readonly StockPartLine[],
   next: readonly StockPartLine[],
+  context: StockMovementContext,
 ): Promise<void> {
   // inventoryPartId -> net units to DECREMENT (negative = restock)
   const netDecrement = new Map<string, number>();
@@ -70,11 +102,90 @@ export async function reconcileInventoryForParts(
     netDecrement.set(line.inventoryPartId, current + stockUnits(line.quantity));
   }
 
-  for (const [inventoryPartId, delta] of netDecrement) {
-    if (delta === 0) continue;
-    await tx.inventoryPart.updateMany({
-      where: { id: inventoryPartId, organizationId },
-      data: { quantity: { decrement: delta } },
+  const ledgerRows: Prisma.StockMovementCreateManyInput[] = [];
+
+  for (const [inventoryPartId, decrement] of netDecrement) {
+    if (decrement === 0) continue;
+
+    // Atomic, org-scoped, and returns the post-write balance in one round trip.
+    const updated = await tx.$queryRaw<{ quantity: number }[]>`
+      UPDATE "inventory_parts"
+      SET "quantity" = "quantity" - ${decrement}, "updatedAt" = NOW()
+      WHERE "id" = ${inventoryPartId} AND "organizationId" = ${organizationId}
+      RETURNING "quantity"
+    `;
+
+    // No row matched — unknown part, or one belonging to another organization.
+    if (updated.length === 0) continue;
+
+    ledgerRows.push({
+      inventoryPartId,
+      organizationId,
+      // The map holds units to decrement; the ledger stores the signed change.
+      delta: -decrement,
+      quantityAfter: Number(updated[0].quantity),
+      reason: context.reason,
+      userId: context.userId ?? null,
+      serviceRecordId: context.serviceRecordId ?? null,
+      serviceRecordLabel: context.serviceRecordLabel ?? null,
+      note: context.note ?? null,
     });
   }
+
+  if (ledgerRows.length > 0) {
+    await tx.stockMovement.createMany({ data: ledgerRows });
+  }
+}
+
+/**
+ * Record a stock change that did not come from a service record — a manual
+ * edit on the inventory screen, or a bulk markup run.
+ *
+ * `newQuantity` is the absolute value the user set. The delta is derived inside
+ * the statement by joining the table to its own pre-update snapshot: in
+ * `UPDATE ... FROM`, the FROM relation is scanned using the snapshot taken at
+ * statement start, so `prev."quantity"` is the old value while the RETURNING
+ * list sees the new one. (Computing `${newQuantity} - "quantity"` directly in
+ * RETURNING would always yield 0, since RETURNING evaluates against the updated
+ * row.)
+ *
+ * Returns false when no row matched — unknown part, or wrong organization.
+ */
+export async function recordAbsoluteStockChange(
+  tx: Prisma.TransactionClient,
+  organizationId: string,
+  inventoryPartId: string,
+  newQuantity: number,
+  context: StockMovementContext,
+): Promise<boolean> {
+  const updated = await tx.$queryRaw<{ quantity: number; delta: number }[]>`
+    UPDATE "inventory_parts" AS p
+    SET "quantity" = ${newQuantity}, "updatedAt" = NOW()
+    FROM "inventory_parts" AS prev
+    WHERE p."id" = prev."id"
+      AND p."id" = ${inventoryPartId}
+      AND p."organizationId" = ${organizationId}
+    RETURNING p."quantity" AS "quantity", (p."quantity" - prev."quantity") AS "delta"
+  `;
+
+  if (updated.length === 0) return false;
+
+  const delta = Number(updated[0].delta);
+  if (delta === 0) return true;
+
+  await tx.stockMovement.create({
+    data: {
+      inventoryPartId,
+      organizationId,
+      delta,
+      quantityAfter: Number(updated[0].quantity),
+      reason: context.reason,
+      userId: context.userId ?? null,
+      serviceRecordId: context.serviceRecordId ?? null,
+      serviceRecordLabel: context.serviceRecordLabel ?? null,
+      note: context.note ?? null,
+    },
+  });
+
+  return true;
 }

+ 17 - 0
src/features/inventory/Lib/revalidateInventory.ts

@@ -0,0 +1,17 @@
+import { revalidatePath } from "next/cache";
+
+/**
+ * Invalidate every inventory view after stock has moved.
+ *
+ * Must use the "layout" variant. `revalidatePath("/inventory")` invalidates
+ * only that exact route, leaving the `/inventory/[id]` detail pages stale —
+ * including the RSC payload Next prefetches when the list renders its links.
+ * The symptom is a part's history looking empty right after a movement, then
+ * suddenly showing everything once the router cache happens to expire.
+ *
+ * Call from any action that changes `InventoryPart.quantity` or writes a
+ * `StockMovement`.
+ */
+export function revalidateInventory() {
+  revalidatePath("/inventory", "layout");
+}

+ 15 - 0
src/features/inventory/Lib/stockMovementReasons.ts

@@ -0,0 +1,15 @@
+/**
+ * Reasons the UI offers as history filters. Mirrors `StockMovementReason` in
+ * ./reconcileStock and the values persisted in `StockMovement.reason`.
+ *
+ * Kept in a plain module rather than alongside the server actions: a
+ * `"use server"` file may only export async functions, so a const array there
+ * breaks the build.
+ */
+export const STOCK_MOVEMENT_REASONS = [
+  "service_record",
+  "service_record_deleted",
+  "quote_conversion",
+  "manual_adjustment",
+  "bulk_markup",
+] as const;

+ 18 - 0
src/features/quotes/Actions/quoteActions.ts

@@ -4,8 +4,10 @@ import { db } from "@/lib/db";
 import { withAuth } from "@/lib/with-auth";
 import { createQuoteSchema, updateQuoteSchema } from "../Schema/quoteSchema";
 import { revalidatePath } from "next/cache";
+import { onInventoryChanged } from "@/features/inventory/Lib/onInventoryChanged";
 import { resolveInvoicePrefix } from "@/lib/invoice-utils";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { reconcileInventoryForParts } from "@/features/inventory/Lib/reconcileStock";
 import { copyFile, mkdir } from "fs/promises";
 import path from "path";
 
@@ -390,9 +392,23 @@ export async function convertQuoteToServiceRecord(quoteId: string, vehicleId: st
             quantity: p.quantity,
             unitPrice: p.unitPrice,
             total: p.total,
+            // Preserve the stock link so the job — and any later edit or
+            // deletion of it — reconciles against the right inventory item.
+            inventoryPartId: p.inventoryPartId,
             serviceRecordId: created.id,
           })),
         });
+
+        // The quote itself never moved stock (it is only an estimate). The
+        // conversion is the point of consumption, so deduct here — exactly
+        // once, inside the same transaction that creates the job.
+        await reconcileInventoryForParts(tx, organizationId, [], includedParts, {
+          reason: "quote_conversion",
+          userId,
+          serviceRecordId: created.id,
+          serviceRecordLabel: created.invoiceNumber || created.title,
+          note: `Converted from quote ${quote.quoteNumber ?? quote.id}`,
+        });
       }
 
       const includedLabor = quote.laborItems.filter((l) => !l.excluded);
@@ -454,6 +470,8 @@ export async function convertQuoteToServiceRecord(quoteId: string, vehicleId: st
     revalidatePath("/quotes");
     revalidatePath("/work-orders");
     revalidatePath(`/vehicles/${vehicleId}`);
+    // Conversion consumed stock for any inventory-linked quote lines.
+    await onInventoryChanged(organizationId);
     return { ...record, convertedFromQuoteId: quoteId };
   }, {
     requiredPermissions: [{ action: PermissionAction.CREATE, subject: PermissionSubject.SERVICES }],

+ 6 - 0
src/features/quotes/Components/QuotePageClient.tsx

@@ -34,6 +34,7 @@ import { useQuoteFormState } from './useQuoteFormState'
 import { useSaveShortcut } from '@/hooks/use-save-shortcut'
 import { LaborPresetPickerDialog, type LaborPresetOption } from '@/features/labor-presets/Components/LaborPresetPickerDialog'
 import { QuotePartsEditor } from './QuotePartsEditor'
+import type { InventoryPartOption } from '@/features/vehicles/Components/service-edit/form-types'
 import { QuoteLaborEditor } from './QuoteLaborEditor'
 import { QuoteNotesEditor } from './QuoteNotesEditor'
 import { QuoteRightColumn } from './QuoteRightColumn'
@@ -61,6 +62,7 @@ export function QuotePageClient({
   taxEnabled = true,
   defaultLaborRate = 0,
   laborPresets = [],
+  inventoryParts = [],
   smsEnabled = false,
   emailEnabled = false,
   imageAttachments = [],
@@ -75,6 +77,7 @@ export function QuotePageClient({
   taxEnabled?: boolean
   defaultLaborRate?: number
   laborPresets?: LaborPresetOption[]
+  inventoryParts?: InventoryPartOption[]
   smsEnabled?: boolean
   emailEnabled?: boolean
   imageAttachments?: QuoteAttachment[]
@@ -122,6 +125,7 @@ export function QuotePageClient({
           unitPrice: part.unitPrice,
           total: part.quantity * part.unitPrice,
           excluded: false,
+          inventoryPartId: part.inventoryPartId ?? null,
         }))
         state.addPartBulk(newParts)
       }
@@ -163,6 +167,8 @@ export function QuotePageClient({
         onUpdate={state.updatePart}
         onDelete={state.deletePart}
         onAdd={state.addPart}
+        onAddBulk={state.addPartBulk}
+        inventoryParts={inventoryParts}
         t={t}
       />
       <QuoteLaborEditor

+ 36 - 2
src/features/quotes/Components/QuotePartsEditor.tsx

@@ -1,10 +1,12 @@
 "use client";
 
-import { memo } from "react";
+import { memo, useState } from "react";
 import { Button } from "@/components/ui/button";
 import { Input } from "@/components/ui/input";
-import { Plus, Trash2 } from "lucide-react";
+import { Package, Plus, Trash2 } from "lucide-react";
 import { useFormatCurrency } from '@/components/currency-settings-context'
+import { InventoryPickerDialog } from "@/features/vehicles/Components/service-edit/InventoryPickerDialog";
+import type { InventoryPartOption } from "@/features/vehicles/Components/service-edit/form-types";
 import type { QuotePartInput } from "./quote-page-types";
 
 const QuotePartRow = memo(function QuotePartRow({
@@ -51,6 +53,8 @@ interface QuotePartsEditorProps {
   onUpdate: (index: number, field: keyof QuotePartInput, value: string | number | boolean) => void;
   onDelete: (index: number) => void;
   onAdd: () => void;
+  onAddBulk?: (items: QuotePartInput[]) => void;
+  inventoryParts?: InventoryPartOption[];
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   t: (key: string, values?: any) => string;
 }
@@ -62,14 +66,23 @@ export const QuotePartsEditor = memo(function QuotePartsEditor({
   onUpdate,
   onDelete,
   onAdd,
+  onAddBulk,
+  inventoryParts = [],
   t,
 }: QuotePartsEditorProps) {
   const formatCurrency = useFormatCurrency()
+  const [pickerOpen, setPickerOpen] = useState(false)
+  const canPickFromStock = inventoryParts.length > 0 && !!onAddBulk
   return (
     <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>
         <div className="flex items-center gap-2">
+          {canPickFromStock && (
+            <Button type="button" variant="outline" size="sm" onClick={() => setPickerOpen(true)}>
+              <Package className="mr-1 h-3.5 w-3.5" /> {t("parts.addFromInventory")}
+            </Button>
+          )}
           <Button type="button" variant="outline" size="sm" onClick={onAdd}>
             <Plus className="mr-1 h-3.5 w-3.5" /> {t("parts.addPart")}
           </Button>
@@ -98,6 +111,27 @@ export const QuotePartsEditor = memo(function QuotePartsEditor({
           <div className="flex justify-end pt-1 text-sm"><span className="font-medium">{t("parts.subtotal", { amount: formatCurrency(partsSubtotal, currencyCode) })}</span></div>
         </>
       )}
+      {canPickFromStock && (
+        <InventoryPickerDialog
+          open={pickerOpen}
+          onOpenChange={setPickerOpen}
+          inventoryParts={inventoryParts}
+          currencyCode={currencyCode}
+          onSelectPart={(picked) => {
+            onAddBulk?.([
+              {
+                partNumber: picked.partNumber ?? "",
+                name: picked.name,
+                quantity: picked.quantity,
+                unitPrice: picked.unitPrice,
+                total: picked.quantity * picked.unitPrice,
+                excluded: false,
+                inventoryPartId: picked.inventoryPartId ?? null,
+              },
+            ])
+          }}
+        />
+      )}
       {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={onAdd}>
           <Plus className="mr-1 h-4 w-4" /><span className="text-sm">{t("parts.addPart")}</span>

+ 3 - 1
src/features/quotes/Components/quote-page-types.ts

@@ -40,7 +40,7 @@ export interface QuoteRecord {
   inspectionId: string | null;
   createdAt: Date;
   updatedAt: Date;
-  partItems: { id: string; partNumber: string | null; name: string; quantity: number; unitPrice: number; total: number; excluded?: boolean }[];
+  partItems: { id: string; partNumber: string | null; name: string; quantity: number; unitPrice: number; total: number; excluded?: boolean; inventoryPartId?: string | null }[];
   laborItems: { id: string; description: string; hours: number; rate: number; total: number; pricingType?: string; 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;
@@ -63,6 +63,8 @@ export const emptyPart = (): QuotePartInput => ({
   unitPrice: 0,
   total: 0,
   excluded: false,
+  // Free-text line by default; set when picked from stock.
+  inventoryPartId: null,
 });
 
 export const makeEmptyLabor = (defaultRate: number): QuoteLaborInput => ({

+ 1 - 1
src/features/quotes/Components/useQuoteFormState.ts

@@ -57,7 +57,7 @@ export function useQuoteFormState({
   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, excluded: p.excluded ?? false }))
+    quote.partItems.map((p) => ({ partNumber: p.partNumber || "", name: p.name, quantity: p.quantity, unitPrice: p.unitPrice, total: p.total, excluded: p.excluded ?? false, inventoryPartId: p.inventoryPartId ?? null }))
   );
   const [laborItems, setLaborItems] = useState<QuoteLaborInput[]>(
     quote.laborItems.map((l) => ({ description: l.description, hours: l.hours, rate: l.rate, total: l.total, pricingType: (l.pricingType as "hourly" | "service") || "hourly", excluded: l.excluded ?? false }))

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

@@ -7,6 +7,11 @@ export const quotePartSchema = z.object({
   unitPrice: z.coerce.number().min(0).default(0),
   total: z.coerce.number().min(0).default(0),
   excluded: z.boolean().optional().default(false),
+  /**
+   * Set when the line was picked from stock. Carried through to ServicePart on
+   * conversion so the resulting job deducts this exact inventory item.
+   */
+  inventoryPartId: z.string().nullish(),
 });
 
 export const quoteLaborSchema = z.object({

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

@@ -66,6 +66,16 @@ export const SETTING_KEYS = {
   MAINTENANCE_SERVICE_INTERVAL: 'maintenance.serviceInterval',
   MAINTENANCE_APPROACHING_THRESHOLD: 'maintenance.approachingThreshold',
   INVENTORY_MARKUP_MULTIPLIER: 'inventory.markupMultiplier',
+  LOW_STOCK_ALERTS_ENABLED: 'inventory.lowStockAlerts.enabled',
+  /// Org-wide fallback reorder point, applied to parts with no minQuantity of
+  /// their own. 0 means only explicitly configured parts are watched.
+  LOW_STOCK_DEFAULT_THRESHOLD: 'inventory.lowStockAlerts.defaultThreshold',
+  LOW_STOCK_ALERTS_IN_APP: 'inventory.lowStockAlerts.inApp',
+  LOW_STOCK_ALERTS_EMAIL: 'inventory.lowStockAlerts.email',
+  LOW_STOCK_ALERTS_EMAIL_MIN_INTERVAL_HOURS: 'inventory.lowStockAlerts.emailMinIntervalHours',
+  /// Internal bookkeeping — the last time a digest actually went out, used to
+  /// enforce the minimum interval. Not user-editable.
+  LOW_STOCK_ALERTS_LAST_EMAIL_AT: 'inventory.lowStockAlerts.lastEmailAt',
   PARTS_DEFAULT_MARKUP_PERCENT: 'parts.defaultMarkupPercent',
   PARTS_MARKUP_APPLIES_TO_INVENTORY: 'parts.markupAppliesToInventory',
   SMS_TEMPLATE_INVOICE_READY: 'sms.template.invoiceReady',

+ 16 - 5
src/features/vehicles/Actions/addPartToServiceRecord.ts

@@ -4,6 +4,7 @@ import { db } from "@/lib/db";
 import { withAuth } from "@/lib/with-auth";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
 import { revalidatePath } from "next/cache";
+import { onInventoryChanged } from "@/features/inventory/Lib/onInventoryChanged";
 import { calculateTotals } from "@/lib/tax";
 import { reconcileInventoryForParts } from "@/features/inventory/Lib/reconcileStock";
 
@@ -18,10 +19,10 @@ export async function addPartToServiceRecord(input: {
   inventoryPartId?: string;
 }) {
   return withAuth(
-    async ({ organizationId }) => {
+    async ({ userId, organizationId }) => {
       const record = await db.serviceRecord.findFirst({
         where: { id: input.serviceRecordId, vehicle: { organizationId } },
-        select: { id: true, vehicleId: true, subtotal: true, taxRate: true, taxInclusive: true, discountType: true, discountValue: true },
+        select: { id: true, vehicleId: true, subtotal: true, taxRate: true, taxInclusive: true, discountType: true, discountValue: true, title: true, invoiceNumber: true },
       });
       if (!record) throw new Error("Service record not found");
 
@@ -73,14 +74,24 @@ export async function addPartToServiceRecord(input: {
         });
 
         // Deduct inventory stock for the newly added line (delta from empty).
-        await reconcileInventoryForParts(tx, organizationId, [], [
-          { inventoryPartId: input.inventoryPartId, quantity: input.quantity },
-        ]);
+        await reconcileInventoryForParts(
+          tx,
+          organizationId,
+          [],
+          [{ inventoryPartId: input.inventoryPartId, quantity: input.quantity }],
+          {
+            reason: "service_record",
+            userId,
+            serviceRecordId: record.id,
+            serviceRecordLabel: record.invoiceNumber || record.title,
+          },
+        );
 
         return created;
       });
 
       revalidatePath(`/vehicles/${record.vehicleId}/service/${record.id}`);
+      if (input.inventoryPartId) await onInventoryChanged(organizationId);
 
       return part;
     },

+ 24 - 0
src/features/vehicles/Actions/dashboardActions.ts

@@ -1,6 +1,7 @@
 "use server";
 
 import { db } from "@/lib/db";
+import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
 import { withAuth } from "@/lib/with-auth";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
 
@@ -8,10 +9,20 @@ export async function getDashboardStats() {
   return withAuth(async ({ organizationId, role }) => {
     const isAdmin = role === "owner" || role === "admin" || role === "super_admin";
 
+    // Org-wide fallback reorder point; 0 disables it.
+    const thresholdRow = await db.appSetting.findFirst({
+      where: { organizationId, key: SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD },
+      select: { value: true },
+    });
+    const parsedDefault = Number(thresholdRow?.value);
+    const lowStockDefault =
+      Number.isFinite(parsedDefault) && parsedDefault > 0 ? parsedDefault : 0;
+
     const [
       activeJobs,
       pendingJobs,
       totalParts,
+      lowStockRows,
       totalCustomers,
       todaysServices,
       recentServices,
@@ -25,6 +36,18 @@ export async function getDashboardStats() {
       db.inventoryPart.count({
         where: { organizationId, isArchived: false },
       }),
+      // Parts at or below their reorder point. Needs raw SQL because Prisma
+      // cannot compare two columns of the same row in a `where` clause.
+      // Mirrors isLow() in features/inventory/Lib/lowStockAlerts: a part's own
+      // minQuantity wins, falling back to the org-wide default when unset.
+      db.$queryRaw<{ count: bigint }[]>`
+        SELECT COUNT(*) AS count
+        FROM "inventory_parts" p
+        WHERE p."organizationId" = ${organizationId}
+          AND p."isArchived" = false
+          AND COALESCE(NULLIF(p."minQuantity", 0), ${lowStockDefault}) > 0
+          AND p."quantity" <= COALESCE(NULLIF(p."minQuantity", 0), ${lowStockDefault})
+      `,
       db.customer.count({ where: { organizationId } }),
       db.serviceRecord.findMany({
         where: {
@@ -73,6 +96,7 @@ export async function getDashboardStats() {
       activeJobs,
       pendingJobs,
       totalParts,
+      lowStockParts: Number(lowStockRows[0]?.count ?? 0),
       totalCustomers,
       todaysServices,
       recentServices,

+ 28 - 6
src/features/vehicles/Actions/serviceActions.ts

@@ -4,6 +4,7 @@ import { db } from "@/lib/db";
 import { withAuth } from "@/lib/with-auth";
 import { createServiceSchema, updateServiceSchema } from "../Schema/serviceSchema";
 import { revalidatePath } from "next/cache";
+import { onInventoryChanged } from "@/features/inventory/Lib/onInventoryChanged";
 import { unlink } from "fs/promises";
 import { randomUUID } from "crypto";
 import { resolveUploadPath } from "@/lib/resolve-upload-path";
@@ -330,7 +331,12 @@ export async function createServiceRecord(input: unknown) {
         await tx.servicePart.createMany({ data: enrichedParts });
 
         // Deduct stock for inventory-linked parts (delta from an empty set).
-        await reconcileInventoryForParts(tx, organizationId, [], partItems);
+        await reconcileInventoryForParts(tx, organizationId, [], partItems, {
+          reason: "service_record",
+          userId,
+          serviceRecordId: created.id,
+          serviceRecordLabel: created.invoiceNumber || created.title,
+        });
       }
 
       if (laborItems && laborItems.length > 0) {
@@ -357,7 +363,7 @@ export async function createServiceRecord(input: unknown) {
     // Revalidate inventory if any parts were sourced from inventory
     const hasInventoryParts = partItems?.some((p) => p.inventoryPartId);
     if (hasInventoryParts) {
-      revalidatePath("/inventory");
+      await onInventoryChanged(organizationId);
     }
 
     // Update vehicle mileage if service mileage is higher, and reset maintenance dismissed
@@ -390,7 +396,7 @@ export async function createServiceRecord(input: unknown) {
 }
 
 export async function updateServiceRecord(input: unknown) {
-  return withAuth(async ({ organizationId }) => {
+  return withAuth(async ({ userId, organizationId }) => {
     const data = updateServiceSchema.parse(input);
     const existing = await db.serviceRecord.findFirst({
       where: { id: data.id, vehicle: { organizationId } },
@@ -475,7 +481,12 @@ export async function updateServiceRecord(input: unknown) {
           });
         }
 
-        await reconcileInventoryForParts(tx, organizationId, previousParts, partItems);
+        await reconcileInventoryForParts(tx, organizationId, previousParts, partItems, {
+          reason: "service_record",
+          userId,
+          serviceRecordId: id,
+          serviceRecordLabel: existing.invoiceNumber || existing.title,
+        });
       }
 
       // Replace labor if provided
@@ -555,6 +566,8 @@ export async function updateServiceRecord(input: unknown) {
     revalidatePath(`/vehicles/${existing.vehicleId}`);
     revalidatePath(`/vehicles/${existing.vehicleId}/service/${id}`);
     revalidatePath("/services");
+    // Parts may have been added, changed or removed on this record.
+    await onInventoryChanged(organizationId);
     return record;
   }, {
     requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES }],
@@ -722,7 +735,7 @@ export async function getWorkOrders(params: {
 }
 
 export async function deleteServiceRecord(recordId: string) {
-  return withAuth(async ({ organizationId }) => {
+  return withAuth(async ({ userId, organizationId }) => {
     const record = await db.serviceRecord.findFirst({
       where: { id: recordId, vehicle: { organizationId } },
       include: { attachments: true },
@@ -747,13 +760,22 @@ export async function deleteServiceRecord(recordId: string) {
         where: { serviceRecordId: recordId },
         select: { inventoryPartId: true, quantity: true },
       });
-      await reconcileInventoryForParts(tx, organizationId, parts, []);
+      await reconcileInventoryForParts(tx, organizationId, parts, [], {
+        reason: "service_record_deleted",
+        userId,
+        // Deliberately no serviceRecordId: the record is deleted in this same
+        // transaction, so the FK would immediately null out. The label is what
+        // preserves "this stock came back from job X".
+        serviceRecordLabel: record.invoiceNumber || record.title,
+      });
       await tx.serviceRecord.delete({ where: { id: recordId } });
     });
 
     revalidatePath("/");
     revalidatePath(`/vehicles/${record.vehicleId}`);
     revalidatePath("/services");
+    // Deleting the record restocked its linked parts.
+    await onInventoryChanged(organizationId);
     return { recordId };
   }, {
     requiredPermissions: [{ action: PermissionAction.DELETE, subject: PermissionSubject.SERVICES }],

+ 99 - 0
src/hooks/use-debounced-search.ts

@@ -0,0 +1,99 @@
+'use client'
+
+import { useCallback, useEffect, useRef, useState } from 'react'
+
+/** Delay before a pause in typing triggers a search. */
+const DEFAULT_DELAY_MS = 350
+
+/**
+ * Live-search input state for the list pages.
+ *
+ * These pages previously wrapped a bare `<Input>` in a `<form onSubmit>`, so
+ * the only way to run a search was to press Enter — with no submit button and
+ * no visible affordance. In practice people typed, saw nothing happen, and
+ * clicked the field repeatedly; the inventory search alone was the single
+ * largest source of rage-clicks in the app.
+ *
+ * This hook keeps the input responsive on every keystroke while pushing the
+ * committed value through the caller's existing URL-param navigation after a
+ * short pause, so pagination/sort handling is unchanged.
+ *
+ * @param initial   Search term from the URL (server-rendered).
+ * @param onCommit  Applies the term — typically `navigate({ search: term })`.
+ */
+export function useDebouncedSearch(
+  initial: string,
+  onCommit: (term: string | undefined) => void,
+  delayMs: number = DEFAULT_DELAY_MS,
+) {
+  const [value, setValue] = useState(initial)
+
+  // Keep the callback in a ref so a caller passing an inline arrow function
+  // doesn't restart the timer on every render.
+  const onCommitRef = useRef(onCommit)
+  useEffect(() => {
+    onCommitRef.current = onCommit
+  }, [onCommit])
+
+  // The term currently reflected in the URL, always stored trimmed so it can be
+  // compared directly against the server-provided `initial`. Seeded so the
+  // first render never re-commits what the server already applied.
+  const committedRef = useRef(initial)
+  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
+
+  const clearPending = () => {
+    if (timerRef.current) {
+      clearTimeout(timerRef.current)
+      timerRef.current = null
+    }
+  }
+
+  // Adopt genuinely external changes to the URL (back/forward, a filter chip,
+  // a reset link).
+  //
+  // Crucially this must NOT react to the echo of our own commit. Navigation is
+  // asynchronous, so characters typed while the server round-trips would be
+  // overwritten by the older term coming back down as a prop — the input would
+  // visibly drop a letter. Comparing against the last committed term
+  // distinguishes "someone else changed the URL" from "our own search landed".
+  useEffect(() => {
+    if (initial === committedRef.current) return
+    committedRef.current = initial
+    setValue(initial)
+  }, [initial])
+
+  // Debounce on the trimmed term: `committedRef` also holds a trimmed value, so
+  // typing a trailing space is not treated as a new search.
+  const term = value.trim()
+
+  useEffect(() => {
+    if (term === committedRef.current) return
+
+    timerRef.current = setTimeout(() => {
+      timerRef.current = null
+      committedRef.current = term
+      onCommitRef.current(term || undefined)
+    }, delayMs)
+
+    return clearPending
+  }, [term, delayMs])
+
+  /**
+   * Commit immediately, skipping the debounce. Wired to the form's onSubmit so
+   * pressing Enter still works; cancelling the pending timer first stops it
+   * firing a duplicate navigation a moment later.
+   */
+  const commitNow = useCallback(
+    (e?: React.FormEvent) => {
+      e?.preventDefault()
+      clearPending()
+      const next = value.trim()
+      if (next === committedRef.current) return
+      committedRef.current = next
+      onCommitRef.current(next || undefined)
+    },
+    [value],
+  )
+
+  return { value, setValue, commitNow }
+}

+ 2 - 0
src/instrumentation.ts

@@ -9,6 +9,7 @@ export async function register() {
       processReportSchedules,
       processWebhookDeliveries,
       cleanupWebhookDeliveries,
+      checkLowStock,
     } = await import('./cronTasks')
     checkLicenses()
     checkSubscriptions()
@@ -18,5 +19,6 @@ export async function register() {
     processReportSchedules()
     processWebhookDeliveries()
     cleanupWebhookDeliveries()
+    checkLowStock()
   }
 }

+ 303 - 0
src/lib/cron/low-stock-alerts.ts

@@ -0,0 +1,303 @@
+import { CronJob } from "cron";
+import { db } from "@/lib/db";
+import { notify } from "@/lib/notify";
+import { sendOrgMail, getOrgFromAddress } from "@/lib/email";
+import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
+import {
+  buildAlertSummary,
+  canSendDigestEmail,
+  decideLowStockAlerts,
+  formatLowStockLine,
+  type LowStockCandidate,
+} from "@/features/inventory/Lib/lowStockAlerts";
+
+const LOG_PREFIX = "[low-stock]";
+const DEFAULT_EMAIL_MIN_INTERVAL_HOURS = 24;
+
+interface OrgAlertSettings {
+  enabled: boolean;
+  inApp: boolean;
+  email: boolean;
+  emailMinIntervalHours: number;
+  lastEmailAt: Date | null;
+  defaultThreshold: number;
+}
+
+function readSettings(rows: { key: string; value: string }[]): OrgAlertSettings {
+  const map = new Map(rows.map((r) => [r.key, r.value]));
+  const parsedInterval = Number(
+    map.get(SETTING_KEYS.LOW_STOCK_ALERTS_EMAIL_MIN_INTERVAL_HOURS),
+  );
+  const parsedThreshold = Number(map.get(SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD));
+  const lastRaw = map.get(SETTING_KEYS.LOW_STOCK_ALERTS_LAST_EMAIL_AT);
+  const lastEmailAt = lastRaw ? new Date(lastRaw) : null;
+
+  return {
+    enabled: map.get(SETTING_KEYS.LOW_STOCK_ALERTS_ENABLED) === "true",
+    // Channels default on/off respectively; only meaningful when enabled.
+    inApp: map.get(SETTING_KEYS.LOW_STOCK_ALERTS_IN_APP) !== "false",
+    email: map.get(SETTING_KEYS.LOW_STOCK_ALERTS_EMAIL) === "true",
+    emailMinIntervalHours: Number.isFinite(parsedInterval) && parsedInterval > 0
+      ? parsedInterval
+      : DEFAULT_EMAIL_MIN_INTERVAL_HOURS,
+    lastEmailAt:
+      lastEmailAt && !Number.isNaN(lastEmailAt.getTime()) ? lastEmailAt : null,
+    defaultThreshold:
+      Number.isFinite(parsedThreshold) && parsedThreshold > 0 ? parsedThreshold : 0,
+  };
+}
+
+/** Owners and admins receive the digest. */
+async function getRecipients(organizationId: string) {
+  const members = await db.organizationMember.findMany({
+    where: { organizationId, role: { in: ["owner", "admin"] } },
+    select: { user: { select: { email: true, name: true } } },
+  });
+  return members
+    .map((m) => m.user)
+    .filter((u): u is { email: string; name: string } => !!u?.email);
+}
+
+async function sendDigest(
+  organizationId: string,
+  parts: LowStockCandidate[],
+  now: Date,
+  defaultThreshold: number,
+) {
+  const recipients = await getRecipients(organizationId);
+  if (recipients.length === 0) return false;
+
+  const org = await db.organization
+    .findUnique({ where: { id: organizationId }, select: { name: true } })
+    .catch(() => null);
+
+  // Resolving the sender reads the org's email provider settings — exactly
+  // what is broken when email is misconfigured. Without a sender there is no
+  // honest way to send, so skip the digest rather than invent an address. The
+  // in-app notification has already gone out and the parts are still marked by
+  // the caller, so nothing is lost but this one email.
+  let fromAddress: string;
+  try {
+    fromAddress = await getOrgFromAddress(organizationId);
+  } catch (error) {
+    console.error(
+      `${LOG_PREFIX} email is misconfigured (could not resolve sender); skipping digest:`,
+      error,
+    );
+    return false;
+  }
+
+  const rows = parts
+    .map((p) => `<li>${formatLowStockLine(p, defaultThreshold)}</li>`)
+    .join("");
+  const subject =
+    parts.length === 1
+      ? `Low stock: ${parts[0].name}`
+      : `Low stock: ${parts.length} parts need reordering`;
+
+  let sent = 0;
+  for (const recipient of recipients) {
+    try {
+      await sendOrgMail(organizationId, {
+        to: recipient.email,
+        subject,
+        from: fromAddress,
+        html:
+          `<p>Hi ${recipient.name || ""},</p>` +
+          `<p>The following parts at <strong>${org?.name ?? "your workshop"}</strong> ` +
+          `have reached their reorder point:</p><ul>${rows}</ul>` +
+          `<p>You are receiving this because low-stock alerts are enabled in ` +
+          `Settings. Each part is reported once. You will not be reminded ` +
+          `again until it is restocked and drops low a second time.</p>`,
+      });
+      sent++;
+    } catch (error) {
+      console.error(`${LOG_PREFIX} email to ${recipient.email} failed:`, error);
+    }
+  }
+
+  if (sent === 0) return false;
+
+  // Record the send so the minimum-interval throttle holds across runs.
+  try {
+    await db.appSetting.upsert({
+    where: {
+      organizationId_key: {
+        organizationId,
+        key: SETTING_KEYS.LOW_STOCK_ALERTS_LAST_EMAIL_AT,
+      },
+    },
+    create: {
+      organizationId,
+      key: SETTING_KEYS.LOW_STOCK_ALERTS_LAST_EMAIL_AT,
+      value: now.toISOString(),
+      // AppSetting requires a userId; attribute automated writes to an owner.
+      userId: (
+        await db.organizationMember.findFirst({
+          where: { organizationId, role: "owner" },
+          select: { userId: true },
+        })
+      )?.userId ?? "",
+    },
+      update: { value: now.toISOString() },
+    });
+  } catch (error) {
+    // Losing the timestamp only costs an extra digest later; never fail here.
+    console.error(`${LOG_PREFIX} could not record last-email time:`, error);
+  }
+
+  return true;
+}
+
+/**
+ * Process one organization. Exported so it can be driven directly from a test
+ * or an admin action without waiting for the schedule.
+ */
+export async function processOrgLowStock(organizationId: string, now = new Date()) {
+  const settingRows = await db.appSetting.findMany({
+    where: {
+      organizationId,
+      key: {
+        in: [
+          SETTING_KEYS.LOW_STOCK_ALERTS_ENABLED,
+          SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD,
+          SETTING_KEYS.LOW_STOCK_ALERTS_IN_APP,
+          SETTING_KEYS.LOW_STOCK_ALERTS_EMAIL,
+          SETTING_KEYS.LOW_STOCK_ALERTS_EMAIL_MIN_INTERVAL_HOURS,
+          SETTING_KEYS.LOW_STOCK_ALERTS_LAST_EMAIL_AT,
+        ],
+      },
+    },
+    select: { key: true, value: true },
+  });
+
+  const settings = readSettings(settingRows);
+  if (!settings.enabled) return { skipped: true as const };
+
+  // Only two groups can change state: parts that are currently low (may need
+  // alerting) and parts still carrying a marker (may need re-arming). Filtering
+  // in SQL rather than loading the whole inventory keeps this cheap enough to
+  // run on every stock movement, not just on the hourly sweep.
+  //
+  // The COALESCE mirrors effectiveThreshold(): the part's own reorder point
+  // wins, else the org-wide default.
+  const threshold = settings.defaultThreshold;
+  const candidates = await db.$queryRaw<
+    {
+      id: string;
+      name: string;
+      partNumber: string | null;
+      quantity: number;
+      minQuantity: number;
+      lowStockAlertedAt: Date | null;
+    }[]
+  >`
+    SELECT "id", "name", "partNumber", "quantity", "minQuantity", "lowStockAlertedAt"
+    FROM "inventory_parts"
+    WHERE "organizationId" = ${organizationId}
+      AND "isArchived" = false
+      AND (
+        (
+          COALESCE(NULLIF("minQuantity", 0), ${threshold}) > 0
+          AND "quantity" <= COALESCE(NULLIF("minQuantity", 0), ${threshold})
+        )
+        OR "lowStockAlertedAt" IS NOT NULL
+      )
+  `;
+
+  const { newlyLow, toRearm } = decideLowStockAlerts(candidates, settings.defaultThreshold);
+
+  // Recovered parts are re-armed even when nothing new is low, so the next dip
+  // is reported.
+  if (toRearm.length > 0) {
+    await db.inventoryPart.updateMany({
+      where: { id: { in: toRearm }, organizationId },
+      data: { lowStockAlertedAt: null },
+    });
+  }
+
+  if (newlyLow.length === 0) {
+    return { skipped: false as const, alerted: 0, rearmed: toRearm.length, emailed: false };
+  }
+
+  if (settings.inApp) {
+    // One grouped notification, never one per part.
+    const summary = buildAlertSummary(newlyLow, settings.defaultThreshold);
+    await notify({
+      organizationId,
+      type: "inventory.lowStock",
+      title: summary.title,
+      message: summary.message,
+      entityType: "InventoryPart",
+      entityId: newlyLow.length === 1 ? newlyLow[0].id : "",
+      entityUrl: newlyLow.length === 1 ? `/inventory/${newlyLow[0].id}` : "/inventory?lowStock=1",
+    });
+  }
+
+  let emailed = false;
+  if (
+    settings.email &&
+    canSendDigestEmail(settings.lastEmailAt, now, settings.emailMinIntervalHours)
+  ) {
+    // Isolated deliberately. If a misconfigured mail server throws here and the
+    // error escaped, the marking below would be skipped and these same parts
+    // would be re-reported on every subsequent run — a broken SMTP setting
+    // would turn into in-app notification spam. Email is best-effort; the
+    // ledger of what has been alerted is not.
+    try {
+      emailed = await sendDigest(
+        organizationId,
+        newlyLow,
+        now,
+        settings.defaultThreshold,
+      );
+    } catch (error) {
+      console.error(`${LOG_PREFIX} digest email failed:`, error);
+    }
+  }
+
+  // Marking always happens, even if the email leg failed above.
+  await db.inventoryPart.updateMany({
+    where: { id: { in: newlyLow.map((p) => p.id) }, organizationId },
+    data: { lowStockAlertedAt: now },
+  });
+
+  return {
+    skipped: false as const,
+    alerted: newlyLow.length,
+    rearmed: toRearm.length,
+    emailed,
+  };
+}
+
+/** Hourly low-stock scan. Organizations opt in via settings. */
+export function checkLowStock() {
+  const job = new CronJob("15 * * * *", async () => {
+    try {
+      // Only organizations that switched the feature on.
+      const enabled = await db.appSetting.findMany({
+        where: { key: SETTING_KEYS.LOW_STOCK_ALERTS_ENABLED, value: "true" },
+        select: { organizationId: true },
+      });
+
+      let alerted = 0;
+      for (const row of enabled) {
+        if (!row.organizationId) continue;
+        try {
+          const result = await processOrgLowStock(row.organizationId);
+          if (!result.skipped) alerted += result.alerted;
+        } catch (error) {
+          console.error(`${LOG_PREFIX} org ${row.organizationId} failed:`, error);
+        }
+      }
+
+      if (alerted > 0) {
+        console.warn(`${LOG_PREFIX} alerted on ${alerted} newly low part(s)`);
+      }
+    } catch (error) {
+      console.error(`${LOG_PREFIX} scan failed:`, error);
+    }
+  });
+
+  job.start();
+}