Просмотр исходного кода

Tag inventory parts with the makes and models they fit (#551)

* Tag inventory parts with the makes and models they fit

Parts take free-text tags on the part form. The inventory list shows them,
filters by one and finds parts by them; so do the search palette, the
typeahead and the stock pickers. On a work order or quote, parts tagged with
the vehicle's make or model are listed first in the picker.

Also loads the unit on the part page, where saving the part cleared it.

* Address review: another make, pasted lists, combobox ids, AI hint, preset tags

- A tag opening with another make ("Toyota Sprinter" on a Mercedes-Benz
  Sprinter) no longer counts as a fit; the vehicle's own make in any spelling
  ("Mercedes", "VW", "Vauxhall" for an Opel) still does.
- The tag box no longer cuts a pasted list at 40 characters. A tag over the
  limit stays in the box with a note, and the form will not save it.
- The tag box names its list and the highlighted suggestion for assistive
  technology (aria-controls, aria-activedescendant).
- The AI schema hint for tags uses an expression the SQL check accepts.
- The labour preset page passes tags to its part picker.
Bernt Christian Egeland 9 часов назад
Родитель
Сommit
4c3c04e41f
61 измененных файлов с 2446 добавлено и 690 удалено
  1. 13 1
      messages/de/inventory.json
  2. 2 1
      messages/de/service.json
  3. 13 1
      messages/en/inventory.json
  4. 2 1
      messages/en/service.json
  5. 13 1
      messages/es/inventory.json
  6. 2 1
      messages/es/service.json
  7. 13 1
      messages/fr/inventory.json
  8. 2 1
      messages/fr/service.json
  9. 13 1
      messages/it/inventory.json
  10. 2 1
      messages/it/service.json
  11. 13 1
      messages/lt/inventory.json
  12. 2 1
      messages/lt/service.json
  13. 13 1
      messages/nb/inventory.json
  14. 2 1
      messages/nb/service.json
  15. 13 1
      messages/nl/inventory.json
  16. 2 1
      messages/nl/service.json
  17. 13 1
      messages/pl/inventory.json
  18. 2 1
      messages/pl/service.json
  19. 13 1
      messages/pt-BR/inventory.json
  20. 2 1
      messages/pt-BR/service.json
  21. 13 1
      messages/pt-PT/inventory.json
  22. 2 1
      messages/pt-PT/service.json
  23. 13 1
      messages/ru/inventory.json
  24. 2 1
      messages/ru/service.json
  25. 13 1
      messages/tr/inventory.json
  26. 2 1
      messages/tr/service.json
  27. 2 0
      prisma/migrations/20261109133354_inventory_part_tags/migration.sql
  28. 10 6
      prisma/schema/inventory.prisma
  29. 177 0
      src/__tests__/components/tag-input.test.tsx
  30. 11 1
      src/__tests__/features/ai/sql-guard.test.ts
  31. 112 0
      src/__tests__/features/inventory/inventory-picker-tags.test.tsx
  32. 27 0
      src/__tests__/features/inventory/part-name-suggestions.test.tsx
  33. 178 0
      src/__tests__/features/inventory/part-tag-actions.test.ts
  34. 194 0
      src/__tests__/features/inventory/part-tags.test.ts
  35. 3 2
      src/__tests__/lib/raw-sql-qualified.test.ts
  36. 27 21
      src/app/(authenticated)/inventory/[id]/page.tsx
  37. 11 0
      src/app/(authenticated)/inventory/[id]/part-detail-client.tsx
  38. 68 7
      src/app/(authenticated)/inventory/inventory-client.tsx
  39. 8 1
      src/app/(authenticated)/inventory/page.tsx
  40. 1 0
      src/app/(authenticated)/labor-presets/labor-presets-client.tsx
  41. 1 0
      src/app/(authenticated)/labor-presets/page.tsx
  42. 258 0
      src/components/tag-input.tsx
  43. 1 0
      src/features/ai/tools/workshop-columns.ts
  44. 1 1
      src/features/ai/tools/workshop-tools.ts
  45. 4 0
      src/features/inventory/Actions/getStockMovements.ts
  46. 62 14
      src/features/inventory/Actions/inventoryActions.ts
  47. 439 383
      src/features/inventory/Components/InventoryPartForm.tsx
  48. 8 3
      src/features/inventory/Components/PartNameSuggestions.tsx
  49. 74 0
      src/features/inventory/Components/PartTagChips.tsx
  50. 249 0
      src/features/inventory/Lib/partTags.ts
  51. 49 0
      src/features/inventory/Lib/tagSearch.server.ts
  52. 9 0
      src/features/inventory/Schema/inventorySchema.ts
  53. 5 7
      src/features/labor-presets/Components/InventorySearchDialog.tsx
  54. 184 186
      src/features/labor-presets/Components/LaborPresetForm.tsx
  55. 1 0
      src/features/quotes/Components/QuotePageClient.tsx
  56. 5 0
      src/features/quotes/Components/QuotePartsEditor.tsx
  57. 33 21
      src/features/search/Actions/searchActions.ts
  58. 32 11
      src/features/vehicles/Components/service-edit/InventoryPickerDialog.tsx
  59. 2 0
      src/features/vehicles/Components/service-edit/form-types.ts
  60. 1 0
      src/features/vehicles/Components/service-page/ServicePageClient.tsx
  61. 4 0
      src/lib/backup/import.ts

+ 13 - 1
messages/de/inventory.json

@@ -129,7 +129,13 @@
     "descriptionPlaceholder": "Teilebeschreibung...",
     "cancel": "Abbrechen",
     "saveChanges": "Änderungen speichern",
-    "addPart": "Teil hinzufügen"
+    "addPart": "Teil hinzufügen",
+    "tags": "Tags",
+    "tagsPlaceholder": "Marken, Modelle oder andere Suchbegriffe",
+    "tagsHint": "Nach jedem Tag Enter oder ein Komma drücken. In einem Auftrag stehen Teile mit der Marke oder dem Modell des Fahrzeugs ganz oben.",
+    "tagsHintMarine": "Nach jedem Tag Enter oder ein Komma drücken. In einem Auftrag stehen Teile mit der Marke oder dem Modell des Bootes ganz oben.",
+    "removeTag": "{tag} entfernen",
+    "tagTooLong": "Ein Tag darf höchstens {max} Zeichen lang sein. Bitte vor dem Speichern kürzen."
   },
   "openMenu": "Menü öffnen",
   "history": {
@@ -157,5 +163,11 @@
     "inStock": "{quantity} auf Lager",
     "outOfStock": "Nicht auf Lager",
     "onBackorder": "Im Rückstand ({count})"
+  },
+  "tags": {
+    "filterPlaceholder": "Tag",
+    "all": "Alle Tags",
+    "filterBy": "Teile mit dem Tag {tag} anzeigen",
+    "more": "+{count} weitere"
   }
 }

+ 2 - 1
messages/de/service.json

@@ -147,7 +147,8 @@
     "deleteRow": "Zeile löschen",
     "outOfStock": "Nicht auf Lager",
     "onBackorder": "Im Rückstand ({count})",
-    "nameMissingHint": "Zeilen ohne Teilenamen werden nicht gespeichert."
+    "nameMissingHint": "Zeilen ohne Teilenamen werden nicht gespeichert.",
+    "fitsVehicleFirst": "Teile mit Tags für {vehicle} stehen oben."
   },
   "labor": {
     "title": "Arbeit",

+ 13 - 1
messages/en/inventory.json

@@ -129,7 +129,13 @@
     "descriptionPlaceholder": "Part description...",
     "cancel": "Cancel",
     "saveChanges": "Save Changes",
-    "addPart": "Add Part"
+    "addPart": "Add Part",
+    "tags": "Tags",
+    "tagsPlaceholder": "Makes, models or anything to find it by",
+    "tagsHint": "Press Enter or a comma after each one. On a job, parts tagged with the vehicle's make or model are listed first.",
+    "tagsHintMarine": "Press Enter or a comma after each one. On a job, parts tagged with the vessel's make or model are listed first.",
+    "removeTag": "Remove {tag}",
+    "tagTooLong": "A tag can be at most {max} characters. Shorten it before saving."
   },
   "openMenu": "Open menu",
   "history": {
@@ -157,5 +163,11 @@
     "inStock": "{quantity} in stock",
     "outOfStock": "Out of stock",
     "onBackorder": "On backorder ({count})"
+  },
+  "tags": {
+    "filterPlaceholder": "Tag",
+    "all": "All Tags",
+    "filterBy": "Show parts tagged {tag}",
+    "more": "+{count} more"
   }
 }

+ 2 - 1
messages/en/service.json

@@ -147,7 +147,8 @@
     "deleteRow": "Delete row",
     "outOfStock": "Out of stock",
     "onBackorder": "On backorder ({count})",
-    "nameMissingHint": "Rows without a part name are not saved."
+    "nameMissingHint": "Rows without a part name are not saved.",
+    "fitsVehicleFirst": "Parts tagged for {vehicle} are listed first."
   },
   "labor": {
     "title": "Labor & Services",

+ 13 - 1
messages/es/inventory.json

@@ -129,7 +129,13 @@
     "descriptionPlaceholder": "Descripción de la pieza...",
     "cancel": "Cancelar",
     "saveChanges": "Guardar cambios",
-    "addPart": "Añadir pieza"
+    "addPart": "Añadir pieza",
+    "tags": "Etiquetas",
+    "tagsPlaceholder": "Marcas, modelos o lo que ayude a encontrarla",
+    "tagsHint": "Pulsa Intro o una coma después de cada una. En un trabajo, las piezas etiquetadas con la marca o el modelo del vehículo aparecen primero.",
+    "tagsHintMarine": "Pulsa Intro o una coma después de cada una. En un trabajo, las piezas etiquetadas con la marca o el modelo de la embarcación aparecen primero.",
+    "removeTag": "Quitar {tag}",
+    "tagTooLong": "Una etiqueta puede tener como máximo {max} caracteres. Acórtala antes de guardar."
   },
   "openMenu": "Abrir menú",
   "history": {
@@ -157,5 +163,11 @@
     "inStock": "{quantity} en stock",
     "outOfStock": "Agotado",
     "onBackorder": "En pedido pendiente ({count})"
+  },
+  "tags": {
+    "filterPlaceholder": "Etiqueta",
+    "all": "Todas las etiquetas",
+    "filterBy": "Ver piezas con la etiqueta {tag}",
+    "more": "+{count} más"
   }
 }

+ 2 - 1
messages/es/service.json

@@ -147,7 +147,8 @@
     "deleteRow": "Eliminar fila",
     "outOfStock": "Agotado",
     "onBackorder": "En pedido pendiente ({count})",
-    "nameMissingHint": "Las filas sin nombre de pieza no se guardan."
+    "nameMissingHint": "Las filas sin nombre de pieza no se guardan.",
+    "fitsVehicleFirst": "Las piezas etiquetadas para {vehicle} aparecen primero."
   },
   "labor": {
     "title": "Mano de Obra",

+ 13 - 1
messages/fr/inventory.json

@@ -129,7 +129,13 @@
     "descriptionPlaceholder": "Description de la pièce...",
     "cancel": "Annuler",
     "saveChanges": "Enregistrer les modifications",
-    "addPart": "Ajouter une pièce"
+    "addPart": "Ajouter une pièce",
+    "tags": "Étiquettes",
+    "tagsPlaceholder": "Marques, modèles ou tout ce qui aide à la retrouver",
+    "tagsHint": "Appuyez sur Entrée ou une virgule après chaque étiquette. Sur une intervention, les pièces étiquetées avec la marque ou le modèle du véhicule apparaissent en premier.",
+    "tagsHintMarine": "Appuyez sur Entrée ou une virgule après chaque étiquette. Sur une intervention, les pièces étiquetées avec la marque ou le modèle du bateau apparaissent en premier.",
+    "removeTag": "Retirer {tag}",
+    "tagTooLong": "Une étiquette peut contenir au plus {max} caractères. Raccourcissez-la avant d'enregistrer."
   },
   "openMenu": "Ouvrir le menu",
   "history": {
@@ -157,5 +163,11 @@
     "inStock": "{quantity} en stock",
     "outOfStock": "En rupture de stock",
     "onBackorder": "En réapprovisionnement ({count})"
+  },
+  "tags": {
+    "filterPlaceholder": "Étiquette",
+    "all": "Toutes les étiquettes",
+    "filterBy": "Afficher les pièces étiquetées {tag}",
+    "more": "+{count} de plus"
   }
 }

+ 2 - 1
messages/fr/service.json

@@ -147,7 +147,8 @@
     "deleteRow": "Supprimer la ligne",
     "outOfStock": "En rupture de stock",
     "onBackorder": "En réapprovisionnement ({count})",
-    "nameMissingHint": "Les lignes sans nom de pièce ne sont pas enregistrées."
+    "nameMissingHint": "Les lignes sans nom de pièce ne sont pas enregistrées.",
+    "fitsVehicleFirst": "Les pièces étiquetées pour {vehicle} apparaissent en premier."
   },
   "labor": {
     "title": "Main-d'œuvre",

+ 13 - 1
messages/it/inventory.json

@@ -129,7 +129,13 @@
     "descriptionPlaceholder": "Descrizione del ricambio...",
     "cancel": "Annulla",
     "saveChanges": "Salva modifiche",
-    "addPart": "Aggiungi ricambio"
+    "addPart": "Aggiungi ricambio",
+    "tags": "Tag",
+    "tagsPlaceholder": "Marche, modelli o altro per trovarlo",
+    "tagsHint": "Premi Invio o una virgola dopo ogni tag. In un lavoro, i ricambi con la marca o il modello del veicolo compaiono per primi.",
+    "tagsHintMarine": "Premi Invio o una virgola dopo ogni tag. In un lavoro, i ricambi con la marca o il modello dell'imbarcazione compaiono per primi.",
+    "removeTag": "Rimuovi {tag}",
+    "tagTooLong": "Un tag può avere al massimo {max} caratteri. Accorcialo prima di salvare."
   },
   "openMenu": "Apri menu",
   "history": {
@@ -157,5 +163,11 @@
     "inStock": "{quantity} in magazzino",
     "outOfStock": "Esaurito",
     "onBackorder": "In arretrato ({count})"
+  },
+  "tags": {
+    "filterPlaceholder": "Tag",
+    "all": "Tutti i tag",
+    "filterBy": "Mostra i ricambi con il tag {tag}",
+    "more": "+{count} altri"
   }
 }

+ 2 - 1
messages/it/service.json

@@ -147,7 +147,8 @@
     "deleteRow": "Elimina riga",
     "outOfStock": "Esaurito",
     "onBackorder": "In arretrato ({count})",
-    "nameMissingHint": "Le righe senza nome del ricambio non vengono salvate."
+    "nameMissingHint": "Le righe senza nome del ricambio non vengono salvate.",
+    "fitsVehicleFirst": "I ricambi con tag per {vehicle} compaiono per primi."
   },
   "labor": {
     "title": "Manodopera",

+ 13 - 1
messages/lt/inventory.json

@@ -129,7 +129,13 @@
     "descriptionPlaceholder": "Dalies aprašymas...",
     "cancel": "Atšaukti",
     "saveChanges": "Išsaugoti pakeitimus",
-    "addPart": "Pridėti dalį"
+    "addPart": "Pridėti dalį",
+    "tags": "Žymos",
+    "tagsPlaceholder": "Markės, modeliai ar kita, pagal ką ją rasti",
+    "tagsHint": "Po kiekvienos žymos paspauskite Enter arba kablelį. Darbe pirmiausia rodomos dalys, pažymėtos transporto priemonės marke ar modeliu.",
+    "tagsHintMarine": "Po kiekvienos žymos paspauskite Enter arba kablelį. Darbe pirmiausia rodomos dalys, pažymėtos laivo marke ar modeliu.",
+    "removeTag": "Pašalinti {tag}",
+    "tagTooLong": "Žyma gali būti ne ilgesnė nei {max} simbolių. Sutrumpinkite ją prieš išsaugodami."
   },
   "openMenu": "Atidaryti meniu",
   "history": {
@@ -157,5 +163,11 @@
     "inStock": "{quantity} sandėlyje",
     "outOfStock": "Nėra sandėlyje",
     "onBackorder": "Užsakoma ({count})"
+  },
+  "tags": {
+    "filterPlaceholder": "Žyma",
+    "all": "Visos žymos",
+    "filterBy": "Rodyti dalis su žyma {tag}",
+    "more": "dar {count}"
   }
 }

+ 2 - 1
messages/lt/service.json

@@ -147,7 +147,8 @@
     "deleteRow": "Ištrinti eilutę",
     "outOfStock": "Nėra sandėlyje",
     "onBackorder": "Užsakoma ({count})",
-    "nameMissingHint": "Eilutės be dalies pavadinimo neišsaugomos."
+    "nameMissingHint": "Eilutės be dalies pavadinimo neišsaugomos.",
+    "fitsVehicleFirst": "Dalys su žymomis {vehicle} rodomos pirmiausia."
   },
   "labor": {
     "title": "Darbai ir paslaugos",

+ 13 - 1
messages/nb/inventory.json

@@ -129,7 +129,13 @@
     "descriptionPlaceholder": "Delbeskrivelse...",
     "cancel": "Avbryt",
     "saveChanges": "Lagre endringer",
-    "addPart": "Legg til del"
+    "addPart": "Legg til del",
+    "tags": "Stikkord",
+    "tagsPlaceholder": "Merker, modeller eller annet du vil finne den på",
+    "tagsHint": "Trykk Enter eller komma etter hvert stikkord. På en arbeidsordre vises deler med kjøretøyets merke eller modell først.",
+    "tagsHintMarine": "Trykk Enter eller komma etter hvert stikkord. På en arbeidsordre vises deler med båtens merke eller modell først.",
+    "removeTag": "Fjern {tag}",
+    "tagTooLong": "Et stikkord kan være på høyst {max} tegn. Gjør det kortere før du lagrer."
   },
   "openMenu": "Åpne meny",
   "history": {
@@ -157,5 +163,11 @@
     "inStock": "{quantity} på lager",
     "outOfStock": "Utsolgt",
     "onBackorder": "På restordre ({count})"
+  },
+  "tags": {
+    "filterPlaceholder": "Stikkord",
+    "all": "Alle stikkord",
+    "filterBy": "Vis deler med stikkordet {tag}",
+    "more": "+{count} til"
   }
 }

+ 2 - 1
messages/nb/service.json

@@ -147,7 +147,8 @@
     "deleteRow": "Slett rad",
     "outOfStock": "Utsolgt",
     "onBackorder": "På restordre ({count})",
-    "nameMissingHint": "Rader uten delenavn lagres ikke."
+    "nameMissingHint": "Rader uten delenavn lagres ikke.",
+    "fitsVehicleFirst": "Deler med stikkord for {vehicle} vises først."
   },
   "labor": {
     "title": "Arbeid",

+ 13 - 1
messages/nl/inventory.json

@@ -129,7 +129,13 @@
     "descriptionPlaceholder": "Beschrijving van het onderdeel...",
     "cancel": "Annuleren",
     "saveChanges": "Wijzigingen opslaan",
-    "addPart": "Onderdeel toevoegen"
+    "addPart": "Onderdeel toevoegen",
+    "tags": "Tags",
+    "tagsPlaceholder": "Merken, modellen of iets anders om het op te vinden",
+    "tagsHint": "Druk na elke tag op Enter of een komma. Bij een werkorder staan onderdelen met het merk of model van het voertuig bovenaan.",
+    "tagsHintMarine": "Druk na elke tag op Enter of een komma. Bij een werkorder staan onderdelen met het merk of model van het vaartuig bovenaan.",
+    "removeTag": "{tag} verwijderen",
+    "tagTooLong": "Een tag mag maximaal {max} tekens lang zijn. Maak hem korter voordat je opslaat."
   },
   "openMenu": "Menu openen",
   "history": {
@@ -157,5 +163,11 @@
     "inStock": "{quantity} in voorraad",
     "outOfStock": "Niet op voorraad",
     "onBackorder": "In nabestelling ({count})"
+  },
+  "tags": {
+    "filterPlaceholder": "Tag",
+    "all": "Alle tags",
+    "filterBy": "Onderdelen met tag {tag} tonen",
+    "more": "+{count} meer"
   }
 }

+ 2 - 1
messages/nl/service.json

@@ -147,7 +147,8 @@
     "deleteRow": "Rij verwijderen",
     "outOfStock": "Niet op voorraad",
     "onBackorder": "In nabestelling ({count})",
-    "nameMissingHint": "Rijen zonder onderdeelnaam worden niet opgeslagen."
+    "nameMissingHint": "Rijen zonder onderdeelnaam worden niet opgeslagen.",
+    "fitsVehicleFirst": "Onderdelen met tags voor {vehicle} staan bovenaan."
   },
   "labor": {
     "title": "Arbeidskosten",

+ 13 - 1
messages/pl/inventory.json

@@ -129,7 +129,13 @@
     "descriptionPlaceholder": "Opis części...",
     "cancel": "Anuluj",
     "saveChanges": "Zapisz zmiany",
-    "addPart": "Dodaj część"
+    "addPart": "Dodaj część",
+    "tags": "Tagi",
+    "tagsPlaceholder": "Marki, modele lub cokolwiek, po czym ją znaleźć",
+    "tagsHint": "Po każdym tagu naciśnij Enter lub przecinek. W zleceniu najpierw widać części oznaczone marką lub modelem pojazdu.",
+    "tagsHintMarine": "Po każdym tagu naciśnij Enter lub przecinek. W zleceniu najpierw widać części oznaczone marką lub modelem jednostki pływającej.",
+    "removeTag": "Usuń {tag}",
+    "tagTooLong": "Tag może mieć najwyżej {max} znaków. Skróć go przed zapisaniem."
   },
   "openMenu": "Otwórz menu",
   "history": {
@@ -157,5 +163,11 @@
     "inStock": "{quantity} w magazynie",
     "outOfStock": "Brak w magazynie",
     "onBackorder": "W zamówieniu ({count})"
+  },
+  "tags": {
+    "filterPlaceholder": "Tag",
+    "all": "Wszystkie tagi",
+    "filterBy": "Pokaż części z tagiem {tag}",
+    "more": "+{count} więcej"
   }
 }

+ 2 - 1
messages/pl/service.json

@@ -147,7 +147,8 @@
     "deleteRow": "Usuń wiersz",
     "outOfStock": "Brak w magazynie",
     "onBackorder": "W zamówieniu ({count})",
-    "nameMissingHint": "Wiersze bez nazwy części nie są zapisywane."
+    "nameMissingHint": "Wiersze bez nazwy części nie są zapisywane.",
+    "fitsVehicleFirst": "Części z tagami dla {vehicle} są na początku listy."
   },
   "labor": {
     "title": "Praca",

+ 13 - 1
messages/pt-BR/inventory.json

@@ -129,7 +129,13 @@
     "descriptionPlaceholder": "Descrição da peça...",
     "cancel": "Cancelar",
     "saveChanges": "Salvar alterações",
-    "addPart": "Adicionar peça"
+    "addPart": "Adicionar peça",
+    "tags": "Tags",
+    "tagsPlaceholder": "Marcas, modelos ou o que ajudar a encontrá-la",
+    "tagsHint": "Pressione Enter ou vírgula após cada tag. Em uma ordem de serviço, as peças com a marca ou o modelo do veículo aparecem primeiro.",
+    "tagsHintMarine": "Pressione Enter ou vírgula após cada tag. Em uma ordem de serviço, as peças com a marca ou o modelo da embarcação aparecem primeiro.",
+    "removeTag": "Remover {tag}",
+    "tagTooLong": "Uma tag pode ter no máximo {max} caracteres. Encurte-a antes de salvar."
   },
   "openMenu": "Abrir menu",
   "history": {
@@ -157,5 +163,11 @@
     "inStock": "{quantity} em estoque",
     "outOfStock": "Fora de estoque",
     "onBackorder": "Sob encomenda ({count})"
+  },
+  "tags": {
+    "filterPlaceholder": "Tag",
+    "all": "Todas as tags",
+    "filterBy": "Mostrar peças com a tag {tag}",
+    "more": "+{count} mais"
   }
 }

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

@@ -147,7 +147,8 @@
     "deleteRow": "Excluir linha",
     "outOfStock": "Fora de estoque",
     "onBackorder": "Sob encomenda ({count})",
-    "nameMissingHint": "Linhas sem nome da peça não são salvas."
+    "nameMissingHint": "Linhas sem nome da peça não são salvas.",
+    "fitsVehicleFirst": "As peças com tags de {vehicle} aparecem primeiro."
   },
   "labor": {
     "title": "Mão de Obra e Serviços",

+ 13 - 1
messages/pt-PT/inventory.json

@@ -129,7 +129,13 @@
     "descriptionPlaceholder": "Descrição da peça...",
     "cancel": "Cancelar",
     "saveChanges": "Guardar alterações",
-    "addPart": "Adicionar peça"
+    "addPart": "Adicionar peça",
+    "tags": "Etiquetas",
+    "tagsPlaceholder": "Marcas, modelos ou o que ajudar a encontrá-la",
+    "tagsHint": "Prima Enter ou uma vírgula após cada etiqueta. Numa ordem de reparação, as peças com a marca ou o modelo do veículo aparecem primeiro.",
+    "tagsHintMarine": "Prima Enter ou uma vírgula após cada etiqueta. Numa ordem de reparação, as peças com a marca ou o modelo da embarcação aparecem primeiro.",
+    "removeTag": "Remover {tag}",
+    "tagTooLong": "Uma etiqueta pode ter no máximo {max} caracteres. Encurte-a antes de guardar."
   },
   "openMenu": "Abrir menu",
   "history": {
@@ -157,5 +163,11 @@
     "inStock": "{quantity} em stock",
     "outOfStock": "Sem stock",
     "onBackorder": "Em encomenda ({count})"
+  },
+  "tags": {
+    "filterPlaceholder": "Etiqueta",
+    "all": "Todas as etiquetas",
+    "filterBy": "Mostrar peças com a etiqueta {tag}",
+    "more": "+{count} mais"
   }
 }

+ 2 - 1
messages/pt-PT/service.json

@@ -147,7 +147,8 @@
     "deleteRow": "Eliminar linha",
     "outOfStock": "Sem stock",
     "onBackorder": "Em encomenda ({count})",
-    "nameMissingHint": "As linhas sem nome da peça não são guardadas."
+    "nameMissingHint": "As linhas sem nome da peça não são guardadas.",
+    "fitsVehicleFirst": "As peças com etiquetas de {vehicle} aparecem primeiro."
   },
   "labor": {
     "title": "Mão de obra e serviços",

+ 13 - 1
messages/ru/inventory.json

@@ -129,7 +129,13 @@
     "descriptionPlaceholder": "Описание запчасти...",
     "cancel": "Отмена",
     "saveChanges": "Сохранить изменения",
-    "addPart": "Добавить запчасть"
+    "addPart": "Добавить запчасть",
+    "tags": "Теги",
+    "tagsPlaceholder": "Марки, модели или что угодно для поиска",
+    "tagsHint": "Нажимайте Enter или запятую после каждого тега. В заказ-наряде запчасти с маркой или моделью автомобиля показываются первыми.",
+    "tagsHintMarine": "Нажимайте Enter или запятую после каждого тега. В заказ-наряде запчасти с маркой или моделью судна показываются первыми.",
+    "removeTag": "Удалить {tag}",
+    "tagTooLong": "Тег может содержать не более {max} символов. Сократите его перед сохранением."
   },
   "openMenu": "Открыть меню",
   "history": {
@@ -157,5 +163,11 @@
     "inStock": "{quantity} в наличии",
     "outOfStock": "Нет в наличии",
     "onBackorder": "Под заказ ({count})"
+  },
+  "tags": {
+    "filterPlaceholder": "Тег",
+    "all": "Все теги",
+    "filterBy": "Показать запчасти с тегом {tag}",
+    "more": "ещё {count}"
   }
 }

+ 2 - 1
messages/ru/service.json

@@ -147,7 +147,8 @@
     "deleteRow": "Удалить строку",
     "outOfStock": "Нет в наличии",
     "onBackorder": "Под заказ ({count})",
-    "nameMissingHint": "Строки без названия детали не сохраняются."
+    "nameMissingHint": "Строки без названия детали не сохраняются.",
+    "fitsVehicleFirst": "Запчасти с тегами {vehicle} показаны первыми."
   },
   "labor": {
     "title": "Работа",

+ 13 - 1
messages/tr/inventory.json

@@ -129,7 +129,13 @@
     "descriptionPlaceholder": "Parça açıklaması...",
     "cancel": "İptal",
     "saveChanges": "Değişiklikleri kaydet",
-    "addPart": "Parça ekle"
+    "addPart": "Parça ekle",
+    "tags": "Etiketler",
+    "tagsPlaceholder": "Markalar, modeller veya bulmaya yarayan her şey",
+    "tagsHint": "Her etiketten sonra Enter'a veya virgüle basın. Bir işte, aracın markası veya modeliyle etiketlenmiş parçalar önce listelenir.",
+    "tagsHintMarine": "Her etiketten sonra Enter'a veya virgüle basın. Bir işte, teknenin markası veya modeliyle etiketlenmiş parçalar önce listelenir.",
+    "removeTag": "{tag} kaldır",
+    "tagTooLong": "Bir etiket en fazla {max} karakter olabilir. Kaydetmeden önce kısaltın."
   },
   "openMenu": "Menüyü aç",
   "history": {
@@ -157,5 +163,11 @@
     "inStock": "{quantity} stokta",
     "outOfStock": "Stokta yok",
     "onBackorder": "Ön siparişte ({count})"
+  },
+  "tags": {
+    "filterPlaceholder": "Etiket",
+    "all": "Tüm etiketler",
+    "filterBy": "{tag} etiketli parçaları göster",
+    "more": "+{count} daha"
   }
 }

+ 2 - 1
messages/tr/service.json

@@ -147,7 +147,8 @@
     "deleteRow": "Satırı sil",
     "outOfStock": "Stokta yok",
     "onBackorder": "Ön siparişte ({count})",
-    "nameMissingHint": "Parça adı olmayan satırlar kaydedilmez."
+    "nameMissingHint": "Parça adı olmayan satırlar kaydedilmez.",
+    "fitsVehicleFirst": "{vehicle} için etiketlenmiş parçalar önce listelenir."
   },
   "labor": {
     "title": "İşçilik",

+ 2 - 0
prisma/migrations/20261109133354_inventory_part_tags/migration.sql

@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "inventory_parts" ADD COLUMN     "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];

+ 10 - 6
prisma/schema/inventory.prisma

@@ -51,27 +51,31 @@ model LaborPresetPart {
 }
 
 model InventoryPart {
-  id            String  @id @default(cuid())
+  id            String   @id @default(cuid())
   partNumber    String?
   barcode       String?
   name          String
   description   String?
   category      String?
-  quantity      Float   @default(0)
-  minQuantity   Float   @default(0)
+  quantity      Float    @default(0)
+  minQuantity   Float    @default(0)
   /// Unit of measure for `quantity` (e.g. "pcs", "l", "kg"). Free text,
   /// snapshotted onto part lines at pick time so invoices keep the unit the
   /// part had when it was billed.
   unit          String?
-  unitCost      Float   @default(0)
-  sellPrice     Float   @default(0)
+  unitCost      Float    @default(0)
+  sellPrice     Float    @default(0)
   supplier      String?
   supplierPhone String?
   supplierEmail String?
   supplierUrl   String?
   imageUrl      String?
   location      String?
-  isArchived    Boolean @default(false)
+  isArchived    Boolean  @default(false)
+  /// Free-text labels the workshop gives a part, such as the makes and models
+  /// it fits ("Toyota", "Hilux 2015-2020"). Searched wherever parts are, and
+  /// matched against the job's vehicle to list fitting parts first.
+  tags          String[] @default([])
 
   /// Set when a low-stock alert has been sent for the current dip below
   /// `minQuantity`, and cleared once stock climbs back above it. This

+ 177 - 0
src/__tests__/components/tag-input.test.tsx

@@ -0,0 +1,177 @@
+/**
+ * The tag box on the part form, used the way a person types into it.
+ *
+ * What matters is that what was typed ends up as tags, never twice, and that
+ * Enter in the box adds a tag instead of saving the form half filled in.
+ */
+
+import { describe, it, expect, vi } from 'vitest'
+import { useState } from 'react'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { TagInput } from '@/components/tag-input'
+
+function Harness({
+  initial = [],
+  suggestions = [],
+  onSubmit = vi.fn(),
+  maxTags,
+}: {
+  initial?: string[]
+  suggestions?: string[]
+  onSubmit?: () => void
+  maxTags?: number
+}) {
+  const [tags, setTags] = useState<string[]>(initial)
+  return (
+    <form
+      onSubmit={(e) => {
+        e.preventDefault()
+        onSubmit()
+      }}
+    >
+      <label htmlFor="tags">Tags</label>
+      <TagInput
+        id="tags"
+        value={tags}
+        onChange={setTags}
+        suggestions={suggestions}
+        maxTags={maxTags}
+        // As the part form sets it.
+        maxLength={40}
+        tooLongMessage="A tag can be at most 40 characters."
+        removeLabel={(tag) => `Remove ${tag}`}
+      />
+      <output data-testid="tags">{tags.join('|')}</output>
+      <button type="submit">Save</button>
+    </form>
+  )
+}
+
+const tagsShown = () => screen.getByTestId('tags').textContent
+
+describe('TagInput', () => {
+  it('adds a tag on Enter without submitting the form', async () => {
+    const onSubmit = vi.fn()
+    render(<Harness onSubmit={onSubmit} />)
+    await userEvent.type(screen.getByLabelText('Tags'), 'Toyota{Enter}')
+    expect(tagsShown()).toBe('Toyota')
+    expect(onSubmit).not.toHaveBeenCalled()
+    expect(screen.getByLabelText('Tags')).toHaveValue('')
+  })
+
+  it('adds a tag at each comma, and a pasted list all at once', async () => {
+    render(<Harness />)
+    const box = screen.getByLabelText('Tags')
+    await userEvent.type(box, 'Toyota,Hilux,')
+    expect(tagsShown()).toBe('Toyota|Hilux')
+
+    await userEvent.click(box)
+    await userEvent.paste('Corolla, Yaris, Hia')
+    expect(tagsShown()).toBe('Toyota|Hilux|Corolla|Yaris')
+    // The text after the last comma is still being typed.
+    expect(box).toHaveValue('Hia')
+  })
+
+  it('keeps what was typed when the box is left', async () => {
+    render(<Harness />)
+    await userEvent.type(screen.getByLabelText('Tags'), 'Ranger')
+    await userEvent.click(document.body)
+    expect(tagsShown()).toBe('Ranger')
+  })
+
+  it('does not add the same tag twice in another case', async () => {
+    render(<Harness initial={['Toyota']} />)
+    await userEvent.type(screen.getByLabelText('Tags'), 'toyota{Enter}')
+    expect(tagsShown()).toBe('Toyota')
+  })
+
+  it('takes the last tag off with Backspace in an empty box, or one by its button', async () => {
+    render(<Harness initial={['Toyota', 'Hilux', 'Diesel']} />)
+    await userEvent.type(screen.getByLabelText('Tags'), '{Backspace}')
+    expect(tagsShown()).toBe('Toyota|Hilux')
+    await userEvent.click(screen.getByRole('button', { name: 'Remove Toyota' }))
+    expect(tagsShown()).toBe('Hilux')
+  })
+
+  it('lets Enter in an empty box submit the form as usual', async () => {
+    const onSubmit = vi.fn()
+    render(<Harness initial={['Toyota']} onSubmit={onSubmit} />)
+    await userEvent.type(screen.getByLabelText('Tags'), '{Enter}')
+    expect(onSubmit).toHaveBeenCalledTimes(1)
+  })
+
+  it('suggests the workshop’s tags while typing and adds the one picked', async () => {
+    render(<Harness initial={['Toyota']} suggestions={['Hilux', 'Toyota', 'Hiace', 'Ranger']} />)
+    await userEvent.type(screen.getByLabelText('Tags'), 'hi')
+
+    const options = screen.getAllByRole('option').map((o) => o.textContent)
+    // Narrowed by what was typed, and never a tag the part already has.
+    expect(options).toEqual(['Hilux', 'Hiace'])
+
+    await userEvent.click(screen.getByRole('option', { name: 'Hiace' }))
+    expect(tagsShown()).toBe('Toyota|Hiace')
+  })
+
+  it('picks a suggestion with the arrow keys and Enter', async () => {
+    render(<Harness suggestions={['Hilux', 'Hiace']} />)
+    await userEvent.type(screen.getByLabelText('Tags'), 'hi{ArrowDown}{ArrowDown}{Enter}')
+    expect(tagsShown()).toBe('Hiace')
+  })
+
+  it('stops taking tags at the limit', async () => {
+    render(<Harness initial={['A1', 'A2']} maxTags={3} />)
+    await userEvent.type(screen.getByLabelText('Tags'), 'A3,A4,')
+    expect(tagsShown()).toBe('A1|A2|A3')
+    expect(screen.queryByLabelText('Tags')).toBeNull()
+  })
+
+  it('keeps every tag of a pasted list longer than one tag may be', async () => {
+    render(<Harness />)
+    const box = screen.getByLabelText('Tags')
+    await userEvent.click(box)
+    // 43 characters in all, each tag well inside the 40 a tag may have.
+    await userEvent.paste('Toyota, Hilux, Corolla, Land Cruiser, Hiace')
+    expect(tagsShown()).toBe('Toyota|Hilux|Corolla|Land Cruiser')
+    // The last one, with no comma after it, is whole in the box.
+    expect(box).toHaveValue('Hiace')
+    await userEvent.keyboard('{Enter}')
+    expect(tagsShown()).toBe('Toyota|Hilux|Corolla|Land Cruiser|Hiace')
+  })
+
+  it('keeps a tag that is too long in the box, says why, and adds the rest', async () => {
+    const long = 'x'.repeat(41)
+    render(<Harness />)
+    const box = screen.getByLabelText('Tags')
+    await userEvent.click(box)
+    await userEvent.paste(`Toyota, ${long}, Hilux`)
+    await userEvent.keyboard('{Enter}')
+
+    expect(tagsShown()).toBe('Toyota|Hilux')
+    expect(box).toHaveValue(long)
+    const message = screen.getByText('A tag can be at most 40 characters.')
+    expect(box).toHaveAttribute('aria-invalid', 'true')
+    expect(box).toHaveAttribute('aria-describedby', message.id)
+
+    // Shortened, it is added and the message goes.
+    await userEvent.clear(box)
+    await userEvent.type(box, 'Land Cruiser{Enter}')
+    expect(tagsShown()).toBe('Toyota|Hilux|Land Cruiser')
+    expect(screen.queryByText('A tag can be at most 40 characters.')).toBeNull()
+    expect(box).not.toHaveAttribute('aria-invalid')
+  })
+
+  it('tells assistive technology which suggestion Enter will pick', async () => {
+    render(<Harness suggestions={['Hilux', 'Hiace']} />)
+    const box = screen.getByLabelText('Tags')
+    await userEvent.type(box, 'hi')
+    const list = screen.getByRole('listbox')
+    expect(box).toHaveAttribute('aria-controls', list.id)
+    expect(box).not.toHaveAttribute('aria-activedescendant')
+
+    await userEvent.keyboard('{ArrowDown}{ArrowDown}')
+    const hiace = screen.getByRole('option', { name: 'Hiace' })
+    expect(box).toHaveAttribute('aria-activedescendant', hiace.id)
+    expect(hiace).toHaveAttribute('aria-selected', 'true')
+  })
+})

+ 11 - 1
src/__tests__/features/ai/sql-guard.test.ts

@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
 
 vi.mock('@/lib/db', () => ({ db: {} }))
 
-import { validateSql } from '@/features/ai/tools/workshop-tools'
+import { DB_SCHEMA, validateSql } from '@/features/ai/tools/workshop-tools'
 
 /**
  * The AI writes its own SQL, and the allow-list of tables is what keeps it
@@ -24,6 +24,16 @@ describe('validateSql: allowed shapes', () => {
     ok('SELECT id, make, model FROM vehicles WHERE year > 2015 LIMIT 10')
   })
 
+  it('accepts the tag lookup the schema tells the model to write for inventory parts', () => {
+    const hint = DB_SCHEMA.match(/^inventory_parts .*match with (.+)$/m)?.[1]
+    expect(hint).toBeTruthy()
+    ok(`SELECT id, name FROM inventory_parts WHERE ${hint}`)
+    // A function as a row source is refused, so the hint must not be one.
+    rejected(
+      "SELECT id FROM inventory_parts WHERE EXISTS (SELECT 1 FROM unnest(tags) t WHERE t ILIKE '%hilux%')"
+    )
+  })
+
   it('accepts a JOIN between two allowed tables', () => {
     ok(
       'SELECT v.id, c.name FROM vehicles v LEFT JOIN customers c ON c.id = v."customerId" ORDER BY c.name'

+ 112 - 0
src/__tests__/features/inventory/inventory-picker-tags.test.tsx

@@ -0,0 +1,112 @@
+/**
+ * The stock picker on a work order and a quote, with tagged parts.
+ *
+ * The point of tagging a part with the makes and models it fits is that the
+ * right one is at the top when a job for that vehicle is open, and that typing
+ * a model finds it.
+ */
+
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { InventoryPickerDialog } from '@/features/vehicles/Components/service-edit/InventoryPickerDialog'
+import type { InventoryPartOption } from '@/features/vehicles/Components/service-edit/form-types'
+
+vi.mock('next-intl', () => ({
+  useTranslations: () => (key: string, values?: Record<string, unknown>) =>
+    values ? `${key}:${JSON.stringify(values)}` : key,
+}))
+
+vi.mock('@/components/currency-settings-context', () => ({
+  useFormatCurrency: () => (n: number) => `$${n.toFixed(2)}`,
+}))
+
+const part = (over: Partial<InventoryPartOption> & { id: string; name: string }) => ({
+  partNumber: null,
+  barcode: null,
+  description: null,
+  unit: null,
+  unitCost: 10,
+  sellPrice: 20,
+  quantity: 3,
+  category: null,
+  tags: [],
+  ...over,
+})
+
+// Alphabetical, the order the server sends them in.
+const PARTS: InventoryPartOption[] = [
+  part({ id: 'air', name: 'Air filter' }),
+  part({ id: 'cabin', name: 'Cabin filter', tags: ['Ford Ranger'] }),
+  part({ id: 'fuel', name: 'Fuel filter', tags: ['Toyota', 'Hilux 2015-2020'] }),
+  part({ id: 'oil', name: 'Oil filter', tags: ['Toyota'] }),
+]
+
+function open(vehicle?: { make: string; model: string } | null) {
+  const onSelectPart = vi.fn()
+  render(
+    <InventoryPickerDialog
+      open
+      onOpenChange={vi.fn()}
+      inventoryParts={PARTS}
+      currencyCode="AUD"
+      onSelectPart={onSelectPart}
+      vehicle={vehicle}
+    />
+  )
+  return { onSelectPart }
+}
+
+/** The part names in the order the picker lists them. */
+function listed(): string[] {
+  const dialog = screen.getByRole('dialog')
+  return within(dialog)
+    .getAllByRole('button')
+    .map((b) => b.querySelector('.font-medium')?.textContent ?? '')
+    .filter((name) => name.endsWith('filter'))
+}
+
+describe('InventoryPickerDialog with tags', () => {
+  it('lists parts tagged for the job’s model first, then its make, then the rest', () => {
+    open({ make: 'Toyota', model: 'Hilux' })
+    expect(listed()).toEqual(['Fuel filter', 'Oil filter', 'Air filter', 'Cabin filter'])
+    expect(screen.getByText('fitsVehicleFirst:{"vehicle":"Toyota Hilux"}')).toBeInTheDocument()
+  })
+
+  it('keeps the plain order, and says nothing, when no part fits the vehicle', () => {
+    open({ make: 'Volkswagen', model: 'Golf' })
+    expect(listed()).toEqual(['Air filter', 'Cabin filter', 'Fuel filter', 'Oil filter'])
+    expect(screen.queryByText(/fitsVehicleFirst/)).toBeNull()
+  })
+
+  it('keeps the plain order for a counter sale with no vehicle', () => {
+    open(null)
+    expect(listed()).toEqual(['Air filter', 'Cabin filter', 'Fuel filter', 'Oil filter'])
+  })
+
+  it('finds a part by a tag, and by a tag and its name together', async () => {
+    open(null)
+    const search = screen.getByPlaceholderText('searchInventory')
+    await userEvent.type(search, 'ranger')
+    expect(listed()).toEqual(['Cabin filter'])
+
+    await userEvent.clear(search)
+    await userEvent.type(search, 'filter hilux')
+    expect(listed()).toEqual(['Fuel filter'])
+  })
+
+  it('shows a part’s tags on its row', () => {
+    open({ make: 'Toyota', model: 'Hilux' })
+    const fuelRow = screen.getByText('Fuel filter').closest('button')!
+    expect(within(fuelRow).getByText('Hilux 2015-2020')).toBeInTheDocument()
+    expect(within(fuelRow).getByText('Toyota')).toBeInTheDocument()
+  })
+
+  it('adds the picked part to the job linked to stock', async () => {
+    const { onSelectPart } = open({ make: 'Toyota', model: 'Hilux' })
+    await userEvent.click(screen.getByText('Fuel filter'))
+    expect(onSelectPart).toHaveBeenCalledWith(
+      expect.objectContaining({ name: 'Fuel filter', inventoryPartId: 'fuel' })
+    )
+  })
+})

+ 27 - 0
src/__tests__/features/inventory/part-name-suggestions.test.tsx

@@ -227,6 +227,33 @@ describe('PartNameSuggestions', () => {
     expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'p3' }))
   })
 
+  it('also offers a part by its tag, after any name or number match', () => {
+    const parts: PartSuggestion[] = [
+      {
+        id: 't1',
+        name: 'Fuel filter',
+        partNumber: 'FF-23',
+        unitCost: 9,
+        sellPrice: 22,
+        quantity: 2,
+        tags: ['Toyota', 'Hilux 2015-2020'],
+      },
+      {
+        id: 't2',
+        name: 'Hilux wiper blade',
+        partNumber: 'WB-1',
+        unitCost: 5,
+        sellPrice: 15,
+        quantity: 6,
+      },
+    ]
+    setup('hilux', { parts })
+    const rows = shown()
+    expect(rows).toHaveLength(2)
+    expect(rows[0]).toContain('Hilux wiper blade')
+    expect(rows[1]).toContain('Fuel filter')
+  })
+
   it('renders nothing when the inventory is empty', () => {
     render(<PartNameSuggestions query="brake" parts={[]} onSelect={vi.fn()} />)
     expect(shown()).toHaveLength(0)

+ 178 - 0
src/__tests__/features/inventory/part-tag-actions.test.ts

@@ -0,0 +1,178 @@
+/**
+ * Saving and searching part tags through the inventory actions.
+ *
+ * Two things here would lose a workshop's data quietly if they broke: a save
+ * from a page that does not send tags (one opened before tags existed, or a
+ * form that never loaded them) must leave the stored tags alone, and a list
+ * search must still find a part by its tag although Prisma cannot search
+ * inside a text array.
+ */
+
+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(), revalidateTag: vi.fn() }))
+vi.mock('@/features/inventory/Lib/onInventoryChanged', () => ({
+  onInventoryChanged: vi.fn(async () => undefined),
+}))
+vi.mock('@/lib/files/collect', () => ({ inventoryPartFileUrls: vi.fn(async () => []) }))
+vi.mock('@/lib/files/manager', () => ({
+  releaseFiles: vi.fn(async () => ({ removed: [], kept: [], skipped: [] })),
+  parseStoredFileUrl: vi.fn(() => null),
+}))
+vi.mock('@/lib/db', () => ({
+  db: {
+    $queryRaw: vi.fn(),
+    $transaction: vi.fn(async (steps: unknown) =>
+      Array.isArray(steps) ? Promise.all(steps) : steps
+    ),
+    user: { findUnique: vi.fn() },
+    auditLog: { create: vi.fn() },
+    appSetting: { findFirst: vi.fn() },
+    inventoryPart: {
+      create: vi.fn(),
+      findFirst: vi.fn(),
+      findMany: vi.fn(),
+      count: vi.fn(),
+      updateMany: vi.fn(),
+    },
+    storedImage: { createMany: vi.fn(), deleteMany: vi.fn() },
+  },
+}))
+
+import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
+import { db } from '@/lib/db'
+import {
+  createInventoryPart,
+  getInventoryPartsPaginated,
+  getInventoryTags,
+  updateInventoryPart,
+} from '@/features/inventory/Actions/inventoryActions'
+
+const ORG = 'org-a'
+
+beforeEach(() => {
+  vi.resetAllMocks()
+  vi.mocked(getCachedSession).mockResolvedValue({
+    user: { id: 'user-a', email: 'a@example.com' },
+  } as never)
+  vi.mocked(getCachedMembership).mockResolvedValue({
+    organizationId: ORG,
+    role: 'owner',
+    roleId: null,
+    customRole: null,
+  } as never)
+  vi.mocked(db.user.findUnique).mockResolvedValue({ isSuperAdmin: false } as never)
+  vi.mocked(db.inventoryPart.findMany).mockResolvedValue([])
+  vi.mocked(db.inventoryPart.count).mockResolvedValue(0)
+})
+
+/** The `data` the part was written with. */
+const createdWith = () => vi.mocked(db.inventoryPart.create).mock.calls[0][0].data
+// The last write is the part's own fields; an earlier one may be the gallery's
+// main image, which an update rewrites in its own step.
+const updatedWith = () => vi.mocked(db.inventoryPart.updateMany).mock.calls.at(-1)![0].data
+
+describe('saving tags', () => {
+  it('stores a new part’s tags cleaned, without a repeat in another case', async () => {
+    vi.mocked(db.inventoryPart.create).mockResolvedValue({ id: 'p1', name: 'Fuel filter' } as never)
+
+    const result = await createInventoryPart({
+      name: 'Fuel filter',
+      tags: ['  Toyota ', 'toyota', 'Hilux   2015-2020', ''],
+    })
+
+    expect(result.success).toBe(true)
+    expect(createdWith().tags).toEqual(['Toyota', 'Hilux 2015-2020'])
+  })
+
+  it('gives a part created without tags none', async () => {
+    vi.mocked(db.inventoryPart.create).mockResolvedValue({ id: 'p1', name: 'Fuel filter' } as never)
+    await createInventoryPart({ name: 'Fuel filter' })
+    expect(createdWith().tags).toEqual([])
+  })
+
+  it('leaves the stored tags alone when an update does not send any', async () => {
+    vi.mocked(db.inventoryPart.findFirst).mockResolvedValue({ id: 'p1' } as never)
+    vi.mocked(db.inventoryPart.updateMany).mockResolvedValue({ count: 1 } as never)
+
+    await updateInventoryPart({ id: 'p1', name: 'Fuel filter (new)' })
+
+    expect(updatedWith().name).toBe('Fuel filter (new)')
+    expect(updatedWith().tags).toBeUndefined()
+  })
+
+  it('clears the tags when an update sends an empty list', async () => {
+    vi.mocked(db.inventoryPart.findFirst).mockResolvedValue({ id: 'p1' } as never)
+    vi.mocked(db.inventoryPart.updateMany).mockResolvedValue({ count: 1 } as never)
+
+    await updateInventoryPart({ id: 'p1', tags: [] })
+
+    expect(updatedWith().tags).toEqual([])
+  })
+
+  it('refuses a tag longer than the limit rather than cutting it', async () => {
+    const result = await createInventoryPart({ name: 'Fuel filter', tags: ['x'.repeat(41)] })
+    expect(result.success).toBe(false)
+    expect(db.inventoryPart.create).not.toHaveBeenCalled()
+  })
+})
+
+describe('searching and filtering by tag', () => {
+  it('finds parts whose tag holds a searched word, alongside the other fields', async () => {
+    // The SQL lookup answers which parts carry a tag with "hilux" in it.
+    vi.mocked(db.$queryRaw).mockResolvedValue([{ id: 'fuel' }])
+
+    await getInventoryPartsPaginated({ search: 'hilux' })
+
+    const where = vi.mocked(db.inventoryPart.findMany).mock.calls[0][0]!.where!
+    expect(where.organizationId).toBe(ORG)
+    expect(where.AND).toEqual([
+      {
+        OR: expect.arrayContaining([
+          { name: { contains: 'hilux', mode: 'insensitive' } },
+          { id: { in: ['fuel'] } },
+        ]),
+      },
+    ])
+  })
+
+  it('needs every searched word to match, each on its own', async () => {
+    vi.mocked(db.$queryRaw)
+      .mockResolvedValueOnce([])
+      .mockResolvedValueOnce([{ id: 'fuel' }])
+
+    await getInventoryPartsPaginated({ search: 'filter  hilux' })
+
+    const where = vi.mocked(db.inventoryPart.findMany).mock.calls[0][0]!.where!
+    expect(where.AND).toHaveLength(2)
+    expect(db.$queryRaw).toHaveBeenCalledTimes(2)
+  })
+
+  it('limits the list to the parts carrying the chosen tag', async () => {
+    vi.mocked(db.$queryRaw).mockResolvedValue([{ id: 'fuel' }, { id: 'oil' }])
+
+    await getInventoryPartsPaginated({ tag: 'Toyota' })
+
+    const where = vi.mocked(db.inventoryPart.findMany).mock.calls[0][0]!.where!
+    expect(where.AND).toEqual([{ id: { in: ['fuel', 'oil'] } }])
+  })
+
+  it('lists each of the workshop’s tags once for the filter', async () => {
+    vi.mocked(db.inventoryPart.findMany).mockResolvedValue([
+      { tags: ['Toyota', 'Hilux'] },
+      { tags: ['toyota'] },
+      { tags: ['Toyota', 'Ranger'] },
+    ] as never)
+
+    const result = await getInventoryTags()
+
+    expect(result.data).toEqual(['Hilux', 'Ranger', 'Toyota'])
+    expect(vi.mocked(db.inventoryPart.findMany).mock.calls[0][0]!.where).toEqual(
+      expect.objectContaining({ organizationId: ORG })
+    )
+  })
+})

+ 194 - 0
src/__tests__/features/inventory/part-tags.test.ts

@@ -0,0 +1,194 @@
+/**
+ * The rules behind part tags: what a saved list looks like, which searches
+ * find a part, and which tags say a part fits the vehicle on the job.
+ *
+ * The fit rules decide the order of the work order's parts picker, so a wrong
+ * answer here puts the wrong filter at the top of the list a technician picks
+ * from.
+ */
+
+import { describe, it, expect } from 'vitest'
+import {
+  distinctTags,
+  fittingTags,
+  normalizePartTags,
+  partMatchesQuery,
+  rankByVehicleFit,
+  splitTagInput,
+  tagFit,
+  vehicleFitScore,
+} from '@/features/inventory/Lib/partTags'
+
+describe('normalizePartTags', () => {
+  it('trims, collapses spaces and drops empty tags', () => {
+    expect(normalizePartTags(['  Toyota ', '', '   ', 'Hilux   2015-2020'])).toEqual([
+      'Toyota',
+      'Hilux 2015-2020',
+    ])
+  })
+
+  it('keeps one of two tags that differ only in case, the first typed', () => {
+    expect(normalizePartTags(['Toyota', 'toyota', 'TOYOTA', 'Hilux'])).toEqual(['Toyota', 'Hilux'])
+  })
+})
+
+describe('splitTagInput', () => {
+  it('turns a pasted list into tags at its commas', () => {
+    expect(splitTagInput('Toyota, Hilux ,Corolla,, ')).toEqual(['Toyota', 'Hilux', 'Corolla'])
+  })
+})
+
+describe('distinctTags', () => {
+  it('lists each tag once, sorted, in the spelling most parts use', () => {
+    expect(
+      distinctTags([
+        ['Toyota', 'Hilux'],
+        ['toyota', 'Corolla'],
+        ['Toyota'],
+        ['Mazda 3', 'Mazda 10'],
+      ])
+    ).toEqual(['Corolla', 'Hilux', 'Mazda 3', 'Mazda 10', 'Toyota'])
+  })
+})
+
+describe('partMatchesQuery', () => {
+  const filter = {
+    name: 'Fuel filter',
+    partNumber: 'FF-23',
+    description: 'Inline, 8mm',
+    tags: ['Toyota', 'Hilux 2015-2020'],
+  }
+
+  it('finds a part by a tag', () => {
+    expect(partMatchesQuery(filter, 'hilux')).toBe(true)
+  })
+
+  it('needs every word, but each may come from a different field', () => {
+    expect(partMatchesQuery(filter, 'filter hilux')).toBe(true)
+    expect(partMatchesQuery(filter, 'filter corolla')).toBe(false)
+  })
+
+  it('searches the description too', () => {
+    expect(partMatchesQuery(filter, '8mm')).toBe(true)
+  })
+
+  it('matches everything for an empty search', () => {
+    expect(partMatchesQuery(filter, '   ')).toBe(true)
+  })
+})
+
+describe('tagFit', () => {
+  const hilux = { make: 'Toyota', model: 'Hilux' }
+
+  it('reads a model name, alone, with the make or with years', () => {
+    expect(tagFit('Hilux', hilux)).toBe('model')
+    expect(tagFit('Toyota Hilux', hilux)).toBe('model')
+    expect(tagFit('hilux 2015-2020', hilux)).toBe('model')
+  })
+
+  it('reads a model stored with its trim, such as Hilux SR5', () => {
+    const sr5 = { make: 'Toyota', model: 'Hilux SR5 Double Cab' }
+    expect(tagFit('Hilux', sr5)).toBe('model')
+    expect(tagFit('Hilux 2016', sr5)).toBe('model')
+    expect(tagFit('Toyota Hilux', sr5)).toBe('model')
+    expect(tagFit('Toyota Corolla', sr5)).toBeNull()
+  })
+
+  it('reads the make alone, or with years, as a make match', () => {
+    expect(tagFit('Toyota', hilux)).toBe('make')
+    expect(tagFit('toyota 2010-2020', hilux)).toBe('make')
+    expect(tagFit('Mercedes', { make: 'Mercedes-Benz', model: 'Sprinter' })).toBe('make')
+  })
+
+  it('does not match the make with some other model', () => {
+    expect(tagFit('Toyota Corolla', hilux)).toBeNull()
+  })
+
+  it('does not take a make tag for the model when the model repeats the make', () => {
+    expect(tagFit('Toyota', { make: 'Toyota', model: 'Toyota Hilux' })).toBe('make')
+  })
+
+  it('reads the numbers in a model name, so a 3 Series part does not fit a 5 Series', () => {
+    const fiveSeries = { make: 'BMW', model: '5-Serie F11' }
+    expect(tagFit('5-Serie', fiveSeries)).toBe('model')
+    expect(tagFit('BMW 5-Serie 2010-2017', fiveSeries)).toBe('model')
+    expect(tagFit('3-Serie', fiveSeries)).toBeNull()
+    expect(tagFit('Golf 7', { make: 'Volkswagen', model: 'Golf 6' })).toBeNull()
+  })
+
+  it('does not match a tag that opens with another make, whatever model follows', () => {
+    const sprinter = { make: 'Mercedes-Benz', model: 'Sprinter' }
+    expect(tagFit('Toyota Sprinter', sprinter)).toBeNull()
+    expect(tagFit('VW Crafter', sprinter)).toBeNull()
+    // The vehicle's own make, however it is written, is not another make.
+    expect(tagFit('Mercedes Sprinter', sprinter)).toBe('model')
+    expect(tagFit('Mercedes-Benz Sprinter 2018', sprinter)).toBe('model')
+    expect(tagFit('Vauxhall Astra', { make: 'Opel', model: 'Astra' })).toBe('model')
+    expect(tagFit('VW Golf', { make: 'Volkswagen', model: 'Golf' })).toBe('model')
+  })
+
+  it('reads only the start of a tag for a make, so a common word is not taken for one', () => {
+    // SEAT is a make, but a tag that merely mentions a seat is about the Hilux.
+    expect(tagFit('Hilux seat cover', { make: 'Toyota', model: 'Hilux' })).toBe('model')
+  })
+
+  it('takes the longest make, so Volvo Penta is not a Volvo', () => {
+    const penta = { make: 'Volvo Penta', model: 'D2-40' }
+    expect(tagFit('Volvo Penta D2-40', penta)).toBe('model')
+    expect(tagFit('Volvo Penta D2-40', { make: 'Volvo', model: 'D2-40' })).toBeNull()
+  })
+
+  it('still matches by model when the vehicle’s make is not in the list, or missing', () => {
+    expect(tagFit('Holden Colorado', { make: 'Holden', model: 'Colorado' })).toBe('model')
+    expect(tagFit('Colorado', { make: 'Holden', model: 'Colorado' })).toBe('model')
+    expect(tagFit('Toyota Hilux', { make: '', model: 'Hilux' })).toBe('model')
+  })
+
+  it('does not match on a number or a part of a word', () => {
+    expect(tagFit('50', { make: 'Mazda', model: 'BT-50' })).toBeNull()
+    expect(tagFit('Hi', hilux)).toBeNull()
+    expect(tagFit('2015', hilux)).toBeNull()
+  })
+})
+
+describe('vehicleFitScore and fittingTags', () => {
+  const hilux = { make: 'Toyota', model: 'Hilux' }
+
+  it('scores a model tag above a make tag above nothing', () => {
+    expect(vehicleFitScore(['Diesel', 'Hilux'], hilux)).toBe(2)
+    expect(vehicleFitScore(['Diesel', 'Toyota'], hilux)).toBe(1)
+    expect(vehicleFitScore(['Diesel'], hilux)).toBe(0)
+    expect(vehicleFitScore(['Hilux'], null)).toBe(0)
+  })
+
+  it('names the tags that matched', () => {
+    expect(fittingTags(['Diesel', 'Toyota', 'Hilux', 'Ford Ranger'], hilux)).toEqual([
+      'Toyota',
+      'Hilux',
+    ])
+  })
+})
+
+describe('rankByVehicleFit', () => {
+  it('puts model matches first, then make matches, otherwise keeping the order', () => {
+    const parts = [
+      { id: 'air', tags: [] },
+      { id: 'toyota-oil', tags: ['Toyota'] },
+      { id: 'ranger', tags: ['Ford Ranger'] },
+      { id: 'hilux-fuel', tags: ['Hilux 2015-2020'] },
+      { id: 'cabin', tags: [] },
+    ]
+    expect(rankByVehicleFit(parts, { make: 'Toyota', model: 'Hilux' }).map((p) => p.id)).toEqual([
+      'hilux-fuel',
+      'toyota-oil',
+      'air',
+      'ranger',
+      'cabin',
+    ])
+  })
+
+  it('leaves the order alone without a vehicle', () => {
+    const parts = [{ id: 'a', tags: ['Hilux'] }, { id: 'b' }]
+    expect(rankByVehicleFit(parts, null).map((p) => p.id)).toEqual(['a', 'b'])
+  })
+})

+ 3 - 2
src/__tests__/lib/raw-sql-qualified.test.ts

@@ -27,8 +27,9 @@ const TEMPLATE_START =
 const TABLE_REF = /\b(FROM|JOIN|UPDATE|INTO)\s+("[^"]+"|[A-Za-z_][A-Za-z0-9_]*)/gi
 
 // Names that are not tables: a schema qualifier, a placeholder for an
-// interpolated fragment, or the keywords that can follow the clause word.
-const NOT_A_TABLE = new Set(['public', 'pg_temp', 'only', 'lateral', 'select', 'values'])
+// interpolated fragment, the keywords that can follow the clause word, or
+// unnest, which reads the rows out of an array column (part tags).
+const NOT_A_TABLE = new Set(['public', 'pg_temp', 'only', 'lateral', 'select', 'values', 'unnest'])
 
 function sourceFiles(dir: string): string[] {
   return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {

+ 27 - 21
src/app/(authenticated)/inventory/[id]/page.tsx

@@ -5,7 +5,10 @@ import {
   getInventoryPart,
   getStockMovementsPaginated,
 } from '@/features/inventory/Actions/getStockMovements'
-import { getInventoryCategories } from '@/features/inventory/Actions/inventoryActions'
+import {
+  getInventoryCategories,
+  getInventoryTags,
+} from '@/features/inventory/Actions/inventoryActions'
 import { getDisplaySettings } from '@/features/settings/Actions/settingsActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { PageHeader } from '@/components/page-header'
@@ -25,26 +28,28 @@ export default async function InventoryPartDetailPage({
   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(),
-    getDisplaySettings([
-      SETTING_KEYS.CURRENCY_CODE,
-      SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER,
-      SETTING_KEYS.UNIT_SYSTEM,
-      SETTING_KEYS.MEASURE_SYSTEM,
-      SETTING_KEYS.DATE_FORMAT,
-      SETTING_KEYS.TIME_FORMAT,
-      SETTING_KEYS.TIMEZONE,
-      SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD,
-    ]),
-  ])
+  const [partResult, movementsResult, categoriesResult, tagsResult, settingsResult] =
+    await Promise.all([
+      getInventoryPart(id),
+      getStockMovementsPaginated({
+        inventoryPartId: id,
+        page,
+        pageSize: PAGE_SIZE,
+        reason: sp.reason,
+      }),
+      getInventoryCategories(),
+      getInventoryTags(),
+      getDisplaySettings([
+        SETTING_KEYS.CURRENCY_CODE,
+        SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER,
+        SETTING_KEYS.UNIT_SYSTEM,
+        SETTING_KEYS.MEASURE_SYSTEM,
+        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.
@@ -91,6 +96,7 @@ export default async function InventoryPartDetailPage({
         currencyCode={resolveCurrencyCode(settings[SETTING_KEYS.CURRENCY_CODE])}
         markupMultiplier={Number(settings[SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER]) || 1}
         categories={categoriesResult.data ?? []}
+        tagSuggestions={tagsResult.data ?? []}
         lowStockDefault={Number(settings[SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD]) || 0}
         unitSystem={measureSystemIn(settings)}
       />

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

@@ -23,6 +23,7 @@ import {
 } from '@/components/ui/select'
 import { DataTablePagination } from '@/components/data-table-pagination'
 import { InventoryPartForm } from '@/features/inventory/Components/InventoryPartForm'
+import { PartTagChips } from '@/features/inventory/Components/PartTagChips'
 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'
@@ -59,6 +60,7 @@ export function PartDetailClient({
   currencyCode,
   markupMultiplier,
   categories,
+  tagSuggestions = [],
   lowStockDefault,
   unitSystem,
 }: {
@@ -72,6 +74,8 @@ export function PartDetailClient({
   currencyCode: string
   markupMultiplier: number
   categories: string[]
+  /** Tags the workshop's parts carry, suggested in the edit form. */
+  tagSuggestions?: string[]
   lowStockDefault: number
   /** workshop.unitSystem, orders unit suggestions in the edit form. */
   unitSystem?: string
@@ -134,6 +138,12 @@ export function PartDetailClient({
           <p className="text-sm text-muted-foreground">
             {[part.partNumber, part.category, part.location].filter(Boolean).join(' · ') || '—'}
           </p>
+          <PartTagChips
+            tags={part.tags ?? []}
+            max={12}
+            onPick={(tag) => router.push(`/inventory?tag=${encodeURIComponent(tag)}`)}
+            className="pt-1"
+          />
         </div>
         <Button
           onClick={() => setShowForm(true)}
@@ -354,6 +364,7 @@ export function PartDetailClient({
         markupMultiplier={markupMultiplier}
         unitSystem={unitSystem}
         categories={categories}
+        tagSuggestions={tagSuggestions}
       />
     </div>
   )

+ 68 - 7
src/app/(authenticated)/inventory/inventory-client.tsx

@@ -47,6 +47,9 @@ import { TableContextMenuHint } from '@/components/table-context-menu-hint'
 import { useGlassModal } from '@/components/glass-modal'
 import { useConfirm } from '@/components/confirm-dialog'
 import { InventoryPartForm } from '@/features/inventory/Components/InventoryPartForm'
+import { PartTagChips } from '@/features/inventory/Components/PartTagChips'
+import { sameTag } from '@/features/inventory/Lib/partTags'
+import { cn } from '@/lib/utils'
 import {
   deleteInventoryPart,
   deleteInventoryParts,
@@ -111,8 +114,12 @@ interface InventoryPart {
     sortOrder: number
   }[]
   location: string | null
+  tags: string[]
 }
 
+/** The tag filter's "every tag" choice; not a value a tag can collide with. */
+const ALL_TAGS = '__all__'
+
 interface PaginatedData {
   parts: InventoryPart[]
   total: number
@@ -126,6 +133,8 @@ export function InventoryClient({
   search,
   category,
   categories,
+  tag = '',
+  tags = [],
   currencyCode = DEFAULT_CURRENCY_CODE,
   markupMultiplier: initialMarkup = 1.0,
   defaultUnit = '',
@@ -140,6 +149,10 @@ export function InventoryClient({
   search: string
   category: string
   categories: string[]
+  /** The tag the list is filtered by, or empty. */
+  tag?: string
+  /** Every tag the workshop's parts carry, for the filter and the form. */
+  tags?: string[]
   currencyCode?: string
   markupMultiplier?: number
   /** Unit pre-filled on new parts (inventory.defaultUnit setting). */
@@ -223,7 +236,7 @@ export function InventoryClient({
       }
       if (
         !('page' in params) &&
-        ('search' in params || 'category' in params || 'lowStock' in params)
+        ('search' in params || 'category' in params || 'tag' in params || 'lowStock' in params)
       ) {
         newParams.delete('page')
       }
@@ -356,7 +369,7 @@ export function InventoryClient({
         </Link>
       </div>
     )
-  ) : search || category ? (
+  ) : search || category || tag ? (
     t('empty.noMatch')
   ) : (
     <ListEmpty
@@ -372,6 +385,14 @@ export function InventoryClient({
     />
   )
 
+  // The filter is offered once any part has a tag, and kept while one is
+  // applied even if no part carries it any more, so it can be cleared.
+  const selectedTag = tag ? (tags.find((option) => sameTag(option, tag)) ?? tag) : ''
+  const tagOptions = selectedTag && !tags.includes(selectedTag) ? [selectedTag, ...tags] : tags
+  const showTagFilter = tagOptions.length > 0
+  const filterByTag = (picked: string) =>
+    navigate({ tag: sameTag(picked, selectedTag) ? undefined : picked })
+
   return (
     <div className="flex min-h-0 flex-1 flex-col gap-4">
       {/* Toolbar */}
@@ -387,8 +408,21 @@ export function InventoryClient({
             </Button>
           </div>
         ) : (
-          <div className="flex flex-1 items-center gap-2">
-            <form onSubmit={handleSearch} className="relative flex-1 sm:max-w-sm">
+          <div
+            className={cn(
+              'flex flex-1 items-center gap-2',
+              // Two dropdowns and the search do not fit one phone row; the
+              // search takes its own and the filters sit under it.
+              showTagFilter && 'flex-wrap sm:flex-nowrap'
+            )}
+          >
+            <form
+              onSubmit={handleSearch}
+              className={cn(
+                'relative flex-1 sm:max-w-sm',
+                showTagFilter && 'basis-full sm:basis-0'
+              )}
+            >
               <Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
               <Input
                 placeholder={t('searchPlaceholder')}
@@ -408,6 +442,18 @@ export function InventoryClient({
                 ...categories.map((cat) => ({ value: cat, label: cat })),
               ]}
             />
+            {showTagFilter && (
+              <SearchableSelect
+                value={selectedTag || ALL_TAGS}
+                onValueChange={(v) => navigate({ tag: v === ALL_TAGS ? undefined : v })}
+                placeholder={t('tags.filterPlaceholder')}
+                className="w-[120px] shrink-0 sm:w-[150px]"
+                options={[
+                  { value: ALL_TAGS, label: t('tags.all') },
+                  ...tagOptions.map((option) => ({ value: option, label: option })),
+                ]}
+              />
+            )}
             {/* 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). */}
@@ -488,7 +534,7 @@ export function InventoryClient({
         {data.parts.length === 0 ? (
           <div
             className={
-              search || category || lowStockOnly
+              search || category || tag || lowStockOnly
                 ? 'rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground'
                 : 'rounded-lg border border-dashed'
             }
@@ -548,6 +594,13 @@ export function InventoryClient({
                       {part.location && <span className="truncate">{part.location}</span>}
                       {part.supplier && <span className="truncate">{part.supplier}</span>}
                     </div>
+                    {/* Read only: the card is a button, and one cannot hold another. */}
+                    <PartTagChips
+                      tags={part.tags}
+                      max={3}
+                      active={selectedTag || undefined}
+                      className="mt-2"
+                    />
                   </div>
                 </button>
                 <DropdownMenu>
@@ -704,7 +757,7 @@ export function InventoryClient({
                 <TableCell
                   colSpan={11}
                   className={
-                    search || category || lowStockOnly
+                    search || category || tag || lowStockOnly
                       ? 'h-32 text-center text-muted-foreground'
                       : 'p-0'
                   }
@@ -766,7 +819,14 @@ export function InventoryClient({
                                 }}
                               />
                             )}
-                            {part.name}
+                            <div className="min-w-0 space-y-1">
+                              <span>{part.name}</span>
+                              <PartTagChips
+                                tags={part.tags}
+                                active={selectedTag || undefined}
+                                onPick={filterByTag}
+                              />
+                            </div>
                           </div>
                         </TableCell>
                         <TableCell className="hidden sm:table-cell text-muted-foreground">
@@ -918,6 +978,7 @@ export function InventoryClient({
         unitSystem={unitSystem}
         initialBarcode={!editPart ? scannedBarcode : undefined}
         categories={categories}
+        tagSuggestions={tags}
         onViewImages={(urls, startIndex) => {
           setGalleryImages(urls)
           setGalleryIndex(startIndex)

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

@@ -4,6 +4,7 @@ import { resolveListSort } from '@/lib/list-sort-preference.server'
 import {
   getInventoryPartsPaginated,
   getInventoryCategories,
+  getInventoryTags,
   hasAnyReorderPoint,
 } from '@/features/inventory/Actions/inventoryActions'
 import { getDisplaySettings } from '@/features/settings/Actions/settingsActions'
@@ -20,6 +21,7 @@ export default async function InventoryPage({
     pageSize?: string
     search?: string
     category?: string
+    tag?: string
     sortBy?: string
     sortOrder?: string
     lowStock?: string
@@ -30,17 +32,19 @@ export default async function InventoryPage({
     sortBy: 'updatedAt',
     sortOrder: 'desc',
   })
-  const [result, categoriesResult, settingsResult, reorderResult] = await Promise.all([
+  const [result, categoriesResult, tagsResult, settingsResult, reorderResult] = await Promise.all([
     getInventoryPartsPaginated({
       page: params.page ? parseInt(params.page) : 1,
       pageSize: params.pageSize ? parseInt(params.pageSize) : 20,
       search: params.search,
       category: params.category,
+      tag: params.tag,
       sortBy: sort.sortBy,
       sortOrder: sort.sortOrder,
       lowStock: params.lowStock === '1',
     }),
     getInventoryCategories(),
+    getInventoryTags(),
     getDisplaySettings([
       SETTING_KEYS.CURRENCY_CODE,
       SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER,
@@ -64,6 +68,7 @@ export default async function InventoryPage({
   }
 
   const categories = categoriesResult.success && categoriesResult.data ? categoriesResult.data : []
+  const tags = tagsResult.success && tagsResult.data ? tagsResult.data : []
   const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {}
   const currencyCode = resolveCurrencyCode(settings[SETTING_KEYS.CURRENCY_CODE])
   const markupMultiplier = Number(settings[SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER]) || 1.0
@@ -77,6 +82,8 @@ export default async function InventoryPage({
           search={params.search || ''}
           category={params.category || ''}
           categories={categories}
+          tag={params.tag || ''}
+          tags={tags}
           currencyCode={currencyCode}
           markupMultiplier={markupMultiplier}
           defaultUnit={settings[SETTING_KEYS.INVENTORY_DEFAULT_UNIT] || ''}

+ 1 - 0
src/app/(authenticated)/labor-presets/labor-presets-client.tsx

@@ -92,6 +92,7 @@ export function LaborPresetsClient({
     sellPrice: number
     unitCost: number
     quantity: number
+    tags?: string[]
   }[]
 }) {
   const router = useRouter()

+ 1 - 0
src/app/(authenticated)/labor-presets/page.tsx

@@ -61,6 +61,7 @@ export default async function LaborPresetsPage({
           sellPrice: p.sellPrice,
           unitCost: p.unitCost,
           quantity: p.quantity,
+          tags: p.tags,
         }))
       : []
   const currencyCode = resolveCurrencyCode(settings[SETTING_KEYS.CURRENCY_CODE])

+ 258 - 0
src/components/tag-input.tsx

@@ -0,0 +1,258 @@
+'use client'
+
+import { useId, useMemo, useRef, useState } from 'react'
+import { X } from 'lucide-react'
+import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
+import { cn } from '@/lib/utils'
+import {
+  cleanTag,
+  normalizePartTags,
+  sameTag,
+  splitTagInput,
+} from '@/features/inventory/Lib/partTags'
+
+/** Suggestions listed at once; typing narrows the rest. */
+const MAX_SUGGESTIONS = 50
+
+interface TagInputProps {
+  value: string[]
+  onChange: (tags: string[]) => void
+  /** Tags already in use elsewhere, offered while typing so one spelling sticks. */
+  suggestions?: string[]
+  id?: string
+  /**
+   * Name of the text box, so a form can read what was typed but not yet
+   * turned into a tag when it is submitted.
+   */
+  name?: string
+  placeholder?: string
+  maxTags?: number
+  /**
+   * Longest tag accepted. Not a limit on the box itself, which would cut a
+   * pasted list short: a longer tag stays in the box with `tooLongMessage`.
+   */
+  maxLength?: number
+  /** Shown under the box while it holds a tag longer than `maxLength`. */
+  tooLongMessage?: string
+  /** Accessible name of a tag's remove button. */
+  removeLabel: (tag: string) => string
+  className?: string
+}
+
+/**
+ * A text box that turns what is typed into tags: Enter or a comma adds one,
+ * Backspace in an empty box takes the last one off, and leaving the box keeps
+ * whatever was typed. Tags the workshop already uses are suggested below.
+ */
+export function TagInput({
+  value,
+  onChange,
+  suggestions = [],
+  id,
+  name,
+  placeholder,
+  maxTags,
+  maxLength,
+  tooLongMessage,
+  removeLabel,
+  className,
+}: TagInputProps) {
+  const [draft, setDraft] = useState('')
+  const [open, setOpen] = useState(false)
+  const [highlighted, setHighlighted] = useState(-1)
+  const anchorRef = useRef<HTMLDivElement>(null)
+  const inputRef = useRef<HTMLInputElement>(null)
+  const baseId = useId()
+  const listId = `${baseId}-list`
+  const optionId = (index: number) => `${baseId}-option-${index}`
+  const messageId = `${baseId}-message`
+
+  const full = maxTags !== undefined && value.length >= maxTags
+
+  const matches = useMemo(() => {
+    const typed = cleanTag(draft).toLocaleLowerCase()
+    return suggestions
+      .filter((tag) => !value.some((taken) => sameTag(taken, tag)))
+      .filter((tag) => !typed || tag.toLocaleLowerCase().includes(typed))
+      .slice(0, MAX_SUGGESTIONS)
+  }, [draft, suggestions, value])
+
+  const showList = open && !full && matches.length > 0
+  const isTooLong = (tag: string) => maxLength !== undefined && tag.length > maxLength
+  const draftTooLong = splitTagInput(draft).some(isTooLong)
+
+  /**
+   * Adds the tags that fit. One that is too long stays in the box, in front of
+   * `rest` (text still being typed), to be shortened rather than lost.
+   */
+  const add = (tags: string[], rest = '') => {
+    const next = normalizePartTags([...value, ...tags.filter((tag) => !isTooLong(tag))])
+    const capped = maxTags !== undefined ? next.slice(0, maxTags) : next
+    if (capped.length !== value.length) onChange(capped)
+    setDraft([...tags.filter(isTooLong), rest].filter(Boolean).join(', '))
+    setHighlighted(-1)
+  }
+
+  const remove = (tag: string) => {
+    onChange(value.filter((t) => t !== tag))
+    inputRef.current?.focus()
+  }
+
+  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
+    if (e.key === 'ArrowDown' && matches.length > 0) {
+      e.preventDefault()
+      setOpen(true)
+      setHighlighted((i) => (i + 1) % matches.length)
+      return
+    }
+    if (e.key === 'ArrowUp' && matches.length > 0) {
+      e.preventDefault()
+      setOpen(true)
+      setHighlighted((i) => (i <= 0 ? matches.length - 1 : i - 1))
+      return
+    }
+    if (e.key === 'Enter' || e.key === ',') {
+      const picked = showList && highlighted >= 0 ? matches[highlighted] : undefined
+      // Enter in an empty box is left alone, so it still submits the form.
+      if (!picked && !cleanTag(draft)) {
+        if (e.key === ',') e.preventDefault()
+        return
+      }
+      e.preventDefault()
+      add(picked ? [picked] : splitTagInput(draft))
+      return
+    }
+    if (e.key === 'Backspace' && draft === '' && value.length > 0) {
+      e.preventDefault()
+      onChange(value.slice(0, -1))
+    }
+  }
+
+  const onDraftChange = (text: string) => {
+    // A pasted list ("Toyota, Hilux, Corolla") becomes tags at its commas; the
+    // text after the last comma stays in the box to be finished.
+    if (/[,;\n]/.test(text)) {
+      const parts = text.split(/[,;\n]/)
+      const rest = parts.pop() ?? ''
+      add(parts.flatMap(splitTagInput), rest.trimStart())
+    } else {
+      setDraft(text)
+      setHighlighted(-1)
+    }
+    setOpen(true)
+  }
+
+  return (
+    <Popover
+      open={showList}
+      onOpenChange={(next) => {
+        if (!next) setOpen(false)
+      }}
+    >
+      <PopoverAnchor asChild>
+        <div
+          ref={anchorRef}
+          className={cn(
+            'flex min-h-9 w-full flex-wrap items-center gap-1 rounded-md border border-input bg-transparent px-2 py-1 shadow-xs transition-[color,box-shadow] dark:bg-input/30',
+            'focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50',
+            draftTooLong && 'border-destructive focus-within:border-destructive',
+            className
+          )}
+          onClick={() => inputRef.current?.focus()}
+        >
+          {value.map((tag) => (
+            <span
+              key={tag}
+              className="inline-flex max-w-full items-center gap-1 rounded-md bg-secondary py-0.5 pr-1 pl-2 text-xs font-medium text-secondary-foreground"
+            >
+              <span className="truncate">{tag}</span>
+              <button
+                type="button"
+                aria-label={removeLabel(tag)}
+                title={removeLabel(tag)}
+                className="rounded-sm p-0.5 text-muted-foreground hover:bg-background/60 hover:text-foreground"
+                onClick={(e) => {
+                  e.stopPropagation()
+                  remove(tag)
+                }}
+              >
+                <X className="h-3 w-3" />
+              </button>
+            </span>
+          ))}
+          {!full && (
+            <input
+              ref={inputRef}
+              id={id}
+              name={name}
+              value={draft}
+              placeholder={value.length === 0 ? placeholder : undefined}
+              autoComplete="off"
+              role="combobox"
+              aria-expanded={showList}
+              aria-autocomplete="list"
+              aria-controls={showList ? listId : undefined}
+              aria-activedescendant={
+                showList && highlighted >= 0 ? optionId(highlighted) : undefined
+              }
+              aria-invalid={draftTooLong || undefined}
+              aria-describedby={draftTooLong && tooLongMessage ? messageId : undefined}
+              className="h-7 min-w-24 flex-1 bg-transparent px-1 text-base outline-none placeholder:text-muted-foreground md:text-sm"
+              onChange={(e) => onDraftChange(e.target.value)}
+              onKeyDown={onKeyDown}
+              onFocus={() => setOpen(true)}
+              onBlur={() => {
+                if (cleanTag(draft)) add(splitTagInput(draft))
+                setOpen(false)
+              }}
+            />
+          )}
+        </div>
+      </PopoverAnchor>
+      {draftTooLong && tooLongMessage && (
+        <p id={messageId} className="text-xs text-destructive">
+          {tooLongMessage}
+        </p>
+      )}
+      <PopoverContent
+        align="start"
+        className="max-h-60 w-(--radix-popover-trigger-width) min-w-56 overflow-y-auto p-1"
+        // The box keeps the focus: the list is read and picked from, never typed into.
+        onOpenAutoFocus={(e) => e.preventDefault()}
+        onCloseAutoFocus={(e) => e.preventDefault()}
+        onInteractOutside={(e) => {
+          if (anchorRef.current?.contains(e.target as Node)) e.preventDefault()
+        }}
+        // Not modal, unlike the app's other lists, because the focus has to
+        // stay in the box. Inside a dialog its scroll lock would then swallow
+        // the wheel, so the events stop here before they reach it.
+        onWheel={(e) => e.stopPropagation()}
+        onTouchMove={(e) => e.stopPropagation()}
+      >
+        <div role="listbox" id={listId}>
+          {matches.map((tag, i) => (
+            <button
+              key={tag}
+              id={optionId(i)}
+              type="button"
+              role="option"
+              aria-selected={i === highlighted}
+              className={cn(
+                'flex w-full items-center rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent',
+                i === highlighted && 'bg-accent'
+              )}
+              // mousedown, not click: a click would blur the box first and add
+              // the half-typed text as a tag of its own.
+              onMouseDown={(e) => {
+                e.preventDefault()
+                add([tag])
+              }}
+            >
+              <span className="truncate">{tag}</span>
+            </button>
+          ))}
+        </div>
+      </PopoverContent>
+    </Popover>
+  )
+}

+ 1 - 0
src/features/ai/tools/workshop-columns.ts

@@ -303,6 +303,7 @@ export const TABLE_COLUMNS: Readonly<Record<string, readonly string[]>> = {
     'imageUrl',
     'location',
     'isArchived',
+    'tags',
     'lowStockAlertedAt',
     'createdAt',
     'updatedAt',

+ 1 - 1
src/features/ai/tools/workshop-tools.ts

@@ -690,7 +690,7 @@ quote_parts (id, "partNumber", name, quantity, unit, category, "unitPrice", tota
 
 quote_labor (id, description, hours, rate, total, excluded, "quoteId")
 
-inventory_parts (id, "partNumber", name, description, category, quantity, "minQuantity", unit, "unitCost", "sellPrice", supplier, "supplierPhone", "supplierEmail", "supplierUrl", location, "isArchived", "createdAt", "updatedAt")
+inventory_parts (id, "partNumber", name, description, category, quantity, "minQuantity", unit, "unitCost", "sellPrice", supplier, "supplierPhone", "supplierEmail", "supplierUrl", location, "isArchived", tags, "createdAt", "updatedAt") -- tags is text[]: the makes and models a part fits, or any label; match with array_to_string(tags, ',') ILIKE '%hilux%'
 
 notes (id, title, content, "isPinned", "createdAt", "updatedAt", "vehicleId")
 

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

@@ -132,6 +132,9 @@ export async function getInventoryPart(id: string) {
           category: true,
           quantity: true,
           minQuantity: true,
+          // The edit form saves back every field it is given, so a field left
+          // out here would be cleared when the part is saved from this page.
+          unit: true,
           unitCost: true,
           sellPrice: true,
           supplier: true,
@@ -140,6 +143,7 @@ export async function getInventoryPart(id: string) {
           supplierUrl: true,
           imageUrl: true,
           location: true,
+          tags: true,
           gallery: {
             select: {
               id: true,

+ 62 - 14
src/features/inventory/Actions/inventoryActions.ts

@@ -15,13 +15,20 @@ import { releaseFiles, parseStoredFileUrl } from '@/lib/files/manager'
 import { inventoryPartFileUrls } from '@/lib/files/collect'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { normalizeBarcode, withBarcodeConflictMessage } from '../Lib/barcode'
+import { distinctTags, normalizePartTags } from '../Lib/partTags'
+import { partIdsWithTag, partIdsWithTagContaining } from '../Lib/tagSearch.server'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 
+/** Words of a list search looked at; the rest of a pasted paragraph is ignored. */
+const MAX_SEARCH_WORDS = 8
+
 export async function getInventoryPartsPaginated(params: {
   page?: number
   pageSize?: number
   search?: string
   category?: string
+  /** Restrict to parts carrying this tag, in any case. */
+  tag?: string
   sortBy?: string
   sortOrder?: 'asc' | 'desc'
   /** Restrict to parts at or below their reorder point. */
@@ -34,25 +41,40 @@ export async function getInventoryPartsPaginated(params: {
       const skip = (page - 1) * pageSize
       const mode = 'insensitive' as Prisma.QueryMode
 
-      // eslint-disable-next-line @typescript-eslint/no-explicit-any
-      const where: any = { organizationId, isArchived: false }
-
-      if (params.search) {
-        const q = params.search.trim()
-        where.OR = [
-          { name: { contains: q, mode } },
-          { partNumber: { contains: q, mode } },
-          { barcode: { contains: q, mode } },
-          { description: { contains: q, mode } },
-          { supplier: { contains: q, mode } },
-          { location: { contains: q, mode } },
-        ]
+      const where: Prisma.InventoryPartWhereInput = { organizationId, isArchived: false }
+      const and: Prisma.InventoryPartWhereInput[] = []
+
+      // Every word has to match some field or tag, in any order, so "filter
+      // hilux" finds the fuel filter tagged Hilux.
+      const words = (params.search ?? '').trim().split(/\s+/).filter(Boolean)
+      if (words.length > 0) {
+        const searched = words.slice(0, MAX_SEARCH_WORDS)
+        const tagged = await Promise.all(
+          searched.map((word) => partIdsWithTagContaining(organizationId, word))
+        )
+        searched.forEach((word, i) => {
+          and.push({
+            OR: [
+              { name: { contains: word, mode } },
+              { partNumber: { contains: word, mode } },
+              { barcode: { contains: word, mode } },
+              { description: { contains: word, mode } },
+              { supplier: { contains: word, mode } },
+              { location: { contains: word, mode } },
+              { id: { in: tagged[i] } },
+            ],
+          })
+        })
       }
 
       if (params.category && params.category !== 'all') {
         where.category = params.category
       }
 
+      if (params.tag?.trim()) {
+        and.push({ id: { in: await partIdsWithTag(organizationId, params.tag) } })
+      }
+
       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
@@ -76,9 +98,11 @@ export async function getInventoryPartsPaginated(params: {
           AND COALESCE(NULLIF("minQuantity", 0), ${lowStockDefault}) > 0
           AND "quantity" <= COALESCE(NULLIF("minQuantity", 0), ${lowStockDefault})
       `
-        where.id = { in: lowRows.map((r) => r.id) }
+        and.push({ id: { in: lowRows.map((r) => r.id) } })
       }
 
+      if (and.length > 0) where.AND = and
+
       const dir = params.sortOrder || 'desc'
       const sortableColumns = [
         'name',
@@ -161,6 +185,7 @@ export async function createInventoryPart(input: unknown) {
             supplierUrl: rest.supplierUrl || undefined,
             imageUrl: gallery?.[0]?.url || undefined,
             location: rest.location || undefined,
+            tags: normalizePartTags(rest.tags ?? []),
             userId,
             organizationId,
           },
@@ -269,6 +294,7 @@ export async function updateInventoryPart(input: unknown) {
             supplierUrl:
               updateData.supplierUrl !== undefined ? updateData.supplierUrl || null : undefined,
             location: updateData.location !== undefined ? updateData.location || null : undefined,
+            tags: updateData.tags !== undefined ? normalizePartTags(updateData.tags) : undefined,
           },
         })
       )
@@ -397,6 +423,27 @@ export async function getInventoryCategories() {
   )
 }
 
+/**
+ * Every tag the workshop's parts carry, sorted, for the list's tag filter and
+ * the suggestions under the part form's tag field.
+ */
+export async function getInventoryTags() {
+  return withAuth(
+    async ({ organizationId }) => {
+      const rows = await db.inventoryPart.findMany({
+        where: { organizationId, isArchived: false, tags: { isEmpty: false } },
+        select: { tags: true },
+      })
+      return distinctTags(rows.map((row) => row.tags))
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.INVENTORY },
+      ],
+    }
+  )
+}
+
 export async function getInventoryPartsList() {
   return withAuth(
     async ({ userId, organizationId }) => {
@@ -416,6 +463,7 @@ export async function getInventoryPartsList() {
           sellPrice: true,
           quantity: true,
           category: true,
+          tags: true,
           gallery: {
             select: { id: true, url: true, sortOrder: true },
             orderBy: { sortOrder: 'asc' },

+ 439 - 383
src/features/inventory/Components/InventoryPartForm.tsx

@@ -54,6 +54,14 @@ import { compressImage } from '@/lib/compress-image'
 import { priceFromCostAndMultiplier } from '@/features/inventory/Lib/partPricing'
 import { UnitCombobox } from '@/features/inventory/Components/UnitCombobox'
 import { clearableInput } from '@/lib/clearable'
+import { TagInput } from '@/components/tag-input'
+import { useServiceType } from '@/components/service-type-context'
+import {
+  MAX_PART_TAGS,
+  MAX_TAG_LENGTH,
+  normalizePartTags,
+  splitTagInput,
+} from '@/features/inventory/Lib/partTags'
 
 interface InventoryPartFormProps {
   open: boolean
@@ -65,6 +73,8 @@ interface InventoryPartFormProps {
   unitSystem?: string
   initialBarcode?: string
   categories?: string[]
+  /** Tags the workshop's parts already carry, suggested in the tag field. */
+  tagSuggestions?: string[]
   onViewImages?: (urls: string[], startIndex: number) => void
   part?: {
     id: string
@@ -91,6 +101,8 @@ interface InventoryPartFormProps {
       sortOrder: number
     }[]
     location: string | null
+    /** Required: a part opened without its tags would lose them on Save. */
+    tags: string[]
   }
 }
 
@@ -103,11 +115,13 @@ export function InventoryPartForm({
   unitSystem,
   initialBarcode,
   categories = [],
+  tagSuggestions = [],
   onViewImages,
 }: InventoryPartFormProps) {
   const router = useRouter()
   const modal = useGlassModal()
   const t = useTranslations('inventory')
+  const isMarine = useServiceType() === 'marine'
   const [loading, setLoading] = useState(false)
   const [fetching, setFetching] = useState(false)
   const [uploading, setUploading] = useState(false)
@@ -117,6 +131,7 @@ export function InventoryPartForm({
   const [unit, setUnit] = useState(part ? (part.unit ?? '') : (defaultUnit ?? ''))
   const [categoryOpen, setCategoryOpen] = useState(false)
   const [categorySearch, setCategorySearch] = useState('')
+  const [tags, setTags] = useState<string[]>(part?.tags ?? [])
   const sellPriceManualRef = useRef(false)
   const [gallery, setGallery] = useState<
     {
@@ -144,6 +159,7 @@ export function InventoryPartForm({
       setCategory(part?.category ?? '')
       setUnit(part ? (part.unit ?? '') : (defaultUnit ?? ''))
       setCategorySearch('')
+      setTags(part?.tags ?? [])
       sellPriceManualRef.current = !!(part?.sellPrice && part.sellPrice > 0)
       if (part?.gallery && part.gallery.length > 0) {
         setGallery(part.gallery)
@@ -349,9 +365,21 @@ export function InventoryPartForm({
 
   const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
     e.preventDefault()
-    setLoading(true)
 
     const formData = new FormData(e.currentTarget)
+    // Text still in the tag box (saved with the keyboard shortcut, say) is a
+    // tag too. A part that reached the form without its tags keeps the stored
+    // ones rather than being saved with none.
+    const typedTags = splitTagInput(String(formData.get('tagDraft') ?? ''))
+    const knowsTags = !part || Array.isArray(part.tags)
+    // The box keeps a tag that is too long, with a note under it; saving it
+    // would only come back refused, so it is shortened first.
+    if (typedTags.some((tag) => tag.length > MAX_TAG_LENGTH)) {
+      modal.open('error', t('errors.error'), t('form.tagTooLong', { max: MAX_TAG_LENGTH }))
+      return
+    }
+
+    setLoading(true)
     // An emptied field reaches the update action as '' so it can be cleared.
     const optional = (value: FormDataEntryValue | string | null) =>
       clearableInput(value, Boolean(part))
@@ -378,6 +406,7 @@ export function InventoryPartForm({
         sortOrder: i,
       })),
       location: optional(formData.get('location')),
+      tags: knowsTags ? normalizePartTags([...tags, ...typedTags]) : undefined,
     }
 
     const result = part
@@ -432,88 +461,108 @@ export function InventoryPartForm({
 
         <form ref={formRef} onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col">
           <DialogBody className="space-y-4">
-          {/* Supplier Link */}
-          <div className="space-y-2">
-            <Label htmlFor="supplierUrl">{t('form.supplierLink')}</Label>
-            <div className="flex gap-2">
-              <div className="relative flex-1">
-                <Input
-                  id="supplierUrl"
-                  placeholder={t('form.supplierLinkPlaceholder')}
-                  value={supplierUrl}
-                  onChange={(e) => setSupplierUrl(e.target.value)}
-                />
-                {supplierUrl &&
-                  (() => {
-                    try {
-                      new URL(supplierUrl)
-                      return true
-                    } catch {
-                      return false
-                    }
-                  })() && (
-                    <a
-                      href={supplierUrl}
-                      target="_blank"
-                      rel="noopener noreferrer"
-                      className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
-                    >
-                      <ExternalLink className="h-4 w-4" />
-                    </a>
-                  )}
+            {/* Supplier Link */}
+            <div className="space-y-2">
+              <Label htmlFor="supplierUrl">{t('form.supplierLink')}</Label>
+              <div className="flex gap-2">
+                <div className="relative flex-1">
+                  <Input
+                    id="supplierUrl"
+                    placeholder={t('form.supplierLinkPlaceholder')}
+                    value={supplierUrl}
+                    onChange={(e) => setSupplierUrl(e.target.value)}
+                  />
+                  {supplierUrl &&
+                    (() => {
+                      try {
+                        new URL(supplierUrl)
+                        return true
+                      } catch {
+                        return false
+                      }
+                    })() && (
+                      <a
+                        href={supplierUrl}
+                        target="_blank"
+                        rel="noopener noreferrer"
+                        className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
+                      >
+                        <ExternalLink className="h-4 w-4" />
+                      </a>
+                    )}
+                </div>
+                <Button
+                  type="button"
+                  variant="outline"
+                  size="sm"
+                  disabled={fetching || !supplierUrl}
+                  onClick={() => handleFetchMetadata()}
+                >
+                  {fetching ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : null}
+                  {t('form.fetch')}
+                </Button>
               </div>
-              <Button
-                type="button"
-                variant="outline"
-                size="sm"
-                disabled={fetching || !supplierUrl}
-                onClick={() => handleFetchMetadata()}
-              >
-                {fetching ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : null}
-                {t('form.fetch')}
-              </Button>
             </div>
-          </div>
 
-          {/* Image + fields: stacked on mobile, side-by-side on sm+ */}
-          <div className="flex flex-col gap-4 sm:flex-row sm:gap-5">
-            {/* Image area */}
-            <div className={`flex-shrink-0 ${gallery.length > 0 ? 'sm:w-52' : 'sm:w-44'}`}>
-              <Label className="mb-2 block">{t('form.image')}</Label>
-              {gallery.length > 0 ? (
-                <div className="grid grid-cols-3 gap-1.5 sm:grid-cols-2">
-                  {gallery.map((g, i) => (
+            {/* Image + fields: stacked on mobile, side-by-side on sm+ */}
+            <div className="flex flex-col gap-4 sm:flex-row sm:gap-5">
+              {/* Image area */}
+              <div className={`flex-shrink-0 ${gallery.length > 0 ? 'sm:w-52' : 'sm:w-44'}`}>
+                <Label className="mb-2 block">{t('form.image')}</Label>
+                {gallery.length > 0 ? (
+                  <div className="grid grid-cols-3 gap-1.5 sm:grid-cols-2">
+                    {gallery.map((g, i) => (
+                      <div
+                        key={g.url}
+                        className="relative aspect-square overflow-hidden rounded-lg border bg-muted cursor-pointer"
+                        {...interactiveRow(() => {
+                          onOpenChange(false)
+                          onViewImages?.(
+                            gallery.map((img) => img.url),
+                            i
+                          )
+                        })}
+                      >
+                        <img
+                          src={g.url}
+                          alt={`Part ${i + 1}`}
+                          className="h-full w-full object-cover"
+                        />
+                        <button
+                          type="button"
+                          onClick={(e) => {
+                            e.stopPropagation()
+                            setGallery((prev) => prev.filter((_, j) => j !== i))
+                          }}
+                          className="absolute right-0.5 top-0.5 rounded-full bg-background/80 p-0.5 text-muted-foreground hover:text-foreground"
+                        >
+                          <X className="h-3 w-3" />
+                        </button>
+                      </div>
+                    ))}
+                    {/* Add more */}
                     <div
-                      key={g.url}
-                      className="relative aspect-square overflow-hidden rounded-lg border bg-muted cursor-pointer"
-                      {...interactiveRow(() => {
-                        onOpenChange(false)
-                        onViewImages?.(
-                          gallery.map((img) => img.url),
-                          i
-                        )
-                      })}
+                      className={`flex aspect-square cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed bg-muted transition-colors ${
+                        dragOver ? 'border-primary bg-primary/5' : 'border-muted-foreground/25'
+                      }`}
+                      onDragOver={(e) => {
+                        e.preventDefault()
+                        setDragOver(true)
+                      }}
+                      onDragLeave={() => setDragOver(false)}
+                      onDrop={handleDrop}
+                      {...interactiveRow(() => fileInputRef.current?.click())}
                     >
-                      <img
-                        src={g.url}
-                        alt={`Part ${i + 1}`}
-                        className="h-full w-full object-cover"
-                      />
-                      <button
-                        type="button"
-                        onClick={(e) => {
-                          e.stopPropagation()
-                          setGallery((prev) => prev.filter((_, j) => j !== i))
-                        }}
-                        className="absolute right-0.5 top-0.5 rounded-full bg-background/80 p-0.5 text-muted-foreground hover:text-foreground"
-                      >
-                        <X className="h-3 w-3" />
-                      </button>
+                      {uploading ? (
+                        <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
+                      ) : (
+                        <Plus className="h-6 w-6 text-muted-foreground" />
+                      )}
                     </div>
-                  ))}
-                  {/* Add more */}
+                  </div>
+                ) : (
                   <div
-                    className={`flex aspect-square cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed bg-muted transition-colors ${
+                    className={`flex h-36 w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed bg-muted transition-colors sm:h-44 ${
                       dragOver ? 'border-primary bg-primary/5' : 'border-muted-foreground/25'
                     }`}
                     onDragOver={(e) => {
@@ -525,343 +574,350 @@ export function InventoryPartForm({
                     {...interactiveRow(() => fileInputRef.current?.click())}
                   >
                     {uploading ? (
-                      <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
+                      <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
                     ) : (
-                      <Plus className="h-6 w-6 text-muted-foreground" />
+                      <>
+                        <ImageIcon className="h-8 w-8 text-muted-foreground" />
+                        <span className="mt-2 text-xs text-muted-foreground">
+                          {t('form.dropOrClick')}
+                        </span>
+                      </>
                     )}
                   </div>
+                )}
+                {/* Hidden file inputs */}
+                <input
+                  ref={fileInputRef}
+                  type="file"
+                  accept="image/jpeg,image/png,image/webp,image/avif"
+                  multiple
+                  className="hidden"
+                  onChange={handleFileChange}
+                />
+                <input
+                  ref={cameraInputRef}
+                  type="file"
+                  accept="image/jpeg,image/png"
+                  capture="environment"
+                  className="hidden"
+                  onChange={handleFileChange}
+                />
+                <div className="mt-2 flex gap-1">
+                  <Button
+                    type="button"
+                    variant="outline"
+                    size="sm"
+                    className="flex-1 text-xs"
+                    onClick={() => cameraInputRef.current?.click()}
+                    disabled={uploading}
+                  >
+                    <Camera className="mr-1 h-3 w-3" />
+                    {t('form.camera')}
+                  </Button>
+                  <Button
+                    type="button"
+                    variant="outline"
+                    size="sm"
+                    className="flex-1 text-xs"
+                    onClick={() => fileInputRef.current?.click()}
+                    disabled={uploading}
+                  >
+                    <Upload className="mr-1 h-3 w-3" />
+                    {t('form.upload')}
+                  </Button>
                 </div>
-              ) : (
-                <div
-                  className={`flex h-36 w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed bg-muted transition-colors sm:h-44 ${
-                    dragOver ? 'border-primary bg-primary/5' : 'border-muted-foreground/25'
-                  }`}
-                  onDragOver={(e) => {
-                    e.preventDefault()
-                    setDragOver(true)
-                  }}
-                  onDragLeave={() => setDragOver(false)}
-                  onDrop={handleDrop}
-                  {...interactiveRow(() => fileInputRef.current?.click())}
-                >
-                  {uploading ? (
-                    <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
-                  ) : (
-                    <>
-                      <ImageIcon className="h-8 w-8 text-muted-foreground" />
-                      <span className="mt-2 text-xs text-muted-foreground">
-                        {t('form.dropOrClick')}
-                      </span>
-                    </>
-                  )}
-                </div>
-              )}
-              {/* Hidden file inputs */}
-              <input
-                ref={fileInputRef}
-                type="file"
-                accept="image/jpeg,image/png,image/webp,image/avif"
-                multiple
-                className="hidden"
-                onChange={handleFileChange}
-              />
-              <input
-                ref={cameraInputRef}
-                type="file"
-                accept="image/jpeg,image/png"
-                capture="environment"
-                className="hidden"
-                onChange={handleFileChange}
-              />
-              <div className="mt-2 flex gap-1">
-                <Button
-                  type="button"
-                  variant="outline"
-                  size="sm"
-                  className="flex-1 text-xs"
-                  onClick={() => cameraInputRef.current?.click()}
-                  disabled={uploading}
-                >
-                  <Camera className="mr-1 h-3 w-3" />
-                  {t('form.camera')}
-                </Button>
-                <Button
-                  type="button"
-                  variant="outline"
-                  size="sm"
-                  className="flex-1 text-xs"
-                  onClick={() => fileInputRef.current?.click()}
-                  disabled={uploading}
-                >
-                  <Upload className="mr-1 h-3 w-3" />
-                  {t('form.upload')}
-                </Button>
+                {gallery.length > 0 && (
+                  <Button
+                    type="button"
+                    variant="outline"
+                    size="sm"
+                    className="mt-1 w-full text-xs"
+                    onClick={handleAiAnalyze}
+                    disabled={analyzing}
+                  >
+                    {analyzing ? (
+                      <Loader2 className="mr-1 h-3 w-3 animate-spin" />
+                    ) : (
+                      <Sparkles className="mr-1 h-3 w-3" />
+                    )}
+                    {t('form.aiAnalyze')}
+                  </Button>
+                )}
               </div>
-              {gallery.length > 0 && (
-                <Button
-                  type="button"
-                  variant="outline"
-                  size="sm"
-                  className="mt-1 w-full text-xs"
-                  onClick={handleAiAnalyze}
-                  disabled={analyzing}
-                >
-                  {analyzing ? (
-                    <Loader2 className="mr-1 h-3 w-3 animate-spin" />
-                  ) : (
-                    <Sparkles className="mr-1 h-3 w-3" />
-                  )}
-                  {t('form.aiAnalyze')}
-                </Button>
-              )}
-            </div>
 
-            {/* Fields */}
-            <div className="flex-1 space-y-4">
-              <div className="grid grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-4">
-                <div className="space-y-2">
-                  <Label htmlFor="partNumber">{t('form.partNumber')}</Label>
-                  <Input
-                    id="partNumber"
-                    name="partNumber"
-                    placeholder={t('form.partNumberPlaceholder')}
-                    defaultValue={part?.partNumber ?? ''}
-                  />
-                </div>
-                <div className="space-y-2">
-                  <Label htmlFor="barcode">{t('form.barcode')}</Label>
-                  <Input
-                    id="barcode"
-                    name="barcode"
-                    placeholder={t('form.barcodePlaceholder')}
-                    defaultValue={part?.barcode ?? initialBarcode ?? ''}
-                  />
-                </div>
-                <div className="space-y-2">
-                  <Label htmlFor="name">{t('form.nameLabel')}</Label>
-                  <Input
-                    id="name"
-                    name="name"
-                    placeholder={t('form.namePlaceholder')}
-                    defaultValue={part?.name}
-                    required
-                  />
+              {/* Fields */}
+              <div className="flex-1 space-y-4">
+                <div className="grid grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-4">
+                  <div className="space-y-2">
+                    <Label htmlFor="partNumber">{t('form.partNumber')}</Label>
+                    <Input
+                      id="partNumber"
+                      name="partNumber"
+                      placeholder={t('form.partNumberPlaceholder')}
+                      defaultValue={part?.partNumber ?? ''}
+                    />
+                  </div>
+                  <div className="space-y-2">
+                    <Label htmlFor="barcode">{t('form.barcode')}</Label>
+                    <Input
+                      id="barcode"
+                      name="barcode"
+                      placeholder={t('form.barcodePlaceholder')}
+                      defaultValue={part?.barcode ?? initialBarcode ?? ''}
+                    />
+                  </div>
+                  <div className="space-y-2">
+                    <Label htmlFor="name">{t('form.nameLabel')}</Label>
+                    <Input
+                      id="name"
+                      name="name"
+                      placeholder={t('form.namePlaceholder')}
+                      defaultValue={part?.name}
+                      required
+                    />
+                  </div>
                 </div>
-              </div>
 
-              <div className="grid grid-cols-2 gap-3 sm:gap-4">
-                <div className="space-y-2">
-                  <Label>{t('form.category')}</Label>
-                  <Popover open={categoryOpen} onOpenChange={setCategoryOpen} modal>
-                    <PopoverTrigger asChild>
-                      <Button
-                        type="button"
-                        variant="outline"
-                        role="combobox"
-                        aria-expanded={categoryOpen}
-                        className="w-full justify-between font-normal h-9"
+                <div className="grid grid-cols-2 gap-3 sm:gap-4">
+                  <div className="space-y-2">
+                    <Label>{t('form.category')}</Label>
+                    <Popover open={categoryOpen} onOpenChange={setCategoryOpen} modal>
+                      <PopoverTrigger asChild>
+                        <Button
+                          type="button"
+                          variant="outline"
+                          role="combobox"
+                          aria-expanded={categoryOpen}
+                          className="w-full justify-between font-normal h-9"
+                        >
+                          <span className={category ? '' : 'text-muted-foreground'}>
+                            {category || t('form.categoryPlaceholder')}
+                          </span>
+                          <ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
+                        </Button>
+                      </PopoverTrigger>
+                      <PopoverContent
+                        className="w-(--radix-popover-trigger-width) min-w-64 p-0"
+                        align="start"
                       >
-                        <span className={category ? '' : 'text-muted-foreground'}>
-                          {category || t('form.categoryPlaceholder')}
-                        </span>
-                        <ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
-                      </Button>
-                    </PopoverTrigger>
-                    <PopoverContent
-                      className="w-(--radix-popover-trigger-width) min-w-64 p-0"
-                      align="start"
-                    >
-                      <Command>
-                        <CommandInput
-                          placeholder={t('form.categorySearch')}
-                          value={categorySearch}
-                          onValueChange={setCategorySearch}
-                        />
-                        <CommandList>
-                          <CommandEmpty>
-                            {categorySearch.trim() ? (
-                              <button
-                                type="button"
-                                className="flex w-full items-center gap-2 px-2 py-1.5 text-sm hover:bg-accent rounded-sm"
-                                onClick={() => {
-                                  setCategory(categorySearch.trim())
-                                  setCategorySearch('')
-                                  setCategoryOpen(false)
-                                }}
-                              >
-                                <Plus className="h-3.5 w-3.5" />
-                                {t('form.categoryCreate', { name: categorySearch.trim() })}
-                              </button>
-                            ) : (
-                              <span className="text-muted-foreground text-xs">
-                                {t('form.categoryNone')}
-                              </span>
-                            )}
-                          </CommandEmpty>
-                          <CommandGroup>
-                            {categories.map((cat) => (
-                              <CommandItem
-                                key={cat}
-                                value={cat}
-                                onSelect={() => {
-                                  setCategory(cat === category ? '' : cat)
-                                  setCategorySearch('')
-                                  setCategoryOpen(false)
-                                }}
-                              >
-                                <Check
-                                  className={cn(
-                                    'mr-2 h-3.5 w-3.5',
-                                    category === cat ? 'opacity-100' : 'opacity-0'
-                                  )}
-                                />
-                                {cat}
-                              </CommandItem>
-                            ))}
-                            {categorySearch.trim() &&
-                              !categories.some(
-                                (c) => c.toLowerCase() === categorySearch.trim().toLowerCase()
-                              ) && (
-                                <CommandItem
-                                  value={`__create__${categorySearch.trim()}`}
-                                  onSelect={() => {
+                        <Command>
+                          <CommandInput
+                            placeholder={t('form.categorySearch')}
+                            value={categorySearch}
+                            onValueChange={setCategorySearch}
+                          />
+                          <CommandList>
+                            <CommandEmpty>
+                              {categorySearch.trim() ? (
+                                <button
+                                  type="button"
+                                  className="flex w-full items-center gap-2 px-2 py-1.5 text-sm hover:bg-accent rounded-sm"
+                                  onClick={() => {
                                     setCategory(categorySearch.trim())
                                     setCategorySearch('')
                                     setCategoryOpen(false)
                                   }}
                                 >
-                                  <Plus className="mr-2 h-3.5 w-3.5" />
+                                  <Plus className="h-3.5 w-3.5" />
                                   {t('form.categoryCreate', { name: categorySearch.trim() })}
-                                </CommandItem>
+                                </button>
+                              ) : (
+                                <span className="text-muted-foreground text-xs">
+                                  {t('form.categoryNone')}
+                                </span>
                               )}
-                          </CommandGroup>
-                        </CommandList>
-                      </Command>
-                    </PopoverContent>
-                  </Popover>
-                </div>
-                <div className="space-y-2">
-                  <Label htmlFor="location">{t('form.location')}</Label>
-                  <Input
-                    id="location"
-                    name="location"
-                    placeholder={t('form.locationPlaceholder')}
-                    defaultValue={part?.location ?? ''}
-                  />
+                            </CommandEmpty>
+                            <CommandGroup>
+                              {categories.map((cat) => (
+                                <CommandItem
+                                  key={cat}
+                                  value={cat}
+                                  onSelect={() => {
+                                    setCategory(cat === category ? '' : cat)
+                                    setCategorySearch('')
+                                    setCategoryOpen(false)
+                                  }}
+                                >
+                                  <Check
+                                    className={cn(
+                                      'mr-2 h-3.5 w-3.5',
+                                      category === cat ? 'opacity-100' : 'opacity-0'
+                                    )}
+                                  />
+                                  {cat}
+                                </CommandItem>
+                              ))}
+                              {categorySearch.trim() &&
+                                !categories.some(
+                                  (c) => c.toLowerCase() === categorySearch.trim().toLowerCase()
+                                ) && (
+                                  <CommandItem
+                                    value={`__create__${categorySearch.trim()}`}
+                                    onSelect={() => {
+                                      setCategory(categorySearch.trim())
+                                      setCategorySearch('')
+                                      setCategoryOpen(false)
+                                    }}
+                                  >
+                                    <Plus className="mr-2 h-3.5 w-3.5" />
+                                    {t('form.categoryCreate', { name: categorySearch.trim() })}
+                                  </CommandItem>
+                                )}
+                            </CommandGroup>
+                          </CommandList>
+                        </Command>
+                      </PopoverContent>
+                    </Popover>
+                  </div>
+                  <div className="space-y-2">
+                    <Label htmlFor="location">{t('form.location')}</Label>
+                    <Input
+                      id="location"
+                      name="location"
+                      placeholder={t('form.locationPlaceholder')}
+                      defaultValue={part?.location ?? ''}
+                    />
+                  </div>
                 </div>
-              </div>
 
-              <div className="grid grid-cols-2 gap-3 sm:grid-cols-5 sm:gap-4">
                 <div className="space-y-2">
-                  <Label htmlFor="quantity">{t('form.quantity')}</Label>
-                  <Input
-                    id="quantity"
-                    name="quantity"
-                    type="number"
-                    min="0"
-                    step="any"
-                    defaultValue={part?.quantity ?? 1}
+                  <Label htmlFor="part-tags">{t('form.tags')}</Label>
+                  <TagInput
+                    id="part-tags"
+                    name="tagDraft"
+                    value={tags}
+                    onChange={setTags}
+                    suggestions={tagSuggestions}
+                    placeholder={t('form.tagsPlaceholder')}
+                    maxTags={MAX_PART_TAGS}
+                    maxLength={MAX_TAG_LENGTH}
+                    tooLongMessage={t('form.tagTooLong', { max: MAX_TAG_LENGTH })}
+                    removeLabel={(tag) => t('form.removeTag', { tag })}
                   />
+                  <p className="text-xs text-muted-foreground">
+                    {isMarine ? t('form.tagsHintMarine') : t('form.tagsHint')}
+                  </p>
                 </div>
-                <div className="space-y-2">
-                  <Label htmlFor="unit">{t('form.unit')}</Label>
-                  <UnitCombobox id="unit" value={unit} onChange={setUnit} unitSystem={unitSystem} />
-                </div>
-                <div className="space-y-2">
-                  <Label htmlFor="minQuantity">{t('form.minQty')}</Label>
-                  <Input
-                    id="minQuantity"
-                    name="minQuantity"
-                    type="number"
-                    min="0"
-                    step="any"
-                    defaultValue={part?.minQuantity ?? 0}
-                  />
-                </div>
-                <div className="space-y-2">
-                  <Label htmlFor="unitCost">{t('form.unitCost')}</Label>
-                  <Input
-                    id="unitCost"
-                    name="unitCost"
-                    type="number"
-                    min="0"
-                    step="0.01"
-                    defaultValue={part?.unitCost ?? 0}
-                    onChange={(e) => {
-                      if (!markupMultiplier || markupMultiplier <= 0 || sellPriceManualRef.current)
-                        return
-                      const cost = Number(e.target.value) || 0
-                      const sellPriceInput = document.getElementById(
-                        'sellPrice'
-                      ) as HTMLInputElement | null
-                      if (sellPriceInput) {
-                        sellPriceInput.value = String(
-                          priceFromCostAndMultiplier(cost, markupMultiplier)
+
+                <div className="grid grid-cols-2 gap-3 sm:grid-cols-5 sm:gap-4">
+                  <div className="space-y-2">
+                    <Label htmlFor="quantity">{t('form.quantity')}</Label>
+                    <Input
+                      id="quantity"
+                      name="quantity"
+                      type="number"
+                      min="0"
+                      step="any"
+                      defaultValue={part?.quantity ?? 1}
+                    />
+                  </div>
+                  <div className="space-y-2">
+                    <Label htmlFor="unit">{t('form.unit')}</Label>
+                    <UnitCombobox
+                      id="unit"
+                      value={unit}
+                      onChange={setUnit}
+                      unitSystem={unitSystem}
+                    />
+                  </div>
+                  <div className="space-y-2">
+                    <Label htmlFor="minQuantity">{t('form.minQty')}</Label>
+                    <Input
+                      id="minQuantity"
+                      name="minQuantity"
+                      type="number"
+                      min="0"
+                      step="any"
+                      defaultValue={part?.minQuantity ?? 0}
+                    />
+                  </div>
+                  <div className="space-y-2">
+                    <Label htmlFor="unitCost">{t('form.unitCost')}</Label>
+                    <Input
+                      id="unitCost"
+                      name="unitCost"
+                      type="number"
+                      min="0"
+                      step="0.01"
+                      defaultValue={part?.unitCost ?? 0}
+                      onChange={(e) => {
+                        if (
+                          !markupMultiplier ||
+                          markupMultiplier <= 0 ||
+                          sellPriceManualRef.current
                         )
-                      }
-                    }}
-                  />
-                </div>
-                <div className="space-y-2">
-                  <Label htmlFor="sellPrice">{t('form.sellPrice')}</Label>
-                  <Input
-                    id="sellPrice"
-                    name="sellPrice"
-                    type="number"
-                    min="0"
-                    step="0.01"
-                    defaultValue={part?.sellPrice ?? 0}
-                    onChange={() => {
-                      sellPriceManualRef.current = true
-                    }}
-                  />
+                          return
+                        const cost = Number(e.target.value) || 0
+                        const sellPriceInput = document.getElementById(
+                          'sellPrice'
+                        ) as HTMLInputElement | null
+                        if (sellPriceInput) {
+                          sellPriceInput.value = String(
+                            priceFromCostAndMultiplier(cost, markupMultiplier)
+                          )
+                        }
+                      }}
+                    />
+                  </div>
+                  <div className="space-y-2">
+                    <Label htmlFor="sellPrice">{t('form.sellPrice')}</Label>
+                    <Input
+                      id="sellPrice"
+                      name="sellPrice"
+                      type="number"
+                      min="0"
+                      step="0.01"
+                      defaultValue={part?.sellPrice ?? 0}
+                      onChange={() => {
+                        sellPriceManualRef.current = true
+                      }}
+                    />
+                  </div>
                 </div>
               </div>
             </div>
-          </div>
 
-          <div className="grid grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-4">
-            <div className="space-y-2">
-              <Label htmlFor="supplier">{t('form.supplier')}</Label>
-              <Input
-                id="supplier"
-                name="supplier"
-                placeholder={t('form.supplierPlaceholder')}
-                defaultValue={part?.supplier ?? ''}
-              />
-            </div>
-            <div className="space-y-2">
-              <Label htmlFor="supplierPhone">{t('form.supplierPhone')}</Label>
-              <Input
-                id="supplierPhone"
-                name="supplierPhone"
-                placeholder={t('form.supplierPhonePlaceholder')}
-                defaultValue={part?.supplierPhone ?? ''}
-              />
+            <div className="grid grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-4">
+              <div className="space-y-2">
+                <Label htmlFor="supplier">{t('form.supplier')}</Label>
+                <Input
+                  id="supplier"
+                  name="supplier"
+                  placeholder={t('form.supplierPlaceholder')}
+                  defaultValue={part?.supplier ?? ''}
+                />
+              </div>
+              <div className="space-y-2">
+                <Label htmlFor="supplierPhone">{t('form.supplierPhone')}</Label>
+                <Input
+                  id="supplierPhone"
+                  name="supplierPhone"
+                  placeholder={t('form.supplierPhonePlaceholder')}
+                  defaultValue={part?.supplierPhone ?? ''}
+                />
+              </div>
+              <div className="space-y-2">
+                <Label htmlFor="supplierEmail">{t('form.supplierEmail')}</Label>
+                <Input
+                  id="supplierEmail"
+                  name="supplierEmail"
+                  type="email"
+                  placeholder={t('form.supplierEmailPlaceholder')}
+                  defaultValue={part?.supplierEmail ?? ''}
+                />
+              </div>
             </div>
+
             <div className="space-y-2">
-              <Label htmlFor="supplierEmail">{t('form.supplierEmail')}</Label>
-              <Input
-                id="supplierEmail"
-                name="supplierEmail"
-                type="email"
-                placeholder={t('form.supplierEmailPlaceholder')}
-                defaultValue={part?.supplierEmail ?? ''}
+              <Label htmlFor="description">{t('form.description')}</Label>
+              <Textarea
+                id="description"
+                name="description"
+                placeholder={t('form.descriptionPlaceholder')}
+                rows={3}
+                defaultValue={part?.description ?? ''}
               />
             </div>
-          </div>
-
-          <div className="space-y-2">
-            <Label htmlFor="description">{t('form.description')}</Label>
-            <Textarea
-              id="description"
-              name="description"
-              placeholder={t('form.descriptionPlaceholder')}
-              rows={3}
-              defaultValue={part?.description ?? ''}
-            />
-          </div>
-
           </DialogBody>
 
           <DialogFooter>

+ 8 - 3
src/features/inventory/Components/PartNameSuggestions.tsx

@@ -19,17 +19,21 @@ export interface PartSuggestion {
   unitCost: number
   sellPrice: number
   quantity: number
+  tags?: string[]
 }
 
 const MAX_SUGGESTIONS = 3
 const MIN_QUERY_LENGTH = 2
+/** The rank of a part that does not answer the query at all. */
+const NO_MATCH = 7
 
 /**
  * Rank matches so the most useful three surface.
  *
  * A prefix match on the name is what someone typing "bra..." for "Brake pad"
  * expects to see first; an exact part-number hit is even stronger, since that
- * is a deliberate lookup rather than a guess.
+ * is a deliberate lookup rather than a guess. A tag match ("hilux") comes
+ * last, after anything the name or number says.
  */
 function rank(part: PartSuggestion, query: string): number {
   const name = part.name.toLowerCase()
@@ -41,11 +45,12 @@ function rank(part: PartSuggestion, query: string): number {
   if (name.startsWith(query)) return 3
   if (name.includes(query)) return 4
   if (number.includes(query)) return 5
-  return 6
+  if (part.tags?.some((tag) => tag.toLowerCase().includes(query))) return 6
+  return NO_MATCH
 }
 
 function matches(part: PartSuggestion, query: string): boolean {
-  return rank(part, query) < 6
+  return rank(part, query) < NO_MATCH
 }
 
 /**

+ 74 - 0
src/features/inventory/Components/PartTagChips.tsx

@@ -0,0 +1,74 @@
+'use client'
+
+import { useTranslations } from 'next-intl'
+import { cn } from '@/lib/utils'
+import { sameTag } from '../Lib/partTags'
+
+/**
+ * A part's tags as small chips. With `onPick` each chip is a button (the list
+ * filters by it); without, the chips are only read. Tags in `highlight` are
+ * drawn in the primary colour, which the pickers use for the ones that fit the
+ * job's vehicle, and are shown first so a long list never hides them.
+ */
+export function PartTagChips({
+  tags,
+  max = 4,
+  highlight = [],
+  active,
+  onPick,
+  className,
+}: {
+  tags: readonly string[]
+  /** Chips shown before the rest collapse into "+N". */
+  max?: number
+  highlight?: readonly string[]
+  /** The tag the list is filtered by, drawn as pressed. */
+  active?: string
+  onPick?: (tag: string) => void
+  className?: string
+}) {
+  const t = useTranslations('inventory')
+  if (tags.length === 0) return null
+
+  const isHighlighted = (tag: string) => highlight.some((h) => sameTag(h, tag))
+  const ordered = [...tags.filter(isHighlighted), ...tags.filter((tag) => !isHighlighted(tag))]
+  const shown = ordered.slice(0, max)
+  const hidden = ordered.slice(max)
+
+  return (
+    <span className={cn('flex flex-wrap items-center gap-1', className)}>
+      {shown.map((tag) => {
+        const pressed = active !== undefined && sameTag(active, tag)
+        const chipClass = cn(
+          'inline-flex max-w-40 items-center rounded px-1.5 py-px text-[11px] leading-4 font-medium',
+          isHighlighted(tag) ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground',
+          pressed && 'ring-1 ring-primary'
+        )
+        return onPick ? (
+          <button
+            key={tag}
+            type="button"
+            className={cn(chipClass, 'hover:bg-accent hover:text-accent-foreground')}
+            title={t('tags.filterBy', { tag })}
+            aria-pressed={pressed}
+            onClick={(e) => {
+              e.stopPropagation()
+              onPick(tag)
+            }}
+          >
+            <span className="truncate">{tag}</span>
+          </button>
+        ) : (
+          <span key={tag} className={chipClass} title={tag}>
+            <span className="truncate">{tag}</span>
+          </span>
+        )
+      })}
+      {hidden.length > 0 && (
+        <span className="text-[11px] text-muted-foreground" title={hidden.join(', ')}>
+          {t('tags.more', { count: hidden.length })}
+        </span>
+      )}
+    </span>
+  )
+}

+ 249 - 0
src/features/inventory/Lib/partTags.ts

@@ -0,0 +1,249 @@
+/**
+ * Tags on inventory parts: what is stored, how a search matches them, and how
+ * they say a part fits the vehicle on the job.
+ *
+ * Pure and client-safe, so the form, the pickers and the server agree on the
+ * same rules.
+ */
+
+import { MAKES, canonicalMakeKey } from '@/features/fault-codes/Lib/makes'
+
+/** Longest tag a part can carry, in characters. */
+export const MAX_TAG_LENGTH = 40
+
+/** Most tags one part can carry. */
+export const MAX_PART_TAGS = 30
+
+/** A tag with its edges trimmed and runs of whitespace made one space. */
+export function cleanTag(tag: string): string {
+  return tag.replace(/\s+/g, ' ').trim()
+}
+
+/**
+ * The tags as stored: cleaned, empty ones dropped, and a repeat that differs
+ * only in case dropped too, so "Toyota" and "toyota" are one tag and the
+ * spelling typed first wins.
+ */
+export function normalizePartTags(tags: readonly string[]): string[] {
+  const seen = new Set<string>()
+  const out: string[] = []
+  for (const raw of tags) {
+    const tag = cleanTag(raw)
+    if (!tag) continue
+    const key = tag.toLocaleLowerCase()
+    if (seen.has(key)) continue
+    seen.add(key)
+    out.push(tag)
+  }
+  return out
+}
+
+/** Typed or pasted text split into tags at its commas. */
+export function splitTagInput(text: string): string[] {
+  return text
+    .split(/[,;\n]/)
+    .map(cleanTag)
+    .filter(Boolean)
+}
+
+/** Whether two tags are the same tag, which ignores case. */
+export function sameTag(a: string, b: string): boolean {
+  return cleanTag(a).toLocaleLowerCase() === cleanTag(b).toLocaleLowerCase()
+}
+
+/**
+ * Every distinct tag across the given lists, sorted for a menu. Where the
+ * workshop has spelt one tag two ways, the spelling more parts use is shown.
+ */
+export function distinctTags(lists: readonly (readonly string[])[]): string[] {
+  const byKey = new Map<string, Map<string, number>>()
+  for (const list of lists) {
+    for (const raw of list) {
+      const tag = cleanTag(raw)
+      if (!tag) continue
+      const key = tag.toLocaleLowerCase()
+      const spellings = byKey.get(key) ?? new Map<string, number>()
+      spellings.set(tag, (spellings.get(tag) ?? 0) + 1)
+      byKey.set(key, spellings)
+    }
+  }
+  const tags = [...byKey.values()].map(
+    (spellings) => [...spellings.entries()].sort((a, b) => b[1] - a[1])[0][0]
+  )
+  return tags.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true }))
+}
+
+/** The fields of a part a search looks through. */
+export interface SearchablePart {
+  name: string
+  partNumber?: string | null
+  barcode?: string | null
+  category?: string | null
+  description?: string | null
+  tags?: readonly string[] | null
+}
+
+/**
+ * Whether a part answers a typed search: every word of the query appears in
+ * one of its fields or tags, in any order, so "filter hilux" finds a fuel
+ * filter tagged "Hilux".
+ */
+export function partMatchesQuery(part: SearchablePart, query: string): boolean {
+  const words = query.toLocaleLowerCase().split(/\s+/).filter(Boolean)
+  if (words.length === 0) return true
+  const fields = [
+    part.name,
+    part.partNumber,
+    part.barcode,
+    part.category,
+    part.description,
+    ...(part.tags ?? []),
+  ]
+    .filter((field): field is string => Boolean(field))
+    .map((field) => field.toLocaleLowerCase())
+  return words.every((word) => fields.some((field) => field.includes(word)))
+}
+
+/** The vehicle a part is being picked for. */
+export interface FitVehicle {
+  make?: string | null
+  model?: string | null
+}
+
+/** Letters and digits only, lower case: "Hilux 2015-2020" is hilux, 2015, 2020. */
+function wordsOf(text: string | null | undefined): string[] {
+  return (text ?? '')
+    .toLocaleLowerCase()
+    .split(/[^\p{L}\p{N}]+/u)
+    .filter(Boolean)
+}
+
+/** Whether `phrase` appears in `words` as whole words, in order. */
+function containsPhrase(words: string[], phrase: string[]): boolean {
+  if (phrase.length === 0 || phrase.length > words.length) return false
+  for (let start = 0; start + phrase.length <= words.length; start++) {
+    if (phrase.every((word, i) => words[start + i] === word)) return true
+  }
+  return false
+}
+
+const isYear = (word: string) => /^(19|20)\d{2}$/.test(word)
+const hasLetter = (word: string) => /\p{L}/u.test(word)
+
+/** Every way a known make is written, as words: its name, its key and its aliases. */
+const MAKE_PHRASES: { key: string; words: string[] }[] = MAKES.flatMap((make) =>
+  [make.name, make.key, ...(make.aliases ?? [])].map((text) => ({
+    key: make.key,
+    words: wordsOf(text),
+  }))
+)
+
+/** Whether `words` begins with `phrase`. */
+function startsWith(words: string[], phrase: string[]): boolean {
+  return (
+    phrase.length > 0 && phrase.length <= words.length && phrase.every((w, i) => words[i] === w)
+  )
+}
+
+/**
+ * Whether the tag opens with a make other than the vehicle's: "Toyota
+ * Sprinter" on a Mercedes-Benz. Only the start is read, so "Hilux seat cover"
+ * is not taken for a SEAT; the longest make wins, so "Volvo Penta" is not a
+ * Volvo; and another way of writing the vehicle's own make ("Mercedes", "VW",
+ * "Vauxhall" for an Opel) is the same make.
+ */
+function opensWithAnotherMake(tagWords: string[], vehicle: FitVehicle): boolean {
+  const ownWords = wordsOf(vehicle.make)
+  if (ownWords.length === 0) return false
+  const own = canonicalMakeKey(vehicle.make)
+  const opening = MAKE_PHRASES.filter(({ words }) => startsWith(tagWords, words)).sort(
+    (a, b) => b.words.length - a.words.length
+  )[0]
+  if (!opening || opening.key === own) return false
+  // A make the list does not know, written out in full ("Volvo Penta" when the
+  // list only had "Volvo"), is still the vehicle's own.
+  return !(startsWith(tagWords, ownWords) && ownWords.length >= opening.words.length)
+}
+
+/**
+ * How a tag relates to the vehicle: `model` when it names the vehicle's
+ * model ("Hilux", "Toyota Hilux", "Hilux 2015-2020"), `make` when it names
+ * only the make, perhaps with years ("Toyota", "Toyota 2010"), else null.
+ *
+ * A tag naming the make and some other model ("Toyota Corolla" on a Hilux) is
+ * no match: it says which Toyota the part fits, and it is not this one. Nor
+ * is a tag opening with another make ("Toyota Sprinter" on a Mercedes-Benz
+ * Sprinter), whatever model follows.
+ */
+export function tagFit(tag: string, vehicle: FitVehicle): 'model' | 'make' | null {
+  const tagWords = wordsOf(tag)
+  if (opensWithAnotherMake(tagWords, vehicle)) return null
+  // Years narrow a tag ("Hilux 2015-2020") but do not name anything. Other
+  // numbers do ("3-Serie" is not a "5-Serie"), though a tag needs a word with
+  // letters in it: "50" alone, from "BT-50", is too weak to count.
+  const named = tagWords.filter((word) => !isYear(word))
+  if (!named.some(hasLetter)) return null
+  const modelWords = wordsOf(vehicle.model)
+  const makeWords = wordsOf(vehicle.make)
+  const onlyMake = makeWords.length > 0 && named.every((word) => makeWords.includes(word))
+  // What the tag says besides the make: "Hilux" of "Toyota Hilux".
+  const beyondMake = named.filter((word) => !makeWords.includes(word))
+
+  if (modelWords.length > 0) {
+    if (containsPhrase(tagWords, modelWords)) return 'model'
+    // A shorter name inside a longer model field: "Hilux" or "Toyota Hilux" on
+    // a "Hilux SR5". Not when the tag is only the make and the model field
+    // repeats it.
+    if (!onlyMake && beyondMake.some(hasLetter) && containsPhrase(modelWords, beyondMake)) {
+      return 'model'
+    }
+  }
+
+  if (onlyMake && (containsPhrase(named, makeWords) || containsPhrase(makeWords, named))) {
+    return 'make'
+  }
+
+  return null
+}
+
+/**
+ * How strongly a part's tags say it fits the vehicle: 2 when a tag names the
+ * model, 1 when one names only the make, 0 when none does.
+ */
+export function vehicleFitScore(
+  tags: readonly string[] | null | undefined,
+  vehicle: FitVehicle | null | undefined
+): number {
+  if (!vehicle || !tags?.length) return 0
+  let score = 0
+  for (const tag of tags) {
+    const fit = tagFit(tag, vehicle)
+    if (fit === 'model') return 2
+    if (fit === 'make') score = 1
+  }
+  return score
+}
+
+/** The tags that say a part fits the vehicle, for showing which ones matched. */
+export function fittingTags(
+  tags: readonly string[] | null | undefined,
+  vehicle: FitVehicle | null | undefined
+): string[] {
+  if (!vehicle || !tags?.length) return []
+  return tags.filter((tag) => tagFit(tag, vehicle) !== null)
+}
+
+/**
+ * The parts with those whose tags fit the vehicle first, model matches before
+ * make matches; otherwise in the order given.
+ */
+export function rankByVehicleFit<T extends { tags?: readonly string[] | null }>(
+  parts: readonly T[],
+  vehicle: FitVehicle | null | undefined
+): T[] {
+  if (!vehicle) return [...parts]
+  return parts
+    .map((part, index) => ({ part, index, score: vehicleFitScore(part.tags, vehicle) }))
+    .sort((a, b) => b.score - a.score || a.index - b.index)
+    .map(({ part }) => part)
+}

+ 49 - 0
src/features/inventory/Lib/tagSearch.server.ts

@@ -0,0 +1,49 @@
+import 'server-only'
+import { db } from '@/lib/db'
+
+/**
+ * Finding parts by tag on the server.
+ *
+ * Tags are a text array, and Prisma can only test an array for an exact,
+ * case-sensitive element. A search needs "contains, any case", so the ids are
+ * found in SQL here and the caller filters on them, the same way the low-stock
+ * filter does.
+ */
+
+/** A LIKE pattern for text containing `term`, where `%` and `_` mean themselves. */
+function containsPattern(term: string): string {
+  return `%${term.replace(/[\\%_]/g, (c) => `\\${c}`)}%`
+}
+
+/** This workshop's parts with a tag containing `term`, in any case. */
+export async function partIdsWithTagContaining(
+  organizationId: string,
+  term: string
+): Promise<string[]> {
+  if (!term.trim()) return []
+  const rows = await db.$queryRaw<{ id: string }[]>`
+    SELECT "id"
+    FROM "public"."inventory_parts"
+    WHERE "organizationId" = ${organizationId}
+      AND "isArchived" = false
+      AND EXISTS (
+        SELECT 1 FROM unnest("tags") AS t(tag) WHERE t.tag ILIKE ${containsPattern(term.trim())}
+      )
+  `
+  return rows.map((row) => row.id)
+}
+
+/** This workshop's parts carrying `tag`, in any case. */
+export async function partIdsWithTag(organizationId: string, tag: string): Promise<string[]> {
+  if (!tag.trim()) return []
+  const rows = await db.$queryRaw<{ id: string }[]>`
+    SELECT "id"
+    FROM "public"."inventory_parts"
+    WHERE "organizationId" = ${organizationId}
+      AND "isArchived" = false
+      AND EXISTS (
+        SELECT 1 FROM unnest("tags") AS t(tag) WHERE lower(t.tag) = lower(${tag.trim()})
+      )
+  `
+  return rows.map((row) => row.id)
+}

+ 9 - 0
src/features/inventory/Schema/inventorySchema.ts

@@ -1,5 +1,6 @@
 import { z } from 'zod'
 import { imageUrlSchema } from '@/lib/upload-url'
+import { MAX_PART_TAGS, MAX_TAG_LENGTH } from '../Lib/partTags'
 
 export const createInventoryPartSchema = z.object({
   name: z.string().min(1, 'Part name is required'),
@@ -29,6 +30,14 @@ export const createInventoryPartSchema = z.object({
     .optional()
     .default([]),
   location: z.string().optional(),
+  // No default: on an update, leaving tags out keeps the part's own, and a
+  // page opened before tags existed sends none.
+  tags: z
+    .array(
+      z.string().trim().max(MAX_TAG_LENGTH, `A tag can be at most ${MAX_TAG_LENGTH} characters`)
+    )
+    .max(MAX_PART_TAGS, `A part can have at most ${MAX_PART_TAGS} tags`)
+    .optional(),
 })
 
 export const updateInventoryPartSchema = createInventoryPartSchema.partial().extend({

+ 5 - 7
src/features/labor-presets/Components/InventorySearchDialog.tsx

@@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl'
 import { Input } from '@/components/ui/input'
 import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
 import { Search } from 'lucide-react'
+import { partMatchesQuery } from '@/features/inventory/Lib/partTags'
 
 export interface InventoryPartOption {
   id: string
@@ -16,6 +17,7 @@ export interface InventoryPartOption {
   unitCost: number
   /** On-hand stock, shown by the inline name suggestions. */
   quantity: number
+  tags?: string[]
 }
 
 interface InventorySearchDialogProps {
@@ -34,13 +36,9 @@ export function InventorySearchDialog({
   const t = useTranslations('laborPresets')
   const [search, setSearch] = useState('')
 
-  const filtered = inventoryParts.filter((p) => {
-    if (!search) return true
-    const q = search.toLowerCase()
-    return (
-      p.name.toLowerCase().includes(q) || (p.partNumber && p.partNumber.toLowerCase().includes(q))
-    )
-  })
+  const filtered = search
+    ? inventoryParts.filter((p) => partMatchesQuery(p, search))
+    : inventoryParts
 
   return (
     <Dialog

+ 184 - 186
src/features/labor-presets/Components/LaborPresetForm.tsx

@@ -79,6 +79,8 @@ interface LaborPresetFormProps {
     sellPrice: number
     unitCost: number
     quantity: number
+    /** Searched by the preset's part picker and name suggestions. */
+    tags?: string[]
   }[]
   currencyCode?: string
 }
@@ -373,10 +375,7 @@ export function LaborPresetForm({
 
   return (
     <Dialog open={open} onOpenChange={handleOpenChange}>
-      <DialogContent
-        className="max-h-[90vh] sm:max-w-2xl"
-        aria-describedby={undefined}
-      >
+      <DialogContent className="max-h-[90vh] sm:max-w-2xl" aria-describedby={undefined}>
         <DialogHeader>
           <DialogTitle>{preset ? t('form.editPackage') : t('form.addPackage')}</DialogTitle>
           <DocsLink
@@ -387,217 +386,216 @@ export function LaborPresetForm({
 
           {/* Mode tabs, pinned with the title so the switch is always in reach */}
           <div className="mt-1 flex rounded-lg border bg-muted/50 p-0.5">
-          <button
-            type="button"
-            className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
-              mode === 'single'
-                ? 'bg-background text-foreground shadow-sm'
-                : 'text-muted-foreground hover:text-foreground'
-            }`}
-            onClick={() => handleModeChange('single')}
-          >
-            {t('form.singleItem')}
-          </button>
-          <button
-            type="button"
-            className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
-              mode === 'package'
-                ? 'bg-background text-foreground shadow-sm'
-                : 'text-muted-foreground hover:text-foreground'
-            }`}
-            onClick={() => handleModeChange('package')}
-          >
-            {t('form.package')}
-          </button>
+            <button
+              type="button"
+              className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
+                mode === 'single'
+                  ? 'bg-background text-foreground shadow-sm'
+                  : 'text-muted-foreground hover:text-foreground'
+              }`}
+              onClick={() => handleModeChange('single')}
+            >
+              {t('form.singleItem')}
+            </button>
+            <button
+              type="button"
+              className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
+                mode === 'package'
+                  ? 'bg-background text-foreground shadow-sm'
+                  : 'text-muted-foreground hover:text-foreground'
+              }`}
+              onClick={() => handleModeChange('package')}
+            >
+              {t('form.package')}
+            </button>
           </div>
         </DialogHeader>
 
         <form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col">
           <DialogBody className="space-y-4">
-          {mode === 'single' ? (
-            <div className="space-y-2">
-              <div className="hidden grid-cols-[2fr_1fr_1fr_auto] gap-2 text-xs font-medium text-muted-foreground sm:grid">
-                <span>{t('form.singleNameLabel')}</span>
-                <span>{t('form.itemQtyOrHours')}</span>
-                <span>{t('form.itemRate')}</span>
-                <span />
-              </div>
-
-              {singleItems.map((item, i) => (
-                <div key={i} className="space-y-2">
-                  <div className="grid grid-cols-2 gap-2 sm:grid-cols-[2fr_1fr_1fr_auto]">
-                    <div className="col-span-2 flex gap-2 sm:col-span-1">
-                      <Input
-                        placeholder={t('form.singleNamePlaceholder')}
-                        value={item.name}
-                        onChange={(e) => updateSingleItem(i, 'name', e.target.value)}
-                        className="flex-1"
-                      />
-                      <PricingTypeToggle
-                        value={item.pricingType}
-                        onChange={(v) => updateSingleItem(i, 'pricingType', v)}
-                        tHourly={t('form.hourlyTag')}
-                        tService={t('form.serviceTag')}
-                      />
-                    </div>
-                    <Input
-                      type="number"
-                      min="0"
-                      step={item.pricingType === 'service' ? '1' : 'any'}
-                      placeholder={
-                        item.pricingType === 'service' ? t('form.itemQty') : t('form.itemHours')
-                      }
-                      value={item.hours}
-                      onChange={(e) => updateSingleItem(i, 'hours', e.target.value)}
-                    />
-                    <Input
-                      type="number"
-                      min="0"
-                      step="0.01"
-                      value={item.rate}
-                      onChange={(e) => updateSingleItem(i, 'rate', e.target.value)}
-                    />
-                    <Button
-                      type="button"
-                      variant="ghost"
-                      size="icon"
-                      className="h-9 w-9 text-muted-foreground hover:text-destructive"
-                      onClick={() => removeSingleItem(i)}
-                      disabled={singleItems.length <= 1}
-                      aria-label={t('actions.delete')}
-                    >
-                      <Trash2 className="h-4 w-4" />
-                    </Button>
-                  </div>
-                  <Textarea
-                    placeholder={t('form.descriptionPlaceholder')}
-                    value={item.description}
-                    onChange={(e) => updateSingleItem(i, 'description', e.target.value)}
-                    rows={2}
-                  />
-                </div>
-              ))}
-
-              <button
-                type="button"
-                className="flex w-full items-center justify-center rounded-md border border-dashed border-muted-foreground/25 py-1.5 text-muted-foreground transition-colors hover:border-muted-foreground/50 hover:text-foreground"
-                onClick={addSingleItem}
-              >
-                <Plus className="h-4 w-4" />
-              </button>
-
-              <PresetPartsEditor
-                parts={parts}
-                onPartsChange={setParts}
-                inventoryParts={inventoryParts}
-                currencyCode={currencyCode}
-              />
-            </div>
-          ) : (
-            <>
-              <div className="space-y-2">
-                <Label htmlFor="preset-name">{t('form.nameLabel')}</Label>
-                <Input
-                  id="preset-name"
-                  placeholder={t('form.namePlaceholder')}
-                  value={name}
-                  onChange={(e) => setName(e.target.value)}
-                  required
-                />
-              </div>
-
+            {mode === 'single' ? (
               <div className="space-y-2">
-                <Label htmlFor="preset-description">{t('form.descriptionLabel')}</Label>
-                <Textarea
-                  id="preset-description"
-                  placeholder={t('form.descriptionPlaceholder')}
-                  value={description}
-                  onChange={(e) => setDescription(e.target.value)}
-                  rows={2}
-                />
-              </div>
-
-              <div className="space-y-2">
-                <div className="flex items-center justify-between">
-                  <Label>{t('form.itemsTitle')}</Label>
-                  <Button type="button" variant="outline" size="sm" onClick={addItem}>
-                    <Plus className="mr-1 h-3.5 w-3.5" />
-                    {t('form.addItem')}
-                  </Button>
-                </div>
-
                 <div className="hidden grid-cols-[2fr_1fr_1fr_auto] gap-2 text-xs font-medium text-muted-foreground sm:grid">
-                  <span>{t('form.itemDescription')}</span>
+                  <span>{t('form.singleNameLabel')}</span>
                   <span>{t('form.itemQtyOrHours')}</span>
                   <span>{t('form.itemRate')}</span>
                   <span />
                 </div>
 
-                {items.map((item, i) => (
-                  <div key={i} className="grid grid-cols-2 gap-2 sm:grid-cols-[2fr_1fr_1fr_auto]">
-                    <div className="col-span-2 flex gap-2 sm:col-span-1">
+                {singleItems.map((item, i) => (
+                  <div key={i} className="space-y-2">
+                    <div className="grid grid-cols-2 gap-2 sm:grid-cols-[2fr_1fr_1fr_auto]">
+                      <div className="col-span-2 flex gap-2 sm:col-span-1">
+                        <Input
+                          placeholder={t('form.singleNamePlaceholder')}
+                          value={item.name}
+                          onChange={(e) => updateSingleItem(i, 'name', e.target.value)}
+                          className="flex-1"
+                        />
+                        <PricingTypeToggle
+                          value={item.pricingType}
+                          onChange={(v) => updateSingleItem(i, 'pricingType', v)}
+                          tHourly={t('form.hourlyTag')}
+                          tService={t('form.serviceTag')}
+                        />
+                      </div>
                       <Input
-                        placeholder={t('form.itemDescriptionPlaceholder')}
-                        value={item.description}
-                        onChange={(e) => updateItem(i, 'description', e.target.value)}
-                        className="flex-1"
+                        type="number"
+                        min="0"
+                        step={item.pricingType === 'service' ? '1' : 'any'}
+                        placeholder={
+                          item.pricingType === 'service' ? t('form.itemQty') : t('form.itemHours')
+                        }
+                        value={item.hours}
+                        onChange={(e) => updateSingleItem(i, 'hours', e.target.value)}
                       />
-                      <PricingTypeToggle
-                        value={item.pricingType}
-                        onChange={(v) => updateItem(i, 'pricingType', v)}
-                        tHourly={t('form.hourlyTag')}
-                        tService={t('form.serviceTag')}
+                      <Input
+                        type="number"
+                        min="0"
+                        step="0.01"
+                        value={item.rate}
+                        onChange={(e) => updateSingleItem(i, 'rate', e.target.value)}
                       />
+                      <Button
+                        type="button"
+                        variant="ghost"
+                        size="icon"
+                        className="h-9 w-9 text-muted-foreground hover:text-destructive"
+                        onClick={() => removeSingleItem(i)}
+                        disabled={singleItems.length <= 1}
+                        aria-label={t('actions.delete')}
+                      >
+                        <Trash2 className="h-4 w-4" />
+                      </Button>
                     </div>
-                    <Input
-                      type="number"
-                      min="0"
-                      step={item.pricingType === 'service' ? '1' : 'any'}
-                      placeholder={
-                        item.pricingType === 'service' ? t('form.itemQty') : t('form.itemHours')
-                      }
-                      value={item.hours}
-                      onChange={(e) => updateItem(i, 'hours', e.target.value)}
-                    />
-                    <Input
-                      type="number"
-                      min="0"
-                      step="0.01"
-                      value={item.rate}
-                      onChange={(e) => updateItem(i, 'rate', e.target.value)}
+                    <Textarea
+                      placeholder={t('form.descriptionPlaceholder')}
+                      value={item.description}
+                      onChange={(e) => updateSingleItem(i, 'description', e.target.value)}
+                      rows={2}
                     />
-                    <Button
-                      type="button"
-                      variant="ghost"
-                      size="icon"
-                      className="h-9 w-9 text-muted-foreground hover:text-destructive"
-                      onClick={() => removeItem(i)}
-                      disabled={items.length <= 1}
-                      aria-label={t('actions.delete')}
-                    >
-                      <Trash2 className="h-4 w-4" />
-                    </Button>
                   </div>
                 ))}
 
                 <button
                   type="button"
                   className="flex w-full items-center justify-center rounded-md border border-dashed border-muted-foreground/25 py-1.5 text-muted-foreground transition-colors hover:border-muted-foreground/50 hover:text-foreground"
-                  onClick={addItem}
+                  onClick={addSingleItem}
                 >
                   <Plus className="h-4 w-4" />
                 </button>
+
+                <PresetPartsEditor
+                  parts={parts}
+                  onPartsChange={setParts}
+                  inventoryParts={inventoryParts}
+                  currencyCode={currencyCode}
+                />
               </div>
+            ) : (
+              <>
+                <div className="space-y-2">
+                  <Label htmlFor="preset-name">{t('form.nameLabel')}</Label>
+                  <Input
+                    id="preset-name"
+                    placeholder={t('form.namePlaceholder')}
+                    value={name}
+                    onChange={(e) => setName(e.target.value)}
+                    required
+                  />
+                </div>
 
-              <PresetPartsEditor
-                parts={parts}
-                onPartsChange={setParts}
-                inventoryParts={inventoryParts}
-                currencyCode={currencyCode}
-              />
-            </>
-          )}
+                <div className="space-y-2">
+                  <Label htmlFor="preset-description">{t('form.descriptionLabel')}</Label>
+                  <Textarea
+                    id="preset-description"
+                    placeholder={t('form.descriptionPlaceholder')}
+                    value={description}
+                    onChange={(e) => setDescription(e.target.value)}
+                    rows={2}
+                  />
+                </div>
 
+                <div className="space-y-2">
+                  <div className="flex items-center justify-between">
+                    <Label>{t('form.itemsTitle')}</Label>
+                    <Button type="button" variant="outline" size="sm" onClick={addItem}>
+                      <Plus className="mr-1 h-3.5 w-3.5" />
+                      {t('form.addItem')}
+                    </Button>
+                  </div>
+
+                  <div className="hidden grid-cols-[2fr_1fr_1fr_auto] gap-2 text-xs font-medium text-muted-foreground sm:grid">
+                    <span>{t('form.itemDescription')}</span>
+                    <span>{t('form.itemQtyOrHours')}</span>
+                    <span>{t('form.itemRate')}</span>
+                    <span />
+                  </div>
+
+                  {items.map((item, i) => (
+                    <div key={i} className="grid grid-cols-2 gap-2 sm:grid-cols-[2fr_1fr_1fr_auto]">
+                      <div className="col-span-2 flex gap-2 sm:col-span-1">
+                        <Input
+                          placeholder={t('form.itemDescriptionPlaceholder')}
+                          value={item.description}
+                          onChange={(e) => updateItem(i, 'description', e.target.value)}
+                          className="flex-1"
+                        />
+                        <PricingTypeToggle
+                          value={item.pricingType}
+                          onChange={(v) => updateItem(i, 'pricingType', v)}
+                          tHourly={t('form.hourlyTag')}
+                          tService={t('form.serviceTag')}
+                        />
+                      </div>
+                      <Input
+                        type="number"
+                        min="0"
+                        step={item.pricingType === 'service' ? '1' : 'any'}
+                        placeholder={
+                          item.pricingType === 'service' ? t('form.itemQty') : t('form.itemHours')
+                        }
+                        value={item.hours}
+                        onChange={(e) => updateItem(i, 'hours', e.target.value)}
+                      />
+                      <Input
+                        type="number"
+                        min="0"
+                        step="0.01"
+                        value={item.rate}
+                        onChange={(e) => updateItem(i, 'rate', e.target.value)}
+                      />
+                      <Button
+                        type="button"
+                        variant="ghost"
+                        size="icon"
+                        className="h-9 w-9 text-muted-foreground hover:text-destructive"
+                        onClick={() => removeItem(i)}
+                        disabled={items.length <= 1}
+                        aria-label={t('actions.delete')}
+                      >
+                        <Trash2 className="h-4 w-4" />
+                      </Button>
+                    </div>
+                  ))}
+
+                  <button
+                    type="button"
+                    className="flex w-full items-center justify-center rounded-md border border-dashed border-muted-foreground/25 py-1.5 text-muted-foreground transition-colors hover:border-muted-foreground/50 hover:text-foreground"
+                    onClick={addItem}
+                  >
+                    <Plus className="h-4 w-4" />
+                  </button>
+                </div>
+
+                <PresetPartsEditor
+                  parts={parts}
+                  onPartsChange={setParts}
+                  inventoryParts={inventoryParts}
+                  currencyCode={currencyCode}
+                />
+              </>
+            )}
           </DialogBody>
 
           <DialogFooter>

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

@@ -275,6 +275,7 @@ export function QuotePageClient({
         onAdd={state.addPart}
         onAddBulk={state.addPartBulk}
         inventoryParts={inventoryParts}
+        vehicle={state.selectedVehicle}
         t={t}
       />
       <QuoteLaborEditor

+ 5 - 0
src/features/quotes/Components/QuotePartsEditor.tsx

@@ -12,6 +12,7 @@ import { partTaxable } from '@/lib/taxable-lines'
 import { cn } from '@/lib/utils'
 import { InventoryPickerDialog } from '@/features/vehicles/Components/service-edit/InventoryPickerDialog'
 import type { InventoryPartOption } from '@/features/vehicles/Components/service-edit/form-types'
+import type { FitVehicle } from '@/features/inventory/Lib/partTags'
 import type { QuotePartInput } from './quote-page-types'
 import {
   PartNameSuggestions,
@@ -201,6 +202,8 @@ interface QuotePartsEditorProps {
   onAdd: () => void
   onAddBulk?: (items: QuotePartInput[]) => void
   inventoryParts?: InventoryPartOption[]
+  /** The quote's vehicle, so stock tagged for it is listed first. */
+  vehicle?: FitVehicle | null
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   t: (key: string, values?: any) => string
 }
@@ -214,6 +217,7 @@ export const QuotePartsEditor = memo(function QuotePartsEditor({
   onAdd,
   onAddBulk,
   inventoryParts = [],
+  vehicle,
   t,
 }: QuotePartsEditorProps) {
   const formatCurrency = useFormatCurrency()
@@ -333,6 +337,7 @@ export const QuotePartsEditor = memo(function QuotePartsEditor({
           onOpenChange={setPickerOpen}
           inventoryParts={inventoryParts}
           currencyCode={currencyCode}
+          vehicle={vehicle}
           onSelectPart={(picked) => {
             onAddBulk?.([
               {

+ 33 - 21
src/features/search/Actions/searchActions.ts

@@ -7,6 +7,7 @@ import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { isTireHotelEnabled } from '@/features/tire-hotel/Lib/tireHotelSettings'
 import { searchYear } from '@/features/vehicles/Lib/searchYear'
 import { searchWordsOf, typeKeySearchTerms } from '@/features/vehicles/Lib/typeKey'
+import { partIdsWithTagContaining } from '@/features/inventory/Lib/tagSearch.server'
 import { isTypeKeyEnabled } from '@/features/vehicles/Lib/typeKeySetting'
 
 export async function getRecentCustomers() {
@@ -211,27 +212,38 @@ export async function globalSearch(query: string) {
             },
             take: 10,
           }),
-          db.inventoryPart.findMany({
-            where: {
-              organizationId,
-              isArchived: false,
-              AND: words.map((word) => ({
-                OR: [
-                  { name: { contains: word, mode } },
-                  { partNumber: { contains: word, mode } },
-                  { supplier: { contains: word, mode } },
-                ],
-              })),
-            },
-            select: {
-              id: true,
-              name: true,
-              partNumber: true,
-              quantity: true,
-              unit: true,
-            },
-            take: 10,
-          }),
+          (async () => {
+            // Tags are a text array Prisma cannot search by "contains", so the
+            // parts a word finds in a tag are looked up first, per word, for
+            // the first few words of a query.
+            const tagged = await Promise.all(
+              words.map((word, i) =>
+                i < 8 ? partIdsWithTagContaining(organizationId, word) : Promise.resolve([])
+              )
+            )
+            return db.inventoryPart.findMany({
+              where: {
+                organizationId,
+                isArchived: false,
+                AND: words.map((word, i) => ({
+                  OR: [
+                    { name: { contains: word, mode } },
+                    { partNumber: { contains: word, mode } },
+                    { supplier: { contains: word, mode } },
+                    { id: { in: tagged[i] } },
+                  ],
+                })),
+              },
+              select: {
+                id: true,
+                name: true,
+                partNumber: true,
+                quantity: true,
+                unit: true,
+              },
+              take: 10,
+            })
+          })(),
           db.quote.findMany({
             where: {
               organizationId,

+ 32 - 11
src/features/vehicles/Components/service-edit/InventoryPickerDialog.tsx

@@ -1,6 +1,6 @@
 'use client'
 
-import { useCallback, useEffect, useRef, useState } from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
 import { formatQuantity } from '@/lib/format-quantity'
 import { useTranslations } from 'next-intl'
 import { Input } from '@/components/ui/input'
@@ -10,6 +10,14 @@ import { useFormatCurrency } from '@/components/currency-settings-context'
 import type { ServicePartInput } from '@/features/vehicles/Schema/serviceSchema'
 import type { InventoryPartOption } from './form-types'
 import { resolvePartPrice } from '@/features/inventory/Lib/partPricing'
+import {
+  type FitVehicle,
+  fittingTags,
+  partMatchesQuery,
+  rankByVehicleFit,
+  vehicleFitScore,
+} from '@/features/inventory/Lib/partTags'
+import { PartTagChips } from '@/features/inventory/Components/PartTagChips'
 
 const PAGE_SIZE = 100
 
@@ -23,6 +31,8 @@ interface InventoryPickerDialogProps {
   defaultMarkupPercent?: number
   /** When true, picked inventory parts use cost + default markup instead of sellPrice. */
   markupAppliesToInventory?: boolean
+  /** The job's vehicle: parts tagged with its make or model are listed first. */
+  vehicle?: FitVehicle | null
 }
 
 export function InventoryPickerDialog({
@@ -33,6 +43,7 @@ export function InventoryPickerDialog({
   onSelectPart,
   defaultMarkupPercent = 0,
   markupAppliesToInventory = false,
+  vehicle,
 }: InventoryPickerDialogProps) {
   const formatCurrency = useFormatCurrency()
   const t = useTranslations('service.parts')
@@ -40,16 +51,14 @@ export function InventoryPickerDialog({
   const [visibleCount, setVisibleCount] = useState(PAGE_SIZE)
   const listRef = useRef<HTMLDivElement>(null)
 
-  const filtered = inventoryParts.filter((p) => {
-    if (!search) return true
-    const q = search.toLowerCase()
-    return (
-      p.name.toLowerCase().includes(q) ||
-      (p.partNumber && p.partNumber.toLowerCase().includes(q)) ||
-      (p.barcode && p.barcode.toLowerCase().includes(q)) ||
-      (p.category && p.category.toLowerCase().includes(q))
-    )
-  })
+  // Ranked once per list and vehicle; the search then keeps that order.
+  const ranked = useMemo(() => rankByVehicleFit(inventoryParts, vehicle), [inventoryParts, vehicle])
+  const filtered = search ? ranked.filter((p) => partMatchesQuery(p, search)) : ranked
+  const vehicleName = [vehicle?.make, vehicle?.model].filter(Boolean).join(' ')
+  const anyFits = useMemo(
+    () => !!vehicle && inventoryParts.some((p) => vehicleFitScore(p.tags, vehicle) > 0),
+    [inventoryParts, vehicle]
+  )
 
   const visible = filtered.slice(0, visibleCount)
 
@@ -90,6 +99,11 @@ export function InventoryPickerDialog({
             className="pl-9"
           />
         </div>
+        {anyFits && vehicleName && (
+          <p className="-mt-2 text-xs text-muted-foreground">
+            {t('fitsVehicleFirst', { vehicle: vehicleName })}
+          </p>
+        )}
         <div ref={listRef} onScroll={handleScroll} className="max-h-75 overflow-y-auto space-y-1">
           {visible.map((ip) => (
             <button
@@ -139,6 +153,13 @@ export function InventoryPickerDialog({
                 {ip.description && (
                   <p className="text-xs text-muted-foreground truncate">{ip.description}</p>
                 )}
+                {ip.tags && ip.tags.length > 0 && (
+                  <PartTagChips
+                    tags={ip.tags}
+                    highlight={fittingTags(ip.tags, vehicle)}
+                    className="mt-1"
+                  />
+                )}
               </div>
               <div className="flex items-center gap-3 shrink-0 text-xs text-muted-foreground">
                 {ip.sellPrice > 0 ? (

+ 2 - 0
src/features/vehicles/Components/service-edit/form-types.ts

@@ -17,6 +17,8 @@ export interface InventoryPartOption {
   sellPrice: number
   quantity: number
   category: string | null
+  /** The part's tags; makes and models in them rank it for the job's vehicle. */
+  tags?: string[]
 }
 
 export interface InitialData {

+ 1 - 0
src/features/vehicles/Components/service-page/ServicePageClient.tsx

@@ -728,6 +728,7 @@ export function ServicePageClient({
               onSelectPart={(part) => formState.dirtySetPartItems((prev) => [part, ...prev])}
               defaultMarkupPercent={defaultMarkupPercent}
               markupAppliesToInventory={markupAppliesToInventory}
+              vehicle={record.vehicle}
             />
 
             <LaborPresetPickerDialog

+ 4 - 0
src/lib/backup/import.ts

@@ -912,6 +912,10 @@ export async function restoreBackup(
                 imageUrl: rewriteFileUrl(p.imageUrl as string, ctx.organizationId),
                 location: (p.location as string) || null,
                 isArchived: (p.isArchived as boolean) || false,
+                // Backups from before tags have none.
+                tags: Array.isArray(p.tags)
+                  ? (p.tags as unknown[]).filter((tag): tag is string => typeof tag === 'string')
+                  : [],
                 createdAt: toSafeDate(p.createdAt as string),
                 updatedAt: toSafeDate(p.updatedAt as string),
                 userId: ctx.userId,