Explorar o código

Add labor presets (#77)

* labor preset

* react table with sorting

* translations
Bernt Christian Egeland hai 6 meses
pai
achega
e9c88ac1c2
Modificáronse 77 ficheiros con 2076 adicións e 85 borrados
  1. 57 0
      messages/de/laborPresets.json
  2. 2 0
      messages/de/navigation.json
  3. 1 0
      messages/de/quotes.json
  4. 1 0
      messages/de/service.json
  5. 4 0
      messages/de/settings.json
  6. 57 0
      messages/en/laborPresets.json
  7. 2 0
      messages/en/navigation.json
  8. 1 0
      messages/en/quotes.json
  9. 1 0
      messages/en/service.json
  10. 4 0
      messages/en/settings.json
  11. 57 0
      messages/es/laborPresets.json
  12. 2 0
      messages/es/navigation.json
  13. 1 0
      messages/es/quotes.json
  14. 1 0
      messages/es/service.json
  15. 4 0
      messages/es/settings.json
  16. 57 0
      messages/fr/laborPresets.json
  17. 2 0
      messages/fr/navigation.json
  18. 1 0
      messages/fr/quotes.json
  19. 1 0
      messages/fr/service.json
  20. 4 0
      messages/fr/settings.json
  21. 57 0
      messages/it/laborPresets.json
  22. 2 0
      messages/it/navigation.json
  23. 1 0
      messages/it/quotes.json
  24. 1 0
      messages/it/service.json
  25. 4 0
      messages/it/settings.json
  26. 57 0
      messages/nb/laborPresets.json
  27. 2 0
      messages/nb/navigation.json
  28. 1 0
      messages/nb/quotes.json
  29. 1 0
      messages/nb/service.json
  30. 4 0
      messages/nb/settings.json
  31. 57 0
      messages/nl/laborPresets.json
  32. 2 0
      messages/nl/navigation.json
  33. 1 0
      messages/nl/quotes.json
  34. 1 0
      messages/nl/service.json
  35. 4 0
      messages/nl/settings.json
  36. 57 0
      messages/pl/laborPresets.json
  37. 2 0
      messages/pl/navigation.json
  38. 1 0
      messages/pl/quotes.json
  39. 1 0
      messages/pl/service.json
  40. 4 0
      messages/pl/settings.json
  41. 57 0
      messages/pt-BR/laborPresets.json
  42. 2 0
      messages/pt-BR/navigation.json
  43. 1 0
      messages/pt-BR/quotes.json
  44. 1 0
      messages/pt-BR/service.json
  45. 4 0
      messages/pt-BR/settings.json
  46. 57 0
      messages/ru/laborPresets.json
  47. 2 0
      messages/ru/navigation.json
  48. 1 0
      messages/ru/quotes.json
  49. 1 0
      messages/ru/service.json
  50. 4 0
      messages/ru/settings.json
  51. 57 0
      messages/tr/laborPresets.json
  52. 2 0
      messages/tr/navigation.json
  53. 1 0
      messages/tr/quotes.json
  54. 1 0
      messages/tr/service.json
  55. 4 0
      messages/tr/settings.json
  56. 37 0
      prisma/migrations/20260317075019_labor_preset/migration.sql
  57. 30 0
      prisma/schema.prisma
  58. 255 0
      src/app/(authenticated)/labor-presets/labor-presets-client.tsx
  59. 62 0
      src/app/(authenticated)/labor-presets/page.tsx
  60. 5 1
      src/app/(authenticated)/quotes/[id]/page.tsx
  61. 82 69
      src/app/(authenticated)/settings/settings-nav.tsx
  62. 6 1
      src/app/(authenticated)/vehicles/[id]/service/[serviceId]/page.tsx
  63. 2 0
      src/components/app-sidebar.tsx
  64. 203 0
      src/features/labor-presets/Actions/laborPresetActions.ts
  65. 414 0
      src/features/labor-presets/Components/LaborPresetForm.tsx
  66. 127 0
      src/features/labor-presets/Components/LaborPresetPickerDialog.tsx
  67. 22 0
      src/features/labor-presets/Schema/laborPresetSchema.ts
  68. 14 2
      src/features/quotes/Components/QuoteLaborEditor.tsx
  69. 28 0
      src/features/quotes/Components/QuotePageClient.tsx
  70. 6 1
      src/features/quotes/Components/useQuoteFormState.ts
  71. 22 10
      src/features/vehicles/Components/service-edit/LaborEditor.tsx
  72. 6 0
      src/features/vehicles/Components/service-page/DetailsLeftColumn.tsx
  73. 22 0
      src/features/vehicles/Components/service-page/ServicePageClient.tsx
  74. 3 1
      src/features/vehicles/Components/service-page/service-page-types.ts
  75. 2 0
      src/features/vehicles/Components/service-page/useServiceFormState.ts
  76. 2 0
      src/i18n/request.ts
  77. 11 0
      src/lib/permissions.ts

+ 57 - 0
messages/de/laborPresets.json

@@ -0,0 +1,57 @@
+{
+  "searchPlaceholder": "Arbeitsvorlagen durchsuchen...",
+  "addPackage": "Arbeitsvorlage hinzufügen",
+  "table": {
+    "name": "Name",
+    "description": "Beschreibung",
+    "itemCount": "Positionen"
+  },
+  "empty": {
+    "noMatch": "Keine Vorlagen entsprechen Ihrer Suche.",
+    "noPresets": "Noch keine Arbeitsvorlagen vorhanden. Klicken Sie auf \"Arbeitsvorlage hinzufügen\", um eine zu erstellen."
+  },
+  "actions": {
+    "edit": "Bearbeiten",
+    "delete": "Löschen"
+  },
+  "deletePreset": {
+    "title": "Arbeitsvorlage löschen",
+    "description": "Dadurch werden \"{name}\" und alle zugehörigen Positionen dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.",
+    "confirm": "Löschen"
+  },
+  "form": {
+    "singleItem": "Einzelposition",
+    "package": "Paket",
+    "addPackage": "Arbeitsvorlage hinzufügen",
+    "editPackage": "Arbeitsvorlage bearbeiten",
+    "singleNameLabel": "Name",
+    "singleNamePlaceholder": "z. B. Ölwechsel",
+    "nameLabel": "Paketname",
+    "namePlaceholder": "z. B. Ölwechsel-Paket",
+    "descriptionLabel": "Beschreibung",
+    "descriptionPlaceholder": "Optionale Beschreibung...",
+    "itemsTitle": "Arbeitspositionen",
+    "itemDescription": "Beschreibung",
+    "itemHours": "Stunden",
+    "itemRate": "Stundensatz",
+    "itemDescriptionPlaceholder": "Beschreibung *",
+    "addItem": "Position hinzufügen",
+    "cancel": "Abbrechen",
+    "create": "Erstellen",
+    "saveChanges": "Änderungen speichern"
+  },
+  "picker": {
+    "title": "Arbeitsvorlage auswählen",
+    "search": "Vorlagen durchsuchen...",
+    "noPackages": "Keine Arbeitsvorlagen gefunden.",
+    "items": "{count} Positionen",
+    "totalHours": "{hours} Std. gesamt"
+  },
+  "errors": {
+    "error": "Fehler",
+    "saveFailed": "Arbeitsvorlage konnte nicht gespeichert werden",
+    "deleteFailed": "Arbeitsvorlage konnte nicht gelöscht werden",
+    "loadFailed": "Arbeitsvorlage konnte nicht geladen werden",
+    "noItems": "Mindestens eine Position ist erforderlich"
+  }
+}

+ 2 - 0
messages/de/navigation.json

@@ -18,6 +18,7 @@
     "billingHistory": "Abrechnungsverlauf",
     "inventory": "Lagerbestand",
     "allParts": "Alle Teile",
+    "laborPresets": "Arbeitsvorlagen",
     "reports": "Berichte",
     "workBoard": "Auftragsboard",
     "presenter": "Präsentation",
@@ -72,6 +73,7 @@
     "quotes": "Angebote",
     "billing": "Abrechnung",
     "inventory": "Lagerbestand",
+    "laborPresets": "Arbeitsvorlagen",
     "reports": "Berichte",
     "settings": "Einstellungen",
     "superAdmin": "Super Admin",

+ 1 - 0
messages/de/quotes.json

@@ -75,6 +75,7 @@
   "labor": {
     "title": "Arbeitszeit",
     "addLabor": "Arbeitszeit hinzufügen",
+    "fromPresets": "Aus Vorlagen",
     "description": "Beschreibung",
     "descriptionPlaceholder": "Beschreibung *",
     "hours": "Stunden",

+ 1 - 0
messages/de/service.json

@@ -79,6 +79,7 @@
   "labor": {
     "title": "Arbeit",
     "addLabor": "Arbeit hinzufügen",
+    "fromPresets": "Aus Vorlagen",
     "description": "Beschreibung",
     "hours": "Stunden",
     "rate": "Satz ({currency}/Std.)",

+ 4 - 0
messages/de/settings.json

@@ -66,6 +66,10 @@
         "title": "Werkstatt",
         "description": "Techniker- & Arbeitsstandards"
       },
+      "laborPresets": {
+        "title": "Arbeitspakete",
+        "description": "Vordefinierte Arbeitspakete"
+      },
       "maintenance": {
         "title": "Wartung",
         "description": "Vorhergesagte Serviceintervalle"

+ 57 - 0
messages/en/laborPresets.json

@@ -0,0 +1,57 @@
+{
+  "searchPlaceholder": "Search labor packages...",
+  "addPackage": "Add Labor Preset",
+  "table": {
+    "name": "Name",
+    "description": "Description",
+    "itemCount": "Items"
+  },
+  "empty": {
+    "noMatch": "No packages match your search.",
+    "noPresets": "No labor packages yet. Click \"Add Package\" to create one."
+  },
+  "actions": {
+    "edit": "Edit",
+    "delete": "Delete"
+  },
+  "deletePreset": {
+    "title": "Delete Labor Package",
+    "description": "This will permanently delete \"{name}\" and all its items. This cannot be undone.",
+    "confirm": "Delete"
+  },
+  "form": {
+    "singleItem": "Single Item",
+    "package": "Package",
+    "addPackage": "Add Labor Preset",
+    "editPackage": "Edit Labor Preset",
+    "singleNameLabel": "Name",
+    "singleNamePlaceholder": "e.g. Oil Change",
+    "nameLabel": "Package Name",
+    "namePlaceholder": "e.g. Oil Change Package",
+    "descriptionLabel": "Description",
+    "descriptionPlaceholder": "Optional description...",
+    "itemsTitle": "Labor Items",
+    "itemDescription": "Description",
+    "itemHours": "Hours",
+    "itemRate": "Rate",
+    "itemDescriptionPlaceholder": "Description *",
+    "addItem": "Add Item",
+    "cancel": "Cancel",
+    "create": "Create Package",
+    "saveChanges": "Save Changes"
+  },
+  "picker": {
+    "title": "Select Labor Package",
+    "search": "Search packages...",
+    "noPackages": "No labor packages found.",
+    "items": "{count} items",
+    "totalHours": "{hours}h total"
+  },
+  "errors": {
+    "error": "Error",
+    "saveFailed": "Failed to save labor package",
+    "deleteFailed": "Failed to delete labor package",
+    "loadFailed": "Failed to load labor package",
+    "noItems": "At least one item is required"
+  }
+}

+ 2 - 0
messages/en/navigation.json

@@ -18,6 +18,7 @@
     "billingHistory": "Billing History",
     "inventory": "Inventory",
     "allParts": "All Parts",
+    "laborPresets": "Labor Presets",
     "reports": "Reports",
     "workBoard": "Work Board",
     "presenter": "Presenter",
@@ -72,6 +73,7 @@
     "quotes": "Quotes",
     "billing": "Billing",
     "inventory": "Inventory",
+    "laborPresets": "Labor Presets",
     "reports": "Reports",
     "settings": "Settings",
     "superAdmin": "Super Admin",

+ 1 - 0
messages/en/quotes.json

@@ -75,6 +75,7 @@
   "labor": {
     "title": "Labor",
     "addLabor": "Add Labor",
+    "fromPresets": "From Presets",
     "description": "Description",
     "descriptionPlaceholder": "Description *",
     "hours": "Hours",

+ 1 - 0
messages/en/service.json

@@ -81,6 +81,7 @@
   "labor": {
     "title": "Labor",
     "addLabor": "Add Labor",
+    "fromPresets": "From Presets",
     "description": "Description",
     "hours": "Hours",
     "rate": "Rate ({currency}/hr)",

+ 4 - 0
messages/en/settings.json

@@ -66,6 +66,10 @@
         "title": "Workshop",
         "description": "Technician & labor defaults"
       },
+      "laborPresets": {
+        "title": "Labor Presets",
+        "description": "Predefined labor packages"
+      },
       "maintenance": {
         "title": "Maintenance",
         "description": "Predicted service intervals"

+ 57 - 0
messages/es/laborPresets.json

@@ -0,0 +1,57 @@
+{
+  "searchPlaceholder": "Buscar plantillas de mano de obra...",
+  "addPackage": "Agregar plantilla de mano de obra",
+  "table": {
+    "name": "Nombre",
+    "description": "Descripción",
+    "itemCount": "Elementos"
+  },
+  "empty": {
+    "noMatch": "Ninguna plantilla coincide con su búsqueda.",
+    "noPresets": "Aún no hay plantillas de mano de obra. Haga clic en \"Agregar plantilla de mano de obra\" para crear una."
+  },
+  "actions": {
+    "edit": "Editar",
+    "delete": "Eliminar"
+  },
+  "deletePreset": {
+    "title": "Eliminar plantilla de mano de obra",
+    "description": "Esto eliminará permanentemente \"{name}\" y todos sus elementos. Esta acción no se puede deshacer.",
+    "confirm": "Eliminar"
+  },
+  "form": {
+    "singleItem": "Elemento individual",
+    "package": "Paquete",
+    "addPackage": "Agregar plantilla de mano de obra",
+    "editPackage": "Editar plantilla de mano de obra",
+    "singleNameLabel": "Nombre",
+    "singleNamePlaceholder": "ej. Cambio de aceite",
+    "nameLabel": "Nombre del paquete",
+    "namePlaceholder": "ej. Paquete de cambio de aceite",
+    "descriptionLabel": "Descripción",
+    "descriptionPlaceholder": "Descripción opcional...",
+    "itemsTitle": "Elementos de mano de obra",
+    "itemDescription": "Descripción",
+    "itemHours": "Horas",
+    "itemRate": "Tarifa",
+    "itemDescriptionPlaceholder": "Descripción *",
+    "addItem": "Agregar elemento",
+    "cancel": "Cancelar",
+    "create": "Crear",
+    "saveChanges": "Guardar cambios"
+  },
+  "picker": {
+    "title": "Seleccionar plantilla de mano de obra",
+    "search": "Buscar plantillas...",
+    "noPackages": "No se encontraron plantillas de mano de obra.",
+    "items": "{count} elementos",
+    "totalHours": "{hours}h en total"
+  },
+  "errors": {
+    "error": "Error",
+    "saveFailed": "Error al guardar la plantilla de mano de obra",
+    "deleteFailed": "Error al eliminar la plantilla de mano de obra",
+    "loadFailed": "Error al cargar la plantilla de mano de obra",
+    "noItems": "Se requiere al menos un elemento"
+  }
+}

+ 2 - 0
messages/es/navigation.json

@@ -18,6 +18,7 @@
     "billingHistory": "Historial de facturaci\u00f3n",
     "inventory": "Inventario",
     "allParts": "Todas las piezas",
+    "laborPresets": "Plantillas de mano de obra",
     "reports": "Informes",
     "workBoard": "Tablero de trabajo",
     "presenter": "Presentador",
@@ -72,6 +73,7 @@
     "quotes": "Presupuestos",
     "billing": "Facturación",
     "inventory": "Inventario",
+    "laborPresets": "Plantillas de mano de obra",
     "reports": "Informes",
     "settings": "Configuración",
     "superAdmin": "Super Admin",

+ 1 - 0
messages/es/quotes.json

@@ -75,6 +75,7 @@
   "labor": {
     "title": "Mano de obra",
     "addLabor": "Agregar mano de obra",
+    "fromPresets": "Desde plantillas",
     "description": "Descripción",
     "descriptionPlaceholder": "Descripción *",
     "hours": "Horas",

+ 1 - 0
messages/es/service.json

@@ -79,6 +79,7 @@
   "labor": {
     "title": "Mano de Obra",
     "addLabor": "Agregar Mano de Obra",
+    "fromPresets": "Desde plantillas",
     "description": "Descripción",
     "hours": "Horas",
     "rate": "Tarifa ({currency}/hr)",

+ 4 - 0
messages/es/settings.json

@@ -66,6 +66,10 @@
         "title": "Taller",
         "description": "Valores predeterminados de tecnico y mano de obra"
       },
+      "laborPresets": {
+        "title": "Paquetes de mano de obra",
+        "description": "Paquetes de trabajo predefinidos"
+      },
       "maintenance": {
         "title": "Mantenimiento",
         "description": "Intervalos de servicio predichos"

+ 57 - 0
messages/fr/laborPresets.json

@@ -0,0 +1,57 @@
+{
+  "searchPlaceholder": "Rechercher des modèles de main-d'œuvre...",
+  "addPackage": "Ajouter un modèle de main-d'œuvre",
+  "table": {
+    "name": "Nom",
+    "description": "Description",
+    "itemCount": "Éléments"
+  },
+  "empty": {
+    "noMatch": "Aucun modèle ne correspond à votre recherche.",
+    "noPresets": "Aucun modèle de main-d'œuvre pour l'instant. Cliquez sur \"Ajouter un modèle de main-d'œuvre\" pour en créer un."
+  },
+  "actions": {
+    "edit": "Modifier",
+    "delete": "Supprimer"
+  },
+  "deletePreset": {
+    "title": "Supprimer le modèle de main-d'œuvre",
+    "description": "Cela supprimera définitivement \"{name}\" et tous ses éléments. Cette action est irréversible.",
+    "confirm": "Supprimer"
+  },
+  "form": {
+    "singleItem": "Élément unique",
+    "package": "Forfait",
+    "addPackage": "Ajouter un modèle de main-d'œuvre",
+    "editPackage": "Modifier le modèle de main-d'œuvre",
+    "singleNameLabel": "Nom",
+    "singleNamePlaceholder": "ex. Vidange d'huile",
+    "nameLabel": "Nom du forfait",
+    "namePlaceholder": "ex. Forfait vidange d'huile",
+    "descriptionLabel": "Description",
+    "descriptionPlaceholder": "Description facultative...",
+    "itemsTitle": "Éléments de main-d'œuvre",
+    "itemDescription": "Description",
+    "itemHours": "Heures",
+    "itemRate": "Tarif",
+    "itemDescriptionPlaceholder": "Description *",
+    "addItem": "Ajouter un élément",
+    "cancel": "Annuler",
+    "create": "Créer",
+    "saveChanges": "Enregistrer les modifications"
+  },
+  "picker": {
+    "title": "Sélectionner un modèle de main-d'œuvre",
+    "search": "Rechercher des modèles...",
+    "noPackages": "Aucun modèle de main-d'œuvre trouvé.",
+    "items": "{count} éléments",
+    "totalHours": "{hours}h au total"
+  },
+  "errors": {
+    "error": "Erreur",
+    "saveFailed": "Échec de l'enregistrement du modèle de main-d'œuvre",
+    "deleteFailed": "Échec de la suppression du modèle de main-d'œuvre",
+    "loadFailed": "Échec du chargement du modèle de main-d'œuvre",
+    "noItems": "Au moins un élément est requis"
+  }
+}

+ 2 - 0
messages/fr/navigation.json

@@ -18,6 +18,7 @@
     "billingHistory": "Historique de facturation",
     "inventory": "Inventaire",
     "allParts": "Toutes les pi\u00e8ces",
+    "laborPresets": "Modèles de main-d'œuvre",
     "reports": "Rapports",
     "workBoard": "Tableau de travail",
     "presenter": "Pr\u00e9sentateur",
@@ -72,6 +73,7 @@
     "quotes": "Devis",
     "billing": "Facturation",
     "inventory": "Inventaire",
+    "laborPresets": "Modèles de main-d'œuvre",
     "reports": "Rapports",
     "settings": "Paramètres",
     "superAdmin": "Super Admin",

+ 1 - 0
messages/fr/quotes.json

@@ -75,6 +75,7 @@
   "labor": {
     "title": "Main-d'œuvre",
     "addLabor": "Ajouter de la main-d'œuvre",
+    "fromPresets": "Depuis modèles",
     "description": "Description",
     "descriptionPlaceholder": "Description *",
     "hours": "Heures",

+ 1 - 0
messages/fr/service.json

@@ -79,6 +79,7 @@
   "labor": {
     "title": "Main-d'œuvre",
     "addLabor": "Ajouter une Main-d'œuvre",
+    "fromPresets": "Depuis modèles",
     "description": "Description",
     "hours": "Heures",
     "rate": "Taux ({currency}/h)",

+ 4 - 0
messages/fr/settings.json

@@ -66,6 +66,10 @@
         "title": "Atelier",
         "description": "Valeurs par defaut technicien et main-d'oeuvre"
       },
+      "laborPresets": {
+        "title": "Forfaits main-d'oeuvre",
+        "description": "Forfaits de travail predefinis"
+      },
       "maintenance": {
         "title": "Maintenance",
         "description": "Intervalles de service predits"

+ 57 - 0
messages/it/laborPresets.json

@@ -0,0 +1,57 @@
+{
+  "searchPlaceholder": "Cerca modelli di manodopera...",
+  "addPackage": "Aggiungi modello di manodopera",
+  "table": {
+    "name": "Nome",
+    "description": "Descrizione",
+    "itemCount": "Voci"
+  },
+  "empty": {
+    "noMatch": "Nessun modello corrisponde alla ricerca.",
+    "noPresets": "Nessun modello di manodopera ancora. Clicca su \"Aggiungi modello di manodopera\" per crearne uno."
+  },
+  "actions": {
+    "edit": "Modifica",
+    "delete": "Elimina"
+  },
+  "deletePreset": {
+    "title": "Elimina modello di manodopera",
+    "description": "Questo eliminerà permanentemente \"{name}\" e tutte le sue voci. Questa azione non può essere annullata.",
+    "confirm": "Elimina"
+  },
+  "form": {
+    "singleItem": "Voce singola",
+    "package": "Pacchetto",
+    "addPackage": "Aggiungi modello di manodopera",
+    "editPackage": "Modifica modello di manodopera",
+    "singleNameLabel": "Nome",
+    "singleNamePlaceholder": "es. Cambio olio",
+    "nameLabel": "Nome pacchetto",
+    "namePlaceholder": "es. Pacchetto cambio olio",
+    "descriptionLabel": "Descrizione",
+    "descriptionPlaceholder": "Descrizione facoltativa...",
+    "itemsTitle": "Voci di manodopera",
+    "itemDescription": "Descrizione",
+    "itemHours": "Ore",
+    "itemRate": "Tariffa",
+    "itemDescriptionPlaceholder": "Descrizione *",
+    "addItem": "Aggiungi voce",
+    "cancel": "Annulla",
+    "create": "Crea",
+    "saveChanges": "Salva modifiche"
+  },
+  "picker": {
+    "title": "Seleziona modello di manodopera",
+    "search": "Cerca modelli...",
+    "noPackages": "Nessun modello di manodopera trovato.",
+    "items": "{count} voci",
+    "totalHours": "{hours}h in totale"
+  },
+  "errors": {
+    "error": "Errore",
+    "saveFailed": "Impossibile salvare il modello di manodopera",
+    "deleteFailed": "Impossibile eliminare il modello di manodopera",
+    "loadFailed": "Impossibile caricare il modello di manodopera",
+    "noItems": "È richiesta almeno una voce"
+  }
+}

+ 2 - 0
messages/it/navigation.json

@@ -18,6 +18,7 @@
     "billingHistory": "Storico fatturazione",
     "inventory": "Inventario",
     "allParts": "Tutti i ricambi",
+    "laborPresets": "Modelli manodopera",
     "reports": "Report",
     "workBoard": "Bacheca lavori",
     "presenter": "Presentatore",
@@ -72,6 +73,7 @@
     "quotes": "Preventivi",
     "billing": "Fatturazione",
     "inventory": "Inventario",
+    "laborPresets": "Modelli manodopera",
     "reports": "Report",
     "settings": "Impostazioni",
     "superAdmin": "Super Admin",

+ 1 - 0
messages/it/quotes.json

@@ -75,6 +75,7 @@
   "labor": {
     "title": "Manodopera",
     "addLabor": "Aggiungi manodopera",
+    "fromPresets": "Da modelli",
     "description": "Descrizione",
     "descriptionPlaceholder": "Descrizione *",
     "hours": "Ore",

+ 1 - 0
messages/it/service.json

@@ -79,6 +79,7 @@
   "labor": {
     "title": "Manodopera",
     "addLabor": "Aggiungi Manodopera",
+    "fromPresets": "Da modelli",
     "description": "Descrizione",
     "hours": "Ore",
     "rate": "Tariffa ({currency}/h)",

+ 4 - 0
messages/it/settings.json

@@ -66,6 +66,10 @@
         "title": "Officina",
         "description": "Tecnico e valori predefiniti manodopera"
       },
+      "laborPresets": {
+        "title": "Pacchetti manodopera",
+        "description": "Pacchetti di lavoro predefiniti"
+      },
       "maintenance": {
         "title": "Manutenzione",
         "description": "Intervalli di servizio previsti"

+ 57 - 0
messages/nb/laborPresets.json

@@ -0,0 +1,57 @@
+{
+  "searchPlaceholder": "Søk i arbeidsmaler...",
+  "addPackage": "Legg til arbeidsmal",
+  "table": {
+    "name": "Navn",
+    "description": "Beskrivelse",
+    "itemCount": "Elementer"
+  },
+  "empty": {
+    "noMatch": "Ingen maler samsvarer med søket ditt.",
+    "noPresets": "Ingen arbeidsmaler ennå. Klikk på \"Legg til arbeidsmal\" for å opprette en."
+  },
+  "actions": {
+    "edit": "Rediger",
+    "delete": "Slett"
+  },
+  "deletePreset": {
+    "title": "Slett arbeidsmal",
+    "description": "Dette vil permanent slette \"{name}\" og alle tilhørende elementer. Dette kan ikke angres.",
+    "confirm": "Slett"
+  },
+  "form": {
+    "singleItem": "Enkelt element",
+    "package": "Pakke",
+    "addPackage": "Legg til arbeidsmal",
+    "editPackage": "Rediger arbeidsmal",
+    "singleNameLabel": "Navn",
+    "singleNamePlaceholder": "f.eks. Oljeskift",
+    "nameLabel": "Pakkenavn",
+    "namePlaceholder": "f.eks. Oljeskift-pakke",
+    "descriptionLabel": "Beskrivelse",
+    "descriptionPlaceholder": "Valgfri beskrivelse...",
+    "itemsTitle": "Arbeidselementer",
+    "itemDescription": "Beskrivelse",
+    "itemHours": "Timer",
+    "itemRate": "Timepris",
+    "itemDescriptionPlaceholder": "Beskrivelse *",
+    "addItem": "Legg til element",
+    "cancel": "Avbryt",
+    "create": "Opprett",
+    "saveChanges": "Lagre endringer"
+  },
+  "picker": {
+    "title": "Velg arbeidsmal",
+    "search": "Søk i maler...",
+    "noPackages": "Ingen arbeidsmaler funnet.",
+    "items": "{count} elementer",
+    "totalHours": "{hours}t totalt"
+  },
+  "errors": {
+    "error": "Feil",
+    "saveFailed": "Kunne ikke lagre arbeidsmalen",
+    "deleteFailed": "Kunne ikke slette arbeidsmalen",
+    "loadFailed": "Kunne ikke laste arbeidsmalen",
+    "noItems": "Minst ett element er påkrevd"
+  }
+}

+ 2 - 0
messages/nb/navigation.json

@@ -18,6 +18,7 @@
     "billingHistory": "Faktureringshistorikk",
     "inventory": "Lager",
     "allParts": "Alle deler",
+    "laborPresets": "Arbeidsmaler",
     "reports": "Rapporter",
     "workBoard": "Arbeidstavle",
     "presenter": "Presentasjon",
@@ -72,6 +73,7 @@
     "quotes": "Tilbud",
     "billing": "Fakturering",
     "inventory": "Lager",
+    "laborPresets": "Arbeidsmaler",
     "reports": "Rapporter",
     "settings": "Innstillinger",
     "superAdmin": "Super Admin",

+ 1 - 0
messages/nb/quotes.json

@@ -75,6 +75,7 @@
   "labor": {
     "title": "Arbeid",
     "addLabor": "Legg til arbeid",
+    "fromPresets": "Fra maler",
     "description": "Beskrivelse",
     "descriptionPlaceholder": "Beskrivelse *",
     "hours": "Timer",

+ 1 - 0
messages/nb/service.json

@@ -81,6 +81,7 @@
   "labor": {
     "title": "Arbeid",
     "addLabor": "Legg til arbeid",
+    "fromPresets": "Fra maler",
     "description": "Beskrivelse",
     "hours": "Timer",
     "rate": "Sats ({currency}/time)",

+ 4 - 0
messages/nb/settings.json

@@ -66,6 +66,10 @@
         "title": "Verksted",
         "description": "Tekniker- og arbeidsstandarder"
       },
+      "laborPresets": {
+        "title": "Arbeidspakker",
+        "description": "Forhåndsdefinerte arbeidspakker"
+      },
       "maintenance": {
         "title": "Vedlikehold",
         "description": "Predikerte serviceintervaller"

+ 57 - 0
messages/nl/laborPresets.json

@@ -0,0 +1,57 @@
+{
+  "searchPlaceholder": "Arbeidssjablonen zoeken...",
+  "addPackage": "Arbeidssjabloon toevoegen",
+  "table": {
+    "name": "Naam",
+    "description": "Beschrijving",
+    "itemCount": "Items"
+  },
+  "empty": {
+    "noMatch": "Geen sjablonen komen overeen met uw zoekopdracht.",
+    "noPresets": "Nog geen arbeidssjablonen. Klik op \"Arbeidssjabloon toevoegen\" om er een aan te maken."
+  },
+  "actions": {
+    "edit": "Bewerken",
+    "delete": "Verwijderen"
+  },
+  "deletePreset": {
+    "title": "Arbeidssjabloon verwijderen",
+    "description": "Hiermee worden \"{name}\" en alle bijbehorende items permanent verwijderd. Dit kan niet ongedaan worden gemaakt.",
+    "confirm": "Verwijderen"
+  },
+  "form": {
+    "singleItem": "Enkel item",
+    "package": "Pakket",
+    "addPackage": "Arbeidssjabloon toevoegen",
+    "editPackage": "Arbeidssjabloon bewerken",
+    "singleNameLabel": "Naam",
+    "singleNamePlaceholder": "bijv. Olieverversing",
+    "nameLabel": "Pakketnaam",
+    "namePlaceholder": "bijv. Olieverversing-pakket",
+    "descriptionLabel": "Beschrijving",
+    "descriptionPlaceholder": "Optionele beschrijving...",
+    "itemsTitle": "Arbeidsitems",
+    "itemDescription": "Beschrijving",
+    "itemHours": "Uren",
+    "itemRate": "Tarief",
+    "itemDescriptionPlaceholder": "Beschrijving *",
+    "addItem": "Item toevoegen",
+    "cancel": "Annuleren",
+    "create": "Aanmaken",
+    "saveChanges": "Wijzigingen opslaan"
+  },
+  "picker": {
+    "title": "Arbeidssjabloon selecteren",
+    "search": "Sjablonen zoeken...",
+    "noPackages": "Geen arbeidssjablonen gevonden.",
+    "items": "{count} items",
+    "totalHours": "{hours}u totaal"
+  },
+  "errors": {
+    "error": "Fout",
+    "saveFailed": "Kan arbeidssjabloon niet opslaan",
+    "deleteFailed": "Kan arbeidssjabloon niet verwijderen",
+    "loadFailed": "Kan arbeidssjabloon niet laden",
+    "noItems": "Minimaal één item is vereist"
+  }
+}

+ 2 - 0
messages/nl/navigation.json

@@ -18,6 +18,7 @@
     "billingHistory": "Factuurgeschiedenis",
     "inventory": "Voorraad",
     "allParts": "Alle onderdelen",
+    "laborPresets": "Arbeidssjablonen",
     "reports": "Rapporten",
     "workBoard": "Werkbord",
     "presenter": "Presentator",
@@ -72,6 +73,7 @@
     "quotes": "Offertes",
     "billing": "Facturatie",
     "inventory": "Voorraad",
+    "laborPresets": "Arbeidssjablonen",
     "reports": "Rapporten",
     "settings": "Instellingen",
     "superAdmin": "Super Admin",

+ 1 - 0
messages/nl/quotes.json

@@ -75,6 +75,7 @@
   "labor": {
     "title": "Arbeid",
     "addLabor": "Arbeid toevoegen",
+    "fromPresets": "Uit sjablonen",
     "description": "Omschrijving",
     "descriptionPlaceholder": "Omschrijving *",
     "hours": "Uren",

+ 1 - 0
messages/nl/service.json

@@ -79,6 +79,7 @@
   "labor": {
     "title": "Arbeidskosten",
     "addLabor": "Arbeid toevoegen",
+    "fromPresets": "Uit sjablonen",
     "description": "Beschrijving",
     "hours": "Uren",
     "rate": "Tarief ({currency}/uur)",

+ 4 - 0
messages/nl/settings.json

@@ -66,6 +66,10 @@
         "title": "Werkplaats",
         "description": "Standaard technicus en arbeid"
       },
+      "laborPresets": {
+        "title": "Arbeidspakketten",
+        "description": "Voorgedefinieerde arbeidspakketten"
+      },
       "maintenance": {
         "title": "Onderhoud",
         "description": "Voorspelde service-intervallen"

+ 57 - 0
messages/pl/laborPresets.json

@@ -0,0 +1,57 @@
+{
+  "searchPlaceholder": "Szukaj szablonów robocizny...",
+  "addPackage": "Dodaj szablon robocizny",
+  "table": {
+    "name": "Nazwa",
+    "description": "Opis",
+    "itemCount": "Pozycje"
+  },
+  "empty": {
+    "noMatch": "Żadne szablony nie pasują do wyszukiwania.",
+    "noPresets": "Brak szablonów robocizny. Kliknij \"Dodaj szablon robocizny\", aby utworzyć nowy."
+  },
+  "actions": {
+    "edit": "Edytuj",
+    "delete": "Usuń"
+  },
+  "deletePreset": {
+    "title": "Usuń szablon robocizny",
+    "description": "Spowoduje to trwałe usunięcie \"{name}\" i wszystkich jego pozycji. Tej operacji nie można cofnąć.",
+    "confirm": "Usuń"
+  },
+  "form": {
+    "singleItem": "Pojedyncza pozycja",
+    "package": "Pakiet",
+    "addPackage": "Dodaj szablon robocizny",
+    "editPackage": "Edytuj szablon robocizny",
+    "singleNameLabel": "Nazwa",
+    "singleNamePlaceholder": "np. Wymiana oleju",
+    "nameLabel": "Nazwa pakietu",
+    "namePlaceholder": "np. Pakiet wymiany oleju",
+    "descriptionLabel": "Opis",
+    "descriptionPlaceholder": "Opcjonalny opis...",
+    "itemsTitle": "Pozycje robocizny",
+    "itemDescription": "Opis",
+    "itemHours": "Godziny",
+    "itemRate": "Stawka",
+    "itemDescriptionPlaceholder": "Opis *",
+    "addItem": "Dodaj pozycję",
+    "cancel": "Anuluj",
+    "create": "Utwórz",
+    "saveChanges": "Zapisz zmiany"
+  },
+  "picker": {
+    "title": "Wybierz szablon robocizny",
+    "search": "Szukaj szablonów...",
+    "noPackages": "Nie znaleziono szablonów robocizny.",
+    "items": "{count} pozycji",
+    "totalHours": "{hours} godz. łącznie"
+  },
+  "errors": {
+    "error": "Błąd",
+    "saveFailed": "Nie udało się zapisać szablonu robocizny",
+    "deleteFailed": "Nie udało się usunąć szablonu robocizny",
+    "loadFailed": "Nie udało się załadować szablonu robocizny",
+    "noItems": "Wymagana jest co najmniej jedna pozycja"
+  }
+}

+ 2 - 0
messages/pl/navigation.json

@@ -18,6 +18,7 @@
     "billingHistory": "Historia rozliczeń",
     "inventory": "Magazyn",
     "allParts": "Wszystkie części",
+    "laborPresets": "Szablony robocizny",
     "reports": "Raporty",
     "workBoard": "Tablica zleceń",
     "presenter": "Prezenter",
@@ -72,6 +73,7 @@
     "quotes": "Wyceny",
     "billing": "Rozliczenia",
     "inventory": "Magazyn",
+    "laborPresets": "Szablony robocizny",
     "reports": "Raporty",
     "settings": "Ustawienia",
     "superAdmin": "Super Admin",

+ 1 - 0
messages/pl/quotes.json

@@ -75,6 +75,7 @@
   "labor": {
     "title": "Robocizna",
     "addLabor": "Dodaj robociznę",
+    "fromPresets": "Z szablonów",
     "description": "Opis",
     "descriptionPlaceholder": "Opis *",
     "hours": "Godziny",

+ 1 - 0
messages/pl/service.json

@@ -79,6 +79,7 @@
   "labor": {
     "title": "Praca",
     "addLabor": "Dodaj Pracę",
+    "fromPresets": "Z szablonów",
     "description": "Opis",
     "hours": "Godziny",
     "rate": "Stawka ({currency}/godz.)",

+ 4 - 0
messages/pl/settings.json

@@ -66,6 +66,10 @@
         "title": "Warsztat",
         "description": "Domyslne ustawienia technika i robocizny"
       },
+      "laborPresets": {
+        "title": "Pakiety robocizny",
+        "description": "Predefiniowane pakiety pracy"
+      },
       "maintenance": {
         "title": "Konserwacja",
         "description": "Przewidywane interwaly serwisowe"

+ 57 - 0
messages/pt-BR/laborPresets.json

@@ -0,0 +1,57 @@
+{
+  "searchPlaceholder": "Pesquisar modelos de mão de obra...",
+  "addPackage": "Adicionar modelo de mão de obra",
+  "table": {
+    "name": "Nome",
+    "description": "Descrição",
+    "itemCount": "Itens"
+  },
+  "empty": {
+    "noMatch": "Nenhum modelo corresponde à sua pesquisa.",
+    "noPresets": "Nenhum modelo de mão de obra ainda. Clique em \"Adicionar modelo de mão de obra\" para criar um."
+  },
+  "actions": {
+    "edit": "Editar",
+    "delete": "Excluir"
+  },
+  "deletePreset": {
+    "title": "Excluir modelo de mão de obra",
+    "description": "Isso excluirá permanentemente \"{name}\" e todos os seus itens. Esta ação não pode ser desfeita.",
+    "confirm": "Excluir"
+  },
+  "form": {
+    "singleItem": "Item único",
+    "package": "Pacote",
+    "addPackage": "Adicionar modelo de mão de obra",
+    "editPackage": "Editar modelo de mão de obra",
+    "singleNameLabel": "Nome",
+    "singleNamePlaceholder": "ex. Troca de óleo",
+    "nameLabel": "Nome do pacote",
+    "namePlaceholder": "ex. Pacote de troca de óleo",
+    "descriptionLabel": "Descrição",
+    "descriptionPlaceholder": "Descrição opcional...",
+    "itemsTitle": "Itens de mão de obra",
+    "itemDescription": "Descrição",
+    "itemHours": "Horas",
+    "itemRate": "Tarifa",
+    "itemDescriptionPlaceholder": "Descrição *",
+    "addItem": "Adicionar item",
+    "cancel": "Cancelar",
+    "create": "Criar",
+    "saveChanges": "Salvar alterações"
+  },
+  "picker": {
+    "title": "Selecionar modelo de mão de obra",
+    "search": "Pesquisar modelos...",
+    "noPackages": "Nenhum modelo de mão de obra encontrado.",
+    "items": "{count} itens",
+    "totalHours": "{hours}h no total"
+  },
+  "errors": {
+    "error": "Erro",
+    "saveFailed": "Falha ao salvar o modelo de mão de obra",
+    "deleteFailed": "Falha ao excluir o modelo de mão de obra",
+    "loadFailed": "Falha ao carregar o modelo de mão de obra",
+    "noItems": "Pelo menos um item é obrigatório"
+  }
+}

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

@@ -18,6 +18,7 @@
     "billingHistory": "Histórico de Faturamento",
     "inventory": "Estoque",
     "allParts": "Todas as Peças",
+    "laborPresets": "Modelos de mão de obra",
     "reports": "Relatórios",
     "workBoard": "Quadro de Trabalho",
     "presenter": "Apresentador",
@@ -72,6 +73,7 @@
     "quotes": "Orçamentos",
     "billing": "Faturamento",
     "inventory": "Estoque",
+    "laborPresets": "Modelos de mão de obra",
     "reports": "Relatórios",
     "settings": "Configurações",
     "superAdmin": "Super Admin",

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

@@ -75,6 +75,7 @@
   "labor": {
     "title": "Mão de obra",
     "addLabor": "Adicionar mão de obra",
+    "fromPresets": "De modelos",
     "description": "Descrição",
     "descriptionPlaceholder": "Descrição *",
     "hours": "Horas",

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

@@ -79,6 +79,7 @@
   "labor": {
     "title": "Mão de Obra",
     "addLabor": "Adicionar Mão de Obra",
+    "fromPresets": "De modelos",
     "description": "Descrição",
     "hours": "Horas",
     "rate": "Taxa ({currency}/h)",

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

@@ -66,6 +66,10 @@
         "title": "Oficina",
         "description": "Padroes de tecnico e mao de obra"
       },
+      "laborPresets": {
+        "title": "Pacotes de mao de obra",
+        "description": "Pacotes de trabalho predefinidos"
+      },
       "maintenance": {
         "title": "Manutencao",
         "description": "Intervalos de servico previstos"

+ 57 - 0
messages/ru/laborPresets.json

@@ -0,0 +1,57 @@
+{
+  "searchPlaceholder": "Поиск шаблонов работ...",
+  "addPackage": "Добавить шаблон",
+  "table": {
+    "name": "Название",
+    "description": "Описание",
+    "itemCount": "Позиции"
+  },
+  "empty": {
+    "noMatch": "Шаблоны не найдены.",
+    "noPresets": "Шаблонов работ пока нет. Нажмите «Добавить шаблон», чтобы создать."
+  },
+  "actions": {
+    "edit": "Редактировать",
+    "delete": "Удалить"
+  },
+  "deletePreset": {
+    "title": "Удалить шаблон",
+    "description": "Шаблон «{name}» и все его позиции будут удалены безвозвратно.",
+    "confirm": "Удалить"
+  },
+  "form": {
+    "singleItem": "Одна позиция",
+    "package": "Пакет",
+    "addPackage": "Добавить шаблон",
+    "editPackage": "Редактировать шаблон",
+    "singleNameLabel": "Название",
+    "singleNamePlaceholder": "напр. Замена масла",
+    "nameLabel": "Название пакета",
+    "namePlaceholder": "напр. Пакет замены масла",
+    "descriptionLabel": "Описание",
+    "descriptionPlaceholder": "Необязательное описание...",
+    "itemsTitle": "Позиции работ",
+    "itemDescription": "Описание",
+    "itemHours": "Часы",
+    "itemRate": "Ставка",
+    "itemDescriptionPlaceholder": "Описание *",
+    "addItem": "Добавить позицию",
+    "cancel": "Отмена",
+    "create": "Создать",
+    "saveChanges": "Сохранить"
+  },
+  "picker": {
+    "title": "Выбрать шаблон работ",
+    "search": "Поиск шаблонов...",
+    "noPackages": "Шаблоны работ не найдены.",
+    "items": "{count} поз.",
+    "totalHours": "{hours}ч всего"
+  },
+  "errors": {
+    "error": "Ошибка",
+    "saveFailed": "Не удалось сохранить шаблон",
+    "deleteFailed": "Не удалось удалить шаблон",
+    "loadFailed": "Не удалось загрузить шаблон",
+    "noItems": "Необходима хотя бы одна позиция"
+  }
+}

+ 2 - 0
messages/ru/navigation.json

@@ -18,6 +18,7 @@
     "billingHistory": "История счетов",
     "inventory": "Склад",
     "allParts": "Все запчасти",
+    "laborPresets": "Шаблоны работ",
     "reports": "Отчёты",
     "workBoard": "Доска работ",
     "presenter": "Презентация",
@@ -72,6 +73,7 @@
     "quotes": "Предложения",
     "billing": "Счета",
     "inventory": "Склад",
+    "laborPresets": "Шаблоны работ",
     "reports": "Отчёты",
     "settings": "Настройки",
     "superAdmin": "Суперадмин",

+ 1 - 0
messages/ru/quotes.json

@@ -75,6 +75,7 @@
   "labor": {
     "title": "Работа",
     "addLabor": "Добавить работу",
+    "fromPresets": "Из шаблонов",
     "description": "Описание",
     "descriptionPlaceholder": "Описание *",
     "hours": "Часы",

+ 1 - 0
messages/ru/service.json

@@ -81,6 +81,7 @@
   "labor": {
     "title": "Работа",
     "addLabor": "Добавить работу",
+    "fromPresets": "Из шаблонов",
     "description": "Описание",
     "hours": "Часы",
     "rate": "Ставка ({currency}/ч)",

+ 4 - 0
messages/ru/settings.json

@@ -66,6 +66,10 @@
         "title": "Мастерская",
         "description": "Механики и ставки по умолчанию"
       },
+      "laborPresets": {
+        "title": "Пакеты работ",
+        "description": "Предопределённые пакеты работ"
+      },
       "maintenance": {
         "title": "Техобслуживание",
         "description": "Прогнозируемые интервалы обслуживания"

+ 57 - 0
messages/tr/laborPresets.json

@@ -0,0 +1,57 @@
+{
+  "searchPlaceholder": "İşçilik şablonlarını ara...",
+  "addPackage": "İşçilik Şablonu Ekle",
+  "table": {
+    "name": "Ad",
+    "description": "Açıklama",
+    "itemCount": "Kalemler"
+  },
+  "empty": {
+    "noMatch": "Aramanızla eşleşen şablon bulunamadı.",
+    "noPresets": "Henüz işçilik şablonu yok. Bir tane oluşturmak için \"İşçilik Şablonu Ekle\"ye tıklayın."
+  },
+  "actions": {
+    "edit": "Düzenle",
+    "delete": "Sil"
+  },
+  "deletePreset": {
+    "title": "İşçilik Şablonunu Sil",
+    "description": "\"{name}\" ve tüm kalemleri kalıcı olarak silinecektir. Bu işlem geri alınamaz.",
+    "confirm": "Sil"
+  },
+  "form": {
+    "singleItem": "Tekli Kalem",
+    "package": "Paket",
+    "addPackage": "İşçilik Şablonu Ekle",
+    "editPackage": "İşçilik Şablonunu Düzenle",
+    "singleNameLabel": "Ad",
+    "singleNamePlaceholder": "ör. Yağ Değişimi",
+    "nameLabel": "Paket Adı",
+    "namePlaceholder": "ör. Yağ Değişimi Paketi",
+    "descriptionLabel": "Açıklama",
+    "descriptionPlaceholder": "İsteğe bağlı açıklama...",
+    "itemsTitle": "İşçilik Kalemleri",
+    "itemDescription": "Açıklama",
+    "itemHours": "Saat",
+    "itemRate": "Ücret",
+    "itemDescriptionPlaceholder": "Açıklama *",
+    "addItem": "Kalem Ekle",
+    "cancel": "İptal",
+    "create": "Oluştur",
+    "saveChanges": "Değişiklikleri Kaydet"
+  },
+  "picker": {
+    "title": "İşçilik Şablonu Seç",
+    "search": "Şablonları ara...",
+    "noPackages": "İşçilik şablonu bulunamadı.",
+    "items": "{count} kalem",
+    "totalHours": "toplam {hours}s"
+  },
+  "errors": {
+    "error": "Hata",
+    "saveFailed": "İşçilik şablonu kaydedilemedi",
+    "deleteFailed": "İşçilik şablonu silinemedi",
+    "loadFailed": "İşçilik şablonu yüklenemedi",
+    "noItems": "En az bir kalem gereklidir"
+  }
+}

+ 2 - 0
messages/tr/navigation.json

@@ -18,6 +18,7 @@
     "billingHistory": "Fatura Geçmişi",
     "inventory": "Envanter",
     "allParts": "Tüm Parçalar",
+    "laborPresets": "İşçilik şablonları",
     "reports": "Raporlar",
     "workBoard": "İş Panosu",
     "presenter": "Sunum",
@@ -72,6 +73,7 @@
     "quotes": "Teklifler",
     "billing": "Faturalandırma",
     "inventory": "Envanter",
+    "laborPresets": "İşçilik şablonları",
     "reports": "Raporlar",
     "settings": "Ayarlar",
     "superAdmin": "Super Admin",

+ 1 - 0
messages/tr/quotes.json

@@ -75,6 +75,7 @@
   "labor": {
     "title": "İşçilik",
     "addLabor": "İşçilik Ekle",
+    "fromPresets": "Şablonlardan",
     "description": "Açıklama",
     "descriptionPlaceholder": "Açıklama *",
     "hours": "Saat",

+ 1 - 0
messages/tr/service.json

@@ -79,6 +79,7 @@
   "labor": {
     "title": "İşçilik",
     "addLabor": "İşçilik Ekle",
+    "fromPresets": "Şablonlardan",
     "description": "Açıklama",
     "hours": "Saat",
     "rate": "Ücret ({currency}/sa)",

+ 4 - 0
messages/tr/settings.json

@@ -66,6 +66,10 @@
         "title": "Atölye",
         "description": "Teknisyen & işçilik varsayılanları"
       },
+      "laborPresets": {
+        "title": "İşçilik Paketleri",
+        "description": "Önceden tanımlı işçilik paketleri"
+      },
       "maintenance": {
         "title": "Bakım",
         "description": "Öngörülen servis aralıkları"

+ 37 - 0
prisma/migrations/20260317075019_labor_preset/migration.sql

@@ -0,0 +1,37 @@
+-- CreateTable
+CREATE TABLE "labor_presets" (
+    "id" TEXT NOT NULL,
+    "name" TEXT NOT NULL,
+    "description" TEXT,
+    "isArchived" BOOLEAN NOT NULL DEFAULT false,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+    "userId" TEXT NOT NULL,
+    "organizationId" TEXT NOT NULL,
+
+    CONSTRAINT "labor_presets_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "labor_preset_items" (
+    "id" TEXT NOT NULL,
+    "description" TEXT NOT NULL,
+    "hours" DOUBLE PRECISION NOT NULL DEFAULT 0,
+    "rate" DOUBLE PRECISION NOT NULL DEFAULT 0,
+    "sortOrder" INTEGER NOT NULL DEFAULT 0,
+    "presetId" TEXT NOT NULL,
+
+    CONSTRAINT "labor_preset_items_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "labor_presets_organizationId_idx" ON "labor_presets"("organizationId");
+
+-- CreateIndex
+CREATE INDEX "labor_preset_items_presetId_idx" ON "labor_preset_items"("presetId");
+
+-- AddForeignKey
+ALTER TABLE "labor_presets" ADD CONSTRAINT "labor_presets_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "labor_preset_items" ADD CONSTRAINT "labor_preset_items_presetId_fkey" FOREIGN KEY ("presetId") REFERENCES "labor_presets"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 30 - 0
prisma/schema.prisma

@@ -368,6 +368,35 @@ model ServiceLabor {
   @@map("service_labor")
 }
 
+model LaborPreset {
+  id             String   @id @default(cuid())
+  name           String
+  description    String?
+  isArchived     Boolean  @default(false)
+  createdAt      DateTime @default(now())
+  updatedAt      DateTime @updatedAt
+  userId         String
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+  items          LaborPresetItem[]
+
+  @@index([organizationId])
+  @@map("labor_presets")
+}
+
+model LaborPresetItem {
+  id          String @id @default(cuid())
+  description String
+  hours       Float  @default(0)
+  rate        Float  @default(0)
+  sortOrder   Int    @default(0)
+  presetId    String
+  preset      LaborPreset @relation(fields: [presetId], references: [id], onDelete: Cascade)
+
+  @@index([presetId])
+  @@map("labor_preset_items")
+}
+
 model InventoryPart {
   id            String   @id @default(cuid())
   partNumber    String?
@@ -547,6 +576,7 @@ model Organization {
   customers              Customer[]
   quotes                 Quote[]
   inventoryParts         InventoryPart[]
+  laborPresets           LaborPreset[]
   customFieldDefinitions CustomFieldDefinition[]
   settings               AppSetting[]
   subscription           Subscription?

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

@@ -0,0 +1,255 @@
+"use client";
+
+import { useState, useCallback, useTransition } from "react";
+import { useTranslations } from "next-intl";
+import { useRouter, usePathname, useSearchParams } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from "@/components/ui/table";
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { DataTablePagination } from "@/components/data-table-pagination";
+import { useGlassModal } from "@/components/glass-modal";
+import { useConfirm } from "@/components/confirm-dialog";
+import { LaborPresetForm } from "@/features/labor-presets/Components/LaborPresetForm";
+import { deleteLaborPreset, getLaborPreset } from "@/features/labor-presets/Actions/laborPresetActions";
+import { ArrowDown, ArrowUp, ArrowUpDown, Loader2, MoreVertical, Pencil, Plus, Search, Trash2 } from "lucide-react";
+
+interface LaborPresetRow {
+  id: string;
+  name: string;
+  description: string | null;
+  _count: { items: number };
+}
+
+interface PaginatedData {
+  presets: LaborPresetRow[];
+  total: number;
+  page: number;
+  pageSize: number;
+  totalPages: number;
+}
+
+export function LaborPresetsClient({
+  data,
+  search,
+  sortBy,
+  sortOrder,
+  currencyCode = "USD",
+  defaultLaborRate = 0,
+}: {
+  data: PaginatedData;
+  search: string;
+  sortBy: string;
+  sortOrder: "asc" | "desc";
+  currencyCode?: string;
+  defaultLaborRate?: number;
+}) {
+  const router = useRouter();
+  const pathname = usePathname();
+  const searchParams = useSearchParams();
+  const t = useTranslations("laborPresets");
+  const [isPending, startTransition] = useTransition();
+  const [searchInput, setSearchInput] = useState(search);
+  const [showForm, setShowForm] = useState(false);
+  const [editPreset, setEditPreset] = useState<{
+    id: string;
+    name: string;
+    description: string | null;
+    items: { description: string; hours: number; rate: number; sortOrder: number }[];
+  } | null>(null);
+  const modal = useGlassModal();
+  const confirm = useConfirm();
+
+  const navigate = useCallback(
+    (params: Record<string, string | number | undefined>) => {
+      const newParams = new URLSearchParams(searchParams.toString());
+      for (const [key, value] of Object.entries(params)) {
+        if (value === undefined || value === "") {
+          newParams.delete(key);
+        } else {
+          newParams.set(key, String(value));
+        }
+      }
+      if (!("page" in params) && "search" in params) {
+        newParams.delete("page");
+      }
+      startTransition(() => {
+        router.push(`${pathname}?${newParams.toString()}`);
+      });
+    },
+    [router, pathname, searchParams]
+  );
+
+  const handleSearch = useCallback(
+    (e: React.FormEvent) => {
+      e.preventDefault();
+      navigate({ search: searchInput || undefined });
+    },
+    [navigate, searchInput]
+  );
+
+  const handleSort = useCallback(
+    (column: string) => {
+      const newOrder = sortBy === column && sortOrder === "asc" ? "desc" : "asc";
+      navigate({ sortBy: column, sortOrder: newOrder });
+    },
+    [navigate, sortBy, sortOrder]
+  );
+
+  const SortIcon = ({ column }: { column: string }) => {
+    if (sortBy !== column) return <ArrowUpDown className="ml-1 h-3 w-3 opacity-50" />;
+    return sortOrder === "asc"
+      ? <ArrowUp className="ml-1 h-3 w-3" />
+      : <ArrowDown className="ml-1 h-3 w-3" />;
+  };
+
+  const handleEdit = async (id: string) => {
+    const result = await getLaborPreset(id);
+    if (result.success && result.data) {
+      setEditPreset(result.data);
+      setShowForm(true);
+    } else {
+      modal.open("error", t("errors.error"), result.error || t("errors.loadFailed"));
+    }
+  };
+
+  const handleDelete = async (id: string, name: string) => {
+    const ok = await confirm({
+      title: t("deletePreset.title"),
+      description: t("deletePreset.description", { name }),
+      confirmLabel: t("deletePreset.confirm"),
+      destructive: true,
+    });
+    if (!ok) return;
+    const result = await deleteLaborPreset(id);
+    if (result.success) {
+      router.refresh();
+    } else {
+      modal.open("error", t("errors.error"), result.error || t("errors.deleteFailed"));
+    }
+  };
+
+  return (
+    <div className="space-y-4">
+      {/* Toolbar */}
+      <div className="flex items-center justify-between gap-3">
+        <div className="flex flex-1 items-center gap-2">
+          <form onSubmit={handleSearch} className="relative flex-1 sm:max-w-sm">
+            <Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+            <Input
+              placeholder={t("searchPlaceholder")}
+              value={searchInput}
+              onChange={(e) => setSearchInput(e.target.value)}
+              className="pl-9"
+            />
+          </form>
+          {isPending && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
+        </div>
+        <Button size="sm" onClick={() => setShowForm(true)}>
+          <Plus className="mr-1 h-3.5 w-3.5" />
+          {t("addPackage")}
+        </Button>
+      </div>
+
+      {/* Table */}
+      <div className="rounded-lg border">
+        <Table>
+          <TableHeader>
+            <TableRow>
+              <TableHead>
+                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("name")}>
+                  {t("table.name")}
+                  <SortIcon column="name" />
+                </button>
+              </TableHead>
+              <TableHead className="hidden sm:table-cell">
+                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("description")}>
+                  {t("table.description")}
+                  <SortIcon column="description" />
+                </button>
+              </TableHead>
+              <TableHead>{t("table.itemCount")}</TableHead>
+              <TableHead className="w-[50px]" />
+            </TableRow>
+          </TableHeader>
+          <TableBody>
+            {data.presets.length === 0 ? (
+              <TableRow>
+                <TableCell colSpan={4} className="h-32 text-center text-muted-foreground">
+                  {search ? t("empty.noMatch") : t("empty.noPresets")}
+                </TableCell>
+              </TableRow>
+            ) : (
+              data.presets.map((preset) => (
+                <TableRow
+                  key={preset.id}
+                  className="cursor-pointer"
+                  onClick={() => handleEdit(preset.id)}
+                >
+                  <TableCell className="font-medium">{preset.name}</TableCell>
+                  <TableCell className="hidden sm:table-cell text-muted-foreground">
+                    {preset.description || "-"}
+                  </TableCell>
+                  <TableCell>{preset._count.items}</TableCell>
+                  <TableCell onClick={(e) => e.stopPropagation()}>
+                    <DropdownMenu>
+                      <DropdownMenuTrigger asChild>
+                        <Button variant="ghost" size="icon" className="h-8 w-8">
+                          <MoreVertical className="h-4 w-4" />
+                        </Button>
+                      </DropdownMenuTrigger>
+                      <DropdownMenuContent align="end">
+                        <DropdownMenuItem onClick={() => handleEdit(preset.id)}>
+                          <Pencil className="mr-2 h-4 w-4" />
+                          {t("actions.edit")}
+                        </DropdownMenuItem>
+                        <DropdownMenuItem
+                          className="text-destructive"
+                          onClick={() => handleDelete(preset.id, preset.name)}
+                        >
+                          <Trash2 className="mr-2 h-4 w-4" />
+                          {t("actions.delete")}
+                        </DropdownMenuItem>
+                      </DropdownMenuContent>
+                    </DropdownMenu>
+                  </TableCell>
+                </TableRow>
+              ))
+            )}
+          </TableBody>
+        </Table>
+      </div>
+
+      <DataTablePagination
+        total={data.total}
+        page={data.page}
+        pageSize={data.pageSize}
+        totalPages={data.totalPages}
+        onNavigate={navigate}
+      />
+
+      <LaborPresetForm
+        key={editPreset?.id ?? "new"}
+        open={showForm}
+        onOpenChange={(open) => {
+          setShowForm(open);
+          if (!open) setEditPreset(null);
+        }}
+        preset={editPreset ?? undefined}
+        defaultLaborRate={defaultLaborRate}
+      />
+    </div>
+  );
+}

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

@@ -0,0 +1,62 @@
+import { getLaborPresetsPaginated } from "@/features/labor-presets/Actions/laborPresetActions";
+import { getSettings } from "@/features/settings/Actions/settingsActions";
+import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
+import { LaborPresetsClient } from "./labor-presets-client";
+import { PageHeader } from "@/components/page-header";
+
+export default async function LaborPresetsPage({
+  searchParams,
+}: {
+  searchParams: Promise<{
+    page?: string;
+    pageSize?: string;
+    search?: string;
+    sortBy?: string;
+    sortOrder?: string;
+  }>;
+}) {
+  const params = await searchParams;
+  const [result, settingsResult] = await Promise.all([
+    getLaborPresetsPaginated({
+      page: params.page ? parseInt(params.page) : 1,
+      pageSize: params.pageSize ? parseInt(params.pageSize) : 20,
+      search: params.search,
+      sortBy: params.sortBy,
+      sortOrder: params.sortOrder as "asc" | "desc" | undefined,
+    }),
+    getSettings([SETTING_KEYS.CURRENCY_CODE, SETTING_KEYS.DEFAULT_LABOR_RATE]),
+  ]);
+
+  if (!result.success || !result.data) {
+    return (
+      <>
+        <PageHeader />
+        <div className="flex h-[50vh] items-center justify-center">
+          <p className="text-muted-foreground">
+            {result.error || "Failed to load labor presets"}
+          </p>
+        </div>
+      </>
+    );
+  }
+
+  const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {};
+  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || "USD";
+  const defaultLaborRate = Number(settings[SETTING_KEYS.DEFAULT_LABOR_RATE]) || 0;
+
+  return (
+    <>
+      <PageHeader />
+      <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
+        <LaborPresetsClient
+          data={result.data}
+          search={params.search || ""}
+          sortBy={params.sortBy || "updatedAt"}
+          sortOrder={(params.sortOrder as "asc" | "desc") || "desc"}
+          currencyCode={currencyCode}
+          defaultLaborRate={defaultLaborRate}
+        />
+      </div>
+    </>
+  );
+}

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

@@ -1,6 +1,7 @@
 import { getQuote } from '@/features/quotes/Actions/quoteActions'
 import { getSettings } from '@/features/settings/Actions/settingsActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { getLaborPresetsList } from '@/features/labor-presets/Actions/laborPresetActions'
 import { getAuthContext } from '@/lib/get-auth-context'
 import { getFeatures } from '@/lib/features'
 import { PageHeader } from '@/components/page-header'
@@ -8,7 +9,7 @@ import { QuotePageClient } from '@/features/quotes/Components/QuotePageClient'
 
 export default async function QuoteDetailPage({ params }: { params: Promise<{ id: string }> }) {
   const { id } = await params
-  const [result, settingsResult, authContext] = await Promise.all([
+  const [result, settingsResult, presetsResult, authContext] = await Promise.all([
     getQuote(id),
     getSettings([
       SETTING_KEYS.CURRENCY_CODE,
@@ -16,6 +17,7 @@ export default async function QuoteDetailPage({ params }: { params: Promise<{ id
       SETTING_KEYS.TAX_ENABLED,
       SETTING_KEYS.DEFAULT_LABOR_RATE,
     ]),
+    getLaborPresetsList(),
     getAuthContext(),
   ])
 
@@ -38,6 +40,7 @@ export default async function QuoteDetailPage({ params }: { params: Promise<{ id
   const taxEnabled = settings[SETTING_KEYS.TAX_ENABLED] !== 'false'
   const defaultTaxRate = taxEnabled ? Number(settings[SETTING_KEYS.DEFAULT_TAX_RATE]) || 0 : 0
   const defaultLaborRate = Number(settings[SETTING_KEYS.DEFAULT_LABOR_RATE]) || 0
+  const laborPresets = presetsResult.success && presetsResult.data ? presetsResult.data : []
   const organizationId = authContext?.organizationId || ''
 
   // Separate attachments by category
@@ -62,6 +65,7 @@ export default async function QuoteDetailPage({ params }: { params: Promise<{ id
         defaultTaxRate={defaultTaxRate}
         taxEnabled={taxEnabled}
         defaultLaborRate={defaultLaborRate}
+        laborPresets={laborPresets}
         smsEnabled={features?.sms ?? false}
         emailEnabled={features?.smtp ?? false}
       />

+ 82 - 69
src/app/(authenticated)/settings/settings-nav.tsx

@@ -1,17 +1,17 @@
-"use client";
+'use client'
 
-import Link from "next/link";
-import { usePathname, useRouter } from "next/navigation";
-import { useTranslations } from "next-intl";
-import { cn } from "@/lib/utils";
-import type { PlanFeatures } from "@/lib/features";
+import Link from 'next/link'
+import { usePathname, useRouter } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { cn } from '@/lib/utils'
+import type { PlanFeatures } from '@/lib/features'
 import {
   Select,
   SelectContent,
   SelectItem,
   SelectTrigger,
   SelectValue,
-} from "@/components/ui/select";
+} from '@/components/ui/select'
 import {
   Banknote,
   Building2,
@@ -29,107 +29,120 @@ import {
   UserCog,
   UsersRound,
   Wrench,
-} from "lucide-react";
+} from 'lucide-react'
 
 type SettingsNavItem = {
-  key: string;
-  href: string;
-  icon: React.ComponentType<{ className?: string }>;
-  gate?: keyof PlanFeatures;
-  cloudOnly?: boolean;
-  selfHostedOnly?: boolean;
-};
+  key: string
+  href: string
+  icon: React.ComponentType<{ className?: string }>
+  gate?: keyof PlanFeatures
+  cloudOnly?: boolean
+  selfHostedOnly?: boolean
+}
 
 type SettingsCategory = {
-  key: string;
-  items: SettingsNavItem[];
-};
+  key: string
+  items: SettingsNavItem[]
+}
 
 const settingsCategories: SettingsCategory[] = [
   {
-    key: "general",
+    key: 'general',
     items: [
-      { key: "company", href: "/settings/company", icon: Building2 },
-      { key: "subscription", href: "/settings/subscription", icon: CreditCard, cloudOnly: true },
-      { key: "account", href: "/settings/account", icon: UserCog },
-      { key: "team", href: "/settings/team", icon: UsersRound },
-      { key: "localization", href: "/settings/localization", icon: Globe },
+      { key: 'company', href: '/settings/company', icon: Building2 },
+      { key: 'subscription', href: '/settings/subscription', icon: CreditCard, cloudOnly: true },
+      { key: 'account', href: '/settings/account', icon: UserCog },
+      { key: 'team', href: '/settings/team', icon: UsersRound },
+      { key: 'localization', href: '/settings/localization', icon: Globe },
     ],
   },
   {
-    key: "billing",
+    key: 'billing',
     items: [
-      { key: "invoice", href: "/settings/invoice", icon: FileText },
-      { key: "templates", href: "/settings/templates", icon: Layout, gate: "customTemplates" },
-      { key: "payment", href: "/settings/payment", icon: Banknote, gate: "payments" },
+      { key: 'invoice', href: '/settings/invoice', icon: FileText },
+      { key: 'templates', href: '/settings/templates', icon: Layout, gate: 'customTemplates' },
+      { key: 'payment', href: '/settings/payment', icon: Banknote, gate: 'payments' },
     ],
   },
   {
-    key: "communications",
+    key: 'communications',
     items: [
-      { key: "email", href: "/settings/email", icon: Mail, gate: "smtp" },
-      { key: "sms", href: "/settings/sms", icon: MessageSquare, gate: "sms" },
-      { key: "customerPortal", href: "/settings/customer-portal", icon: Globe, gate: "customerPortal" },
+      { key: 'email', href: '/settings/email', icon: Mail, gate: 'smtp' },
+      { key: 'sms', href: '/settings/sms', icon: MessageSquare, gate: 'sms' },
+      {
+        key: 'customerPortal',
+        href: '/settings/customer-portal',
+        icon: Globe,
+        gate: 'customerPortal',
+      },
     ],
   },
   {
-    key: "workshop",
+    key: 'workshop',
     items: [
-      { key: "workshop", href: "/settings/workshop", icon: Wrench },
-      { key: "maintenance", href: "/settings/maintenance", icon: Gauge },
-      { key: "ai", href: "/settings/ai", icon: Sparkles, gate: "ai" },
+      { key: 'workshop', href: '/settings/workshop', icon: Wrench },
+      { key: 'maintenance', href: '/settings/maintenance', icon: Gauge },
+      { key: 'ai', href: '/settings/ai', icon: Sparkles, gate: 'ai' },
     ],
   },
   {
-    key: "system",
+    key: 'system',
     items: [
-      { key: "data", href: "/settings/data", icon: Database },
-      { key: "license", href: "/settings/license", icon: Key, selfHostedOnly: true },
-      { key: "about", href: "/settings/about", icon: Info },
+      { key: 'data', href: '/settings/data', icon: Database },
+      { key: 'license', href: '/settings/license', icon: Key, selfHostedOnly: true },
+      { key: 'about', href: '/settings/about', icon: Info },
     ],
   },
-];
+]
 
-export function SettingsNav({ features, isCloud, mobile }: { features?: PlanFeatures; isCloud?: boolean; mobile?: boolean }) {
-  const pathname = usePathname();
-  const router = useRouter();
-  const t = useTranslations("settings");
+export function SettingsNav({
+  features,
+  isCloud,
+  mobile,
+}: {
+  features?: PlanFeatures
+  isCloud?: boolean
+  mobile?: boolean
+}) {
+  const pathname = usePathname()
+  const router = useRouter()
+  const t = useTranslations('settings')
 
   const filterItem = (item: SettingsNavItem) => {
-    if (item.cloudOnly && !isCloud) return false;
-    if (item.selfHostedOnly && isCloud) return false;
-    return true;
-  };
+    if (item.cloudOnly && !isCloud) return false
+    if (item.selfHostedOnly && isCloud) return false
+    return true
+  }
 
   if (mobile) {
-    const allItems = settingsCategories.flatMap((c) => c.items.filter(filterItem));
-    const activeItem = allItems.find((item) => pathname === item.href);
+    const allItems = settingsCategories.flatMap((c) => c.items.filter(filterItem))
+    const activeItem = allItems.find((item) => pathname === item.href)
 
     return (
       <Select value={activeItem?.href ?? pathname} onValueChange={(value) => router.push(value)}>
         <SelectTrigger className="w-full">
-          <SelectValue placeholder={t("title")} />
+          <SelectValue placeholder={t('title')} />
         </SelectTrigger>
         <SelectContent>
           {settingsCategories.map((category) => {
-            const visibleItems = category.items.filter(filterItem);
-            if (visibleItems.length === 0) return null;
+            const visibleItems = category.items.filter(filterItem)
+            if (visibleItems.length === 0) return null
             return visibleItems.map((item) => (
               <SelectItem key={item.href} value={item.href}>
                 {t(`nav.items.${item.key}.title`)}
               </SelectItem>
-            ));
+            ))
           })}
         </SelectContent>
       </Select>
-    );
+    )
   }
 
   return (
     <nav className="flex flex-col gap-4">
       {settingsCategories.map((category) => {
-        const visibleItems = category.items.filter(filterItem);
-        if (visibleItems.length === 0) return null;
+        const visibleItems = category.items.filter(filterItem)
+        if (visibleItems.length === 0) return null
         return (
           <div key={category.key}>
             <p className="mb-1 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground/60">
@@ -137,30 +150,30 @@ export function SettingsNav({ features, isCloud, mobile }: { features?: PlanFeat
             </p>
             <div className="flex flex-col gap-0.5">
               {visibleItems.map((item) => {
-                const isActive = pathname === item.href;
-                const isLocked = !!(item.gate && features && !features[item.gate]);
+                const isActive = pathname === item.href
+                const isLocked = !!(item.gate && features && !features[item.gate])
                 return (
                   <Link
                     key={item.href}
                     href={item.href}
                     className={cn(
-                      "flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors",
+                      'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors',
                       isActive
-                        ? "bg-primary/10 text-primary font-medium"
+                        ? 'bg-primary/10 text-primary font-medium'
                         : isLocked
-                          ? "text-muted-foreground/50 hover:bg-muted/50 hover:text-muted-foreground"
-                          : "text-muted-foreground hover:bg-muted hover:text-foreground"
+                          ? 'text-muted-foreground/50 hover:bg-muted/50 hover:text-muted-foreground'
+                          : 'text-muted-foreground hover:bg-muted hover:text-foreground'
                     )}
                   >
                     <item.icon className="h-4 w-4 shrink-0" />
                     <div className="min-w-0 flex-1">
                       <div className="flex items-center gap-2">
-                        <p className={cn("truncate", isActive && "font-medium")}>
+                        <p className={cn('truncate', isActive && 'font-medium')}>
                           {t(`nav.items.${item.key}.title`)}
                         </p>
                         {isLocked && (
                           <span className="shrink-0 rounded bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-semibold text-amber-600">
-                            {t("nav.pro")}
+                            {t('nav.pro')}
                           </span>
                         )}
                       </div>
@@ -169,12 +182,12 @@ export function SettingsNav({ features, isCloud, mobile }: { features?: PlanFeat
                       </p>
                     </div>
                   </Link>
-                );
+                )
               })}
             </div>
           </div>
-        );
+        )
       })}
     </nav>
-  );
+  )
 }

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

@@ -2,6 +2,7 @@ import { getServiceRecord } from "@/features/vehicles/Actions/serviceActions";
 import { getSettings } from "@/features/settings/Actions/settingsActions";
 import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
 import { getInventoryPartsList } from "@/features/inventory/Actions/inventoryActions";
+import { getLaborPresetsList } from "@/features/labor-presets/Actions/laborPresetActions";
 
 import { getTechnicians } from "@/features/workboard/Actions/technicianActions";
 import { getAuthContext } from "@/lib/get-auth-context";
@@ -19,7 +20,7 @@ export default async function ServiceDetailPage({
 }) {
   const { id, serviceId } = await params;
 
-  const [result, settingsResult, inventoryResult, techniciansResult, authContext, session] =
+  const [result, settingsResult, inventoryResult, techniciansResult, presetsResult, authContext, session] =
     await Promise.all([
       getServiceRecord(serviceId),
       getSettings([
@@ -31,6 +32,7 @@ export default async function ServiceDetailPage({
       ]),
       getInventoryPartsList(),
       getTechnicians(),
+      getLaborPresetsList(),
       getAuthContext(),
       getCachedSession(),
     ]);
@@ -63,6 +65,8 @@ export default async function ServiceDetailPage({
     Number(settings[SETTING_KEYS.DEFAULT_LABOR_RATE]) || 0;
   const inventoryParts =
     inventoryResult.success && inventoryResult.data ? inventoryResult.data : [];
+  const laborPresets =
+    presetsResult.success && presetsResult.data ? presetsResult.data : [];
   const initialVehicle = {
     id: record.vehicle.id,
     make: record.vehicle.make,
@@ -177,6 +181,7 @@ export default async function ServiceDetailPage({
         defaultLaborRate={defaultLaborRate}
         initialData={initialData}
         inventoryParts={inventoryParts}
+        laborPresets={laborPresets}
         initialVehicle={initialVehicle}
         boardTechnicians={boardTechnicians}
         currentUserName={currentUserName}

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

@@ -50,6 +50,7 @@ import {
   FileText,
   History,
   Globe,
+  Layers,
   MessageSquare,
   LayoutDashboard,
   Loader2,
@@ -124,6 +125,7 @@ export function AppSidebar({
     { titleKey: 'sidebar.quotes' as const, url: '/quotes', icon: FileText, subject: 'quotes' },
     { titleKey: 'sidebar.billing' as const, url: '/billing', icon: Receipt, subject: 'billing' },
     { titleKey: 'sidebar.inventory' as const, url: '/inventory', icon: Package, subject: 'inventory' },
+    { titleKey: 'sidebar.laborPresets' as const, url: '/labor-presets', icon: Layers, subject: 'labor_presets' },
     ...(features?.reports !== false && canAccess('reports') ? [{ titleKey: 'sidebar.reports' as const, url: '/reports', icon: BarChart3, subject: 'reports' }] : []),
     { titleKey: 'sidebar.auditLog' as const, url: '/audit-log', icon: History, subject: 'settings' },
   ].filter((item) => canAccess(item.subject))

+ 203 - 0
src/features/labor-presets/Actions/laborPresetActions.ts

@@ -0,0 +1,203 @@
+"use server";
+
+import { db } from "@/lib/db";
+import { withAuth } from "@/lib/with-auth";
+import { createLaborPresetSchema, updateLaborPresetSchema } from "../Schema/laborPresetSchema";
+import { revalidatePath } from "next/cache";
+import { Prisma } from "@prisma/client";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+
+export async function getLaborPresetsPaginated(params: {
+  page?: number;
+  pageSize?: number;
+  search?: string;
+  sortBy?: string;
+  sortOrder?: "asc" | "desc";
+}) {
+  return withAuth(async ({ userId, organizationId }) => {
+    const page = params.page || 1;
+    const pageSize = params.pageSize || 20;
+    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 } },
+        { description: { contains: q, mode } },
+      ];
+    }
+
+    const dir = params.sortOrder || "desc";
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    const orderBy: any = (() => {
+      switch (params.sortBy) {
+        case "name": return { name: dir };
+        case "description": return { description: dir };
+        case "createdAt": return { createdAt: dir };
+        default: return { updatedAt: dir };
+      }
+    })();
+
+    const [presets, total] = await Promise.all([
+      db.laborPreset.findMany({
+        where,
+        orderBy,
+        skip,
+        take: pageSize,
+        include: {
+          _count: { select: { items: true } },
+        },
+      }),
+      db.laborPreset.count({ where }),
+    ]);
+
+    return {
+      presets,
+      total,
+      page,
+      pageSize,
+      totalPages: Math.ceil(total / pageSize),
+    };
+  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.LABOR_PRESETS }] });
+}
+
+export async function getLaborPreset(presetId: string) {
+  return withAuth(async ({ userId, organizationId }) => {
+    const preset = await db.laborPreset.findFirst({
+      where: { id: presetId, organizationId },
+      include: {
+        items: { orderBy: { sortOrder: "asc" } },
+      },
+    });
+    if (!preset) throw new Error("Preset not found");
+    return preset;
+  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.LABOR_PRESETS }] });
+}
+
+export async function createLaborPreset(input: unknown) {
+  return withAuth(async ({ userId, organizationId }) => {
+    const data = createLaborPresetSchema.parse(input);
+    const resolvedName = data.name?.trim() || data.items[0]?.description || "Untitled";
+    const preset = await db.laborPreset.create({
+      data: {
+        name: resolvedName,
+        description: data.description || undefined,
+        userId,
+        organizationId,
+        items: {
+          create: data.items.map((item, index) => ({
+            description: item.description,
+            hours: item.hours,
+            rate: item.rate,
+            sortOrder: index,
+          })),
+        },
+      },
+      include: { items: true },
+    });
+    revalidatePath("/labor-presets");
+    return preset;
+  }, {
+    requiredPermissions: [{ action: PermissionAction.CREATE, subject: PermissionSubject.LABOR_PRESETS }],
+    audit: ({ result }) => ({
+      action: "labor_preset.create",
+      entity: "LaborPreset",
+      entityId: result.id,
+      message: `Created labor preset "${result.name}"`,
+      metadata: { presetId: result.id },
+    }),
+  });
+}
+
+export async function updateLaborPreset(input: unknown) {
+  return withAuth(async ({ userId, organizationId }) => {
+    const data = updateLaborPresetSchema.parse(input);
+    const { id, items, ...updateData } = data;
+    // Resolve name from first item if not provided
+    if (updateData.name !== undefined) {
+      updateData.name = updateData.name?.trim() || items?.[0]?.description || "Untitled";
+    }
+
+    const result = await db.$transaction(async (tx) => {
+      // Delete old items
+      await tx.laborPresetItem.deleteMany({ where: { presetId: id } });
+
+      // Update preset and create new items
+      return tx.laborPreset.update({
+        where: { id },
+        data: {
+          ...updateData,
+          description: updateData.description !== undefined ? (updateData.description || null) : undefined,
+          items: items ? {
+            create: items.map((item, index) => ({
+              description: item.description,
+              hours: item.hours,
+              rate: item.rate,
+              sortOrder: index,
+            })),
+          } : undefined,
+        },
+        include: { items: true },
+      });
+    });
+
+    revalidatePath("/labor-presets");
+    return { updated: true, presetId: id };
+  }, {
+    requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.LABOR_PRESETS }],
+    audit: ({ result }) => ({
+      action: "labor_preset.update",
+      entity: "LaborPreset",
+      entityId: result.presetId,
+      message: `Updated labor preset ${result.presetId}`,
+      metadata: { presetId: result.presetId },
+    }),
+  });
+}
+
+export async function deleteLaborPreset(presetId: string) {
+  return withAuth(async ({ userId, organizationId }) => {
+    const result = await db.laborPreset.deleteMany({
+      where: { id: presetId, organizationId },
+    });
+    if (result.count === 0) throw new Error("Preset not found");
+    revalidatePath("/labor-presets");
+    return { deleted: true, presetId };
+  }, {
+    requiredPermissions: [{ action: PermissionAction.DELETE, subject: PermissionSubject.LABOR_PRESETS }],
+    audit: ({ result }) => ({
+      action: "labor_preset.delete",
+      entity: "LaborPreset",
+      entityId: result.presetId,
+      message: `Deleted labor preset ${result.presetId}`,
+      metadata: { presetId: result.presetId },
+    }),
+  });
+}
+
+export async function getLaborPresetsList() {
+  return withAuth(async ({ userId, organizationId }) => {
+    return db.laborPreset.findMany({
+      where: { organizationId, isArchived: false },
+      select: {
+        id: true,
+        name: true,
+        description: true,
+        items: {
+          select: {
+            description: true,
+            hours: true,
+            rate: true,
+            sortOrder: true,
+          },
+          orderBy: { sortOrder: "asc" },
+        },
+      },
+      orderBy: { name: "asc" },
+    });
+  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.LABOR_PRESETS }] });
+}

+ 414 - 0
src/features/labor-presets/Components/LaborPresetForm.tsx

@@ -0,0 +1,414 @@
+"use client";
+
+import { useState } from "react";
+import { useTranslations } from "next-intl";
+import { useRouter } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import {
+  Dialog,
+  DialogContent,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { useGlassModal } from "@/components/glass-modal";
+import { createLaborPreset, updateLaborPreset } from "../Actions/laborPresetActions";
+import { Loader2, Plus, Trash2 } from "lucide-react";
+
+interface PresetItem {
+  description: string;
+  hours: number;
+  rate: number;
+}
+
+interface SingleItem {
+  name: string;
+  description: string;
+  hours: number;
+  rate: number;
+}
+
+interface LaborPresetData {
+  id: string;
+  name: string;
+  description: string | null;
+  items: { description: string; hours: number; rate: number; sortOrder: number }[];
+}
+
+type PresetMode = "single" | "package";
+
+interface LaborPresetFormProps {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  preset?: LaborPresetData;
+  defaultLaborRate?: number;
+}
+
+function detectMode(preset?: LaborPresetData): PresetMode {
+  if (!preset) return "single";
+  if (preset.items.length === 1 && preset.name === preset.items[0].description) return "single";
+  return "package";
+}
+
+export function LaborPresetForm({ open, onOpenChange, preset, defaultLaborRate = 0 }: LaborPresetFormProps) {
+  const router = useRouter();
+  const modal = useGlassModal();
+  const t = useTranslations("laborPresets");
+  const [loading, setLoading] = useState(false);
+  const [mode, setMode] = useState<PresetMode>(() => detectMode(preset));
+
+  // Package mode state
+  const [name, setName] = useState(preset?.name ?? "");
+  const [description, setDescription] = useState(preset?.description ?? "");
+  const [items, setItems] = useState<PresetItem[]>(
+    preset?.items.map((i) => ({ description: i.description, hours: i.hours, rate: i.rate })) ?? [
+      { description: "", hours: 0, rate: defaultLaborRate },
+    ]
+  );
+
+  // Single item mode state
+  const [singleItems, setSingleItems] = useState<SingleItem[]>(() => {
+    if (preset && detectMode(preset) === "single") {
+      return [{ name: preset.items[0].description, description: preset.description ?? "", hours: preset.items[0].hours, rate: preset.items[0].rate }];
+    }
+    return [{ name: "", description: "", hours: 0, rate: defaultLaborRate }];
+  });
+
+  const handleOpenChange = (isOpen: boolean) => {
+    if (isOpen) {
+      const detectedMode = detectMode(preset);
+      setMode(detectedMode);
+      setName(preset?.name ?? "");
+      setDescription(preset?.description ?? "");
+      setItems(
+        preset?.items.map((i) => ({ description: i.description, hours: i.hours, rate: i.rate })) ?? [
+          { description: "", hours: 0, rate: defaultLaborRate },
+        ]
+      );
+      if (detectedMode === "single" && preset) {
+        setSingleItems([{ name: preset.items[0].description, description: preset.description ?? "", hours: preset.items[0].hours, rate: preset.items[0].rate }]);
+      } else {
+        setSingleItems([{ name: "", description: "", hours: 0, rate: defaultLaborRate }]);
+      }
+    }
+    onOpenChange(isOpen);
+  };
+
+  const handleModeChange = (newMode: PresetMode) => {
+    setMode(newMode);
+  };
+
+  // Package mode helpers
+  const updateItem = (index: number, field: keyof PresetItem, value: string | number) => {
+    setItems((prev) => {
+      const updated = [...prev];
+      updated[index] = { ...updated[index], [field]: value };
+      return updated;
+    });
+  };
+
+  const addItem = () => {
+    setItems((prev) => [...prev, { description: "", hours: 0, rate: defaultLaborRate }]);
+  };
+
+  const removeItem = (index: number) => {
+    setItems((prev) => prev.filter((_, i) => i !== index));
+  };
+
+  // Single item mode helpers
+  const updateSingleItem = (index: number, field: keyof SingleItem, value: string | number) => {
+    setSingleItems((prev) => {
+      const updated = [...prev];
+      updated[index] = { ...updated[index], [field]: value };
+      return updated;
+    });
+  };
+
+  const addSingleItem = () => {
+    setSingleItems((prev) => [...prev, { name: "", description: "", hours: 0, rate: defaultLaborRate }]);
+  };
+
+  const removeSingleItem = (index: number) => {
+    setSingleItems((prev) => prev.filter((_, i) => i !== index));
+  };
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault();
+    setLoading(true);
+
+    if (mode === "single") {
+      const validItems = singleItems.filter((i) => i.name.trim());
+      if (validItems.length === 0) {
+        modal.open("error", t("errors.error"), t("errors.noItems"));
+        setLoading(false);
+        return;
+      }
+
+      if (preset) {
+        // Editing: update the single preset
+        const item = validItems[0];
+        const result = await updateLaborPreset({
+          id: preset.id,
+          name: "",
+          description: item.description || undefined,
+          items: [{ description: item.name, hours: Number(item.hours) || 0, rate: Number(item.rate) || 0, sortOrder: 0 }],
+        });
+        if (result.success) {
+          onOpenChange(false);
+          router.refresh();
+        } else {
+          modal.open("error", t("errors.error"), result.error || t("errors.saveFailed"));
+        }
+      } else {
+        // Creating: create one preset per single item
+        let allSuccess = true;
+        for (const item of validItems) {
+          const result = await createLaborPreset({
+            name: "",
+            description: item.description || undefined,
+            items: [{ description: item.name, hours: Number(item.hours) || 0, rate: Number(item.rate) || 0, sortOrder: 0 }],
+          });
+          if (!result.success) {
+            modal.open("error", t("errors.error"), result.error || t("errors.saveFailed"));
+            allSuccess = false;
+            break;
+          }
+        }
+        if (allSuccess) {
+          onOpenChange(false);
+          router.refresh();
+        }
+      }
+    } else {
+      // Package mode
+      const validItems = items.filter((i) => i.description.trim());
+      if (validItems.length === 0) {
+        modal.open("error", t("errors.error"), t("errors.noItems"));
+        setLoading(false);
+        return;
+      }
+
+      const data = {
+        name,
+        description: description || undefined,
+        items: validItems.map((item, index) => ({
+          description: item.description,
+          hours: Number(item.hours) || 0,
+          rate: Number(item.rate) || 0,
+          sortOrder: index,
+        })),
+      };
+
+      const result = preset
+        ? await updateLaborPreset({ ...data, id: preset.id })
+        : await createLaborPreset(data);
+
+      if (result.success) {
+        onOpenChange(false);
+        router.refresh();
+      } else {
+        modal.open("error", t("errors.error"), result.error || t("errors.saveFailed"));
+      }
+    }
+
+    setLoading(false);
+  };
+
+  return (
+    <Dialog open={open} onOpenChange={handleOpenChange}>
+      <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
+        <DialogHeader>
+          <DialogTitle>
+            {preset ? t("form.editPackage") : t("form.addPackage")}
+          </DialogTitle>
+        </DialogHeader>
+
+        {/* Mode tabs */}
+        <div className="flex rounded-lg border p-0.5 bg-muted/50">
+          <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>
+
+        <form onSubmit={handleSubmit} 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.itemHours")}</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]">
+                    <Input
+                      placeholder={t("form.singleNamePlaceholder")}
+                      value={item.name}
+                      onChange={(e) => updateSingleItem(i, "name", e.target.value)}
+                      className="col-span-2 sm:col-span-1"
+                    />
+                    <Input
+                      type="number"
+                      min="0"
+                      step="any"
+                      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}
+                    >
+                      <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>
+            </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>
+
+              <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.itemHours")}</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]">
+                    <Input
+                      placeholder={t("form.itemDescriptionPlaceholder")}
+                      value={item.description}
+                      onChange={(e) => updateItem(i, "description", e.target.value)}
+                      className="col-span-2 sm:col-span-1"
+                    />
+                    <Input
+                      type="number"
+                      min="0"
+                      step="any"
+                      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}
+                    >
+                      <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>
+            </>
+          )}
+
+          <div className="flex justify-end gap-3 pt-4">
+            <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
+              {t("form.cancel")}
+            </Button>
+            <Button type="submit" disabled={loading}>
+              {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+              {preset ? t("form.saveChanges") : t("form.create")}
+            </Button>
+          </div>
+        </form>
+      </DialogContent>
+    </Dialog>
+  );
+}

+ 127 - 0
src/features/labor-presets/Components/LaborPresetPickerDialog.tsx

@@ -0,0 +1,127 @@
+"use client";
+
+import { useState } from "react";
+import { useTranslations } from "next-intl";
+import { Input } from "@/components/ui/input";
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { ChevronDown, ChevronRight, Search } from "lucide-react";
+
+export interface LaborPresetOption {
+  id: string;
+  name: string;
+  description: string | null;
+  items: { description: string; hours: number; rate: number; sortOrder: number }[];
+}
+
+interface LaborPresetPickerDialogProps {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  laborPresets: LaborPresetOption[];
+  onSelectPreset: (preset: LaborPresetOption) => void;
+}
+
+export function LaborPresetPickerDialog({
+  open,
+  onOpenChange,
+  laborPresets,
+  onSelectPreset,
+}: LaborPresetPickerDialogProps) {
+  const t = useTranslations("laborPresets.picker");
+  const [search, setSearch] = useState("");
+  const [expandedId, setExpandedId] = useState<string | null>(null);
+
+  const filtered = laborPresets.filter((p) => {
+    if (!search) return true;
+    const q = search.toLowerCase();
+    return (
+      p.name.toLowerCase().includes(q) ||
+      (p.description && p.description.toLowerCase().includes(q))
+    );
+  });
+
+  return (
+    <Dialog
+      open={open}
+      onOpenChange={(v) => {
+        onOpenChange(v);
+        if (!v) {
+          setSearch("");
+          setExpandedId(null);
+        }
+      }}
+    >
+      <DialogContent className="sm:max-w-2xl">
+        <DialogHeader>
+          <DialogTitle>{t("title")}</DialogTitle>
+        </DialogHeader>
+        <div className="relative">
+          <Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+          <Input
+            placeholder={t("search")}
+            value={search}
+            onChange={(e) => setSearch(e.target.value)}
+            className="pl-9"
+          />
+        </div>
+        <div className="max-h-75 overflow-y-auto space-y-1">
+          {filtered.map((preset) => {
+            const totalHours = preset.items.reduce((sum, i) => sum + i.hours, 0);
+            const isExpanded = expandedId === preset.id;
+            const isSingleItem = preset.items.length === 1;
+
+            return (
+              <div key={preset.id} className="rounded-md border border-transparent hover:border-border">
+                <button
+                  type="button"
+                  className="w-full text-left rounded-md px-2.5 py-2 hover:bg-accent transition-colors flex items-center justify-between gap-4"
+                  onClick={() => {
+                    onSelectPreset(preset);
+                    onOpenChange(false);
+                  }}
+                >
+                  <div className="min-w-0 flex-1">
+                    <div className="flex items-center gap-2">
+                      <span className="font-medium text-sm truncate">{preset.name}</span>
+                    </div>
+                    {preset.description && (
+                      <p className="text-xs text-muted-foreground truncate">{preset.description}</p>
+                    )}
+                  </div>
+                  <div className="flex items-center gap-3 shrink-0 text-xs text-muted-foreground">
+                    {!isSingleItem && <span>{t("items", { count: preset.items.length })}</span>}
+                    <span>{t("totalHours", { hours: totalHours.toFixed(1) })}</span>
+                    {!isSingleItem && (
+                      <button
+                        type="button"
+                        className="p-1 hover:text-foreground"
+                        onClick={(e) => {
+                          e.stopPropagation();
+                          setExpandedId(isExpanded ? null : preset.id);
+                        }}
+                      >
+                        {isExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
+                      </button>
+                    )}
+                  </div>
+                </button>
+                {isExpanded && !isSingleItem && (
+                  <div className="px-3 pb-2 space-y-0.5">
+                    {preset.items.map((item, i) => (
+                      <div key={i} className="flex items-center justify-between text-xs text-muted-foreground py-0.5 pl-2 border-l-2 border-muted">
+                        <span className="truncate">{item.description}</span>
+                        <span className="shrink-0 ml-2">{item.hours}h</span>
+                      </div>
+                    ))}
+                  </div>
+                )}
+              </div>
+            );
+          })}
+          {filtered.length === 0 && (
+            <p className="py-6 text-center text-sm text-muted-foreground">{t("noPackages")}</p>
+          )}
+        </div>
+      </DialogContent>
+    </Dialog>
+  );
+}

+ 22 - 0
src/features/labor-presets/Schema/laborPresetSchema.ts

@@ -0,0 +1,22 @@
+import { z } from "zod";
+
+export const laborPresetItemSchema = z.object({
+  description: z.string().min(1, "Description is required"),
+  hours: z.coerce.number().min(0, "Hours must be 0 or more").default(0),
+  rate: z.coerce.number().min(0, "Rate must be 0 or more").default(0),
+  sortOrder: z.coerce.number().int().min(0).default(0),
+});
+
+export const createLaborPresetSchema = z.object({
+  name: z.string().optional(),
+  description: z.string().optional(),
+  items: z.array(laborPresetItemSchema).min(1, "At least one item is required"),
+});
+
+export const updateLaborPresetSchema = createLaborPresetSchema.partial().extend({
+  id: z.string(),
+});
+
+export type LaborPresetItemInput = z.infer<typeof laborPresetItemSchema>;
+export type CreateLaborPresetInput = z.infer<typeof createLaborPresetSchema>;
+export type UpdateLaborPresetInput = z.infer<typeof updateLaborPresetSchema>;

+ 14 - 2
src/features/quotes/Components/QuoteLaborEditor.tsx

@@ -3,7 +3,7 @@
 import { memo } from "react";
 import { Button } from "@/components/ui/button";
 import { Input } from "@/components/ui/input";
-import { Plus, Trash2 } from "lucide-react";
+import { Layers, Plus, Trash2 } from "lucide-react";
 import { formatCurrency } from "@/lib/format";
 import type { QuoteLaborInput } from "./quote-page-types";
 
@@ -44,6 +44,8 @@ interface QuoteLaborEditorProps {
   onUpdate: (index: number, field: keyof QuoteLaborInput, value: string | number | boolean) => void;
   onDelete: (index: number) => void;
   onAdd: () => void;
+  hasPresets?: boolean;
+  onOpenPresets?: () => void;
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   t: (key: string, values?: any) => string;
 }
@@ -56,13 +58,23 @@ export const QuoteLaborEditor = memo(function QuoteLaborEditor({
   onUpdate,
   onDelete,
   onAdd,
+  hasPresets,
+  onOpenPresets,
   t,
 }: QuoteLaborEditorProps) {
   return (
     <div className="rounded-lg border p-3 space-y-2">
       <div className="flex items-center justify-between">
         <h3 className="text-sm font-semibold">{t("labor.title")}</h3>
-        <Button type="button" variant="outline" size="sm" onClick={onAdd}><Plus className="mr-1 h-3.5 w-3.5" /> {t("labor.addLabor")}</Button>
+        <div className="flex gap-2">
+          {hasPresets && onOpenPresets && (
+            <Button type="button" variant="outline" size="sm" onClick={onOpenPresets}>
+              <Layers className="mr-1 h-3.5 w-3.5" />
+              {t("labor.fromPresets")}
+            </Button>
+          )}
+          <Button type="button" variant="outline" size="sm" onClick={onAdd}><Plus className="mr-1 h-3.5 w-3.5" /> {t("labor.addLabor")}</Button>
+        </div>
       </div>
       {laborItems.length > 0 && (
         <>

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

@@ -31,6 +31,7 @@ import { getCurrencySymbol } from '@/lib/format'
 import type { QuoteAttachment, QuoteRecord, TabType } from './quote-page-types'
 import { statusColors } from './quote-page-types'
 import { useQuoteFormState } from './useQuoteFormState'
+import { LaborPresetPickerDialog, type LaborPresetOption } from '@/features/labor-presets/Components/LaborPresetPickerDialog'
 import { QuotePartsEditor } from './QuotePartsEditor'
 import { QuoteLaborEditor } from './QuoteLaborEditor'
 import { QuoteNotesEditor } from './QuoteNotesEditor'
@@ -58,6 +59,7 @@ export function QuotePageClient({
   defaultTaxRate = 0,
   taxEnabled = true,
   defaultLaborRate = 0,
+  laborPresets = [],
   smsEnabled = false,
   emailEnabled = false,
   imageAttachments = [],
@@ -71,6 +73,7 @@ export function QuotePageClient({
   defaultTaxRate?: number
   taxEnabled?: boolean
   defaultLaborRate?: number
+  laborPresets?: LaborPresetOption[]
   smsEnabled?: boolean
   emailEnabled?: boolean
   imageAttachments?: QuoteAttachment[]
@@ -92,6 +95,22 @@ export function QuotePageClient({
     t,
   })
 
+  const [showPresetPicker, setShowPresetPicker] = useState(false)
+
+  const handleSelectPreset = useCallback(
+    (preset: LaborPresetOption) => {
+      const newItems = preset.items.map((item) => ({
+        description: item.description,
+        hours: item.hours,
+        rate: item.rate > 0 ? item.rate : defaultLaborRate,
+        total: item.hours * (item.rate > 0 ? item.rate : defaultLaborRate),
+        excluded: false,
+      }))
+      state.addLaborBulk(newItems)
+    },
+    [defaultLaborRate, state]
+  )
+
   const handleDescriptionChange = useCallback(
     (v: string) => {
       state.setDescription(v)
@@ -136,6 +155,8 @@ export function QuotePageClient({
         onUpdate={state.updateLabor}
         onDelete={state.deleteLabor}
         onAdd={state.addLabor}
+        hasPresets={laborPresets.length > 0}
+        onOpenPresets={() => setShowPresetPicker(true)}
         t={t}
       />
       <QuoteNotesEditor
@@ -345,6 +366,13 @@ export function QuotePageClient({
         </div>
       )}
 
+      <LaborPresetPickerDialog
+        open={showPresetPicker}
+        onOpenChange={setShowPresetPicker}
+        laborPresets={laborPresets}
+        onSelectPreset={handleSelectPreset}
+      />
+
       {/* Dialogs */}
       <SendEmailDialog
         open={state.showEmailDialog}

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

@@ -177,6 +177,11 @@ export function useQuoteFormState({
     markDirty();
   }, [defaultLaborRate, markDirty]);
 
+  const addLaborBulk = useCallback((items: QuoteLaborInput[]) => {
+    setLaborItems((prev) => [...prev, ...items]);
+    markDirty();
+  }, [markDirty]);
+
   const deletePart = useCallback((index: number) => {
     setPartItems((prev) => prev.filter((_, j) => j !== index));
     markDirty();
@@ -320,7 +325,7 @@ export function useQuoteFormState({
     partsSubtotal, laborSubtotal, subtotal, discountAmount, taxAmount, totalAmount,
     selectedVehicle, setSelectedVehicle, selectedCustomer, setSelectedCustomer,
     // Callbacks
-    markDirty, updatePart, updateLabor, addPart, addLabor, deletePart, deleteLabor,
+    markDirty, updatePart, updateLabor, addPart, addLabor, addLaborBulk, deletePart, deleteLabor,
     handleSubmit, handleDelete, handleDownloadPDF,
     handleConvert, handleResolveResponse, saveNow,
   };

+ 22 - 10
src/features/vehicles/Components/service-edit/LaborEditor.tsx

@@ -3,7 +3,7 @@
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Textarea } from '@/components/ui/textarea'
-import { Plus, Trash2 } from 'lucide-react'
+import { Layers, Plus, Trash2 } from 'lucide-react'
 import { formatCurrency, getCurrencySymbol } from '@/lib/format'
 import { useTranslations } from 'next-intl'
 import type { ServiceLaborInput } from '@/features/vehicles/Schema/serviceSchema'
@@ -16,6 +16,8 @@ interface LaborEditorProps {
   laborSubtotal: number
   currencyCode: string
   defaultLaborRate: number
+  hasPresets?: boolean
+  onOpenPresets?: () => void
 }
 
 export function LaborEditor({
@@ -25,6 +27,8 @@ export function LaborEditor({
   laborSubtotal,
   currencyCode,
   defaultLaborRate,
+  hasPresets,
+  onOpenPresets,
 }: LaborEditorProps) {
   const t = useTranslations('service.labor')
   const cs = getCurrencySymbol(currencyCode)
@@ -33,15 +37,23 @@ export function LaborEditor({
     <div className="rounded-lg border p-3 space-y-2">
       <div className="flex items-center justify-between">
         <h3 className="text-sm font-semibold">{t('title')}</h3>
-        <Button
-          type="button"
-          variant="outline"
-          size="sm"
-          onClick={() => setLaborItems((prev) => [...prev, makeEmptyLabor(defaultLaborRate)])}
-        >
-          <Plus className="mr-1 h-3.5 w-3.5" />
-          {t('addLabor')}
-        </Button>
+        <div className="flex gap-2">
+          {hasPresets && onOpenPresets && (
+            <Button type="button" variant="outline" size="sm" onClick={onOpenPresets}>
+              <Layers className="mr-1 h-3.5 w-3.5" />
+              {t('fromPresets')}
+            </Button>
+          )}
+          <Button
+            type="button"
+            variant="outline"
+            size="sm"
+            onClick={() => setLaborItems((prev) => [...prev, makeEmptyLabor(defaultLaborRate)])}
+          >
+            <Plus className="mr-1 h-3.5 w-3.5" />
+            {t('addLabor')}
+          </Button>
+        </div>
       </div>
 
       {laborItems.length > 0 && (

+ 6 - 0
src/features/vehicles/Components/service-page/DetailsLeftColumn.tsx

@@ -15,6 +15,8 @@ interface DetailsLeftColumnProps {
   currencyCode: string
   defaultLaborRate: number
   inventoryParts: InventoryPartOption[]
+  hasPresets?: boolean
+  onOpenPresets?: () => void
   aiEnabled?: boolean
 }
 
@@ -25,6 +27,8 @@ export function DetailsLeftColumn({
   currencyCode,
   defaultLaborRate,
   inventoryParts,
+  hasPresets,
+  onOpenPresets,
   aiEnabled,
 }: DetailsLeftColumnProps) {
   return (
@@ -45,6 +49,8 @@ export function DetailsLeftColumn({
         laborSubtotal={formState.laborSubtotal}
         currencyCode={currencyCode}
         defaultLaborRate={defaultLaborRate}
+        hasPresets={hasPresets}
+        onOpenPresets={onOpenPresets}
       />
       <NotesSection
         initialData={formState.initialData}

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

@@ -10,6 +10,8 @@ import { ImageCarousel } from '../service-detail/ImageCarousel'
 import { ShareDialog } from '../service-detail/ShareDialog'
 import { NotifyCustomerDialog } from '@/components/notify-customer-dialog'
 import { InventoryPickerDialog } from '../service-edit/InventoryPickerDialog'
+import { LaborPresetPickerDialog } from '@/features/labor-presets/Components/LaborPresetPickerDialog'
+import type { LaborPresetOption } from './service-page-types'
 import { ServiceImagesManager } from '../service-images-manager'
 import { ServiceVideoManager } from '../service-video-manager'
 import { ServiceDocumentsManager } from '../service-documents-manager'
@@ -43,6 +45,7 @@ export function ServicePageClient({
   maxImagesPerService,
   maxDiagnosticsPerService,
   maxDocumentsPerService,
+  laborPresets = [],
   smsEnabled = false,
   emailEnabled = false,
   aiEnabled = false,
@@ -58,6 +61,16 @@ export function ServicePageClient({
     record,
   })
 
+  const handleSelectPreset = (preset: LaborPresetOption) => {
+    const newItems = preset.items.map((item) => ({
+      description: item.description,
+      hours: item.hours,
+      rate: item.rate > 0 ? item.rate : defaultLaborRate,
+      total: item.hours * (item.rate > 0 ? item.rate : defaultLaborRate),
+    }))
+    formState.dirtySetLaborItems((prev) => [...prev, ...newItems])
+  }
+
   const actions = useServiceActions({
     record,
     vehicleId,
@@ -101,6 +114,8 @@ export function ServicePageClient({
                 currencyCode={currencyCode}
                 defaultLaborRate={defaultLaborRate}
                 inventoryParts={inventoryParts}
+                hasPresets={laborPresets.length > 0}
+                onOpenPresets={() => formState.setShowPresetPicker(true)}
                 aiEnabled={aiEnabled}
               />
             }
@@ -159,6 +174,13 @@ export function ServicePageClient({
         onSelectPart={(part) => formState.dirtySetPartItems((prev) => [...prev, part])}
       />
 
+      <LaborPresetPickerDialog
+        open={formState.showPresetPicker}
+        onOpenChange={formState.setShowPresetPicker}
+        laborPresets={laborPresets}
+        onSelectPreset={handleSelectPreset}
+      />
+
       <ImageCarousel
         images={formState.imageAttachments}
         currentIndex={actions.carouselIndex}

+ 3 - 1
src/features/vehicles/Components/service-page/service-page-types.ts

@@ -1,6 +1,7 @@
 import type { ServicePartInput, ServiceLaborInput } from '@/features/vehicles/Schema/serviceSchema'
 import type { ServiceDetail } from '../service-detail/types'
 import type { InitialData, InventoryPartOption } from '../service-edit/form-types'
+import type { LaborPresetOption } from '@/features/labor-presets/Components/LaborPresetPickerDialog'
 
 export interface BoardTechnicianOption {
   id: string
@@ -39,9 +40,10 @@ export interface ServicePageClientProps {
   maxImagesPerService: number
   maxDiagnosticsPerService: number
   maxDocumentsPerService: number
+  laborPresets?: LaborPresetOption[]
   smsEnabled?: boolean
   emailEnabled?: boolean
   aiEnabled?: boolean
 }
 
-export type { ServicePartInput, ServiceLaborInput, ServiceDetail, InitialData, InventoryPartOption }
+export type { ServicePartInput, ServiceLaborInput, ServiceDetail, InitialData, InventoryPartOption, LaborPresetOption }

+ 2 - 0
src/features/vehicles/Components/service-page/useServiceFormState.ts

@@ -28,6 +28,7 @@ export function useServiceFormState({
   const [discountType, setDiscountType] = useState<string>(initialData.discountType || 'none')
   const [discountValue, setDiscountValue] = useState(initialData.discountValue ?? 0)
   const [showInventoryPicker, setShowInventoryPicker] = useState(false)
+  const [showPresetPicker, setShowPresetPicker] = useState(false)
 
   // Autosave state
   const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
@@ -181,6 +182,7 @@ export function useServiceFormState({
     partItems, laborItems,
     taxRate, discountType, discountValue,
     showInventoryPicker, setShowInventoryPicker,
+    showPresetPicker, setShowPresetPicker,
     // Autosave
     hasUnsavedChanges, setHasUnsavedChanges, showSaved,
     formRef, autosaveTimer, isSavingRef,

+ 2 - 0
src/i18n/request.ts

@@ -66,6 +66,7 @@ export default getRequestConfig(async () => {
   const reminders = (await import(`../../messages/${locale}/reminders.json`)).default
   const audit = (await import(`../../messages/${locale}/audit.json`)).default
   const aiChat = (await import(`../../messages/${locale}/aiChat.json`)).default
+  const laborPresets = (await import(`../../messages/${locale}/laborPresets.json`)).default
 
   return {
     locale,
@@ -94,6 +95,7 @@ export default getRequestConfig(async () => {
       reminders,
       audit,
       aiChat,
+      laborPresets,
     },
   }
 })

+ 11 - 0
src/lib/permissions.ts

@@ -15,6 +15,7 @@ export enum PermissionSubject {
   SERVICES = "services",
   BILLING = "billing",
   INVENTORY = "inventory",
+  LABOR_PRESETS = "labor_presets",
   INSPECTIONS = "inspections",
   REPORTS = "reports",
   SETTINGS = "settings",
@@ -114,6 +115,16 @@ export const permissionGroups: PermissionGroup[] = [
       { action: PermissionAction.DELETE, label: "Delete" },
     ],
   },
+  {
+    name: "Labor Presets",
+    subject: PermissionSubject.LABOR_PRESETS,
+    permissions: [
+      { action: PermissionAction.CREATE, label: "Create" },
+      { action: PermissionAction.READ, label: "View" },
+      { action: PermissionAction.UPDATE, label: "Edit" },
+      { action: PermissionAction.DELETE, label: "Delete" },
+    ],
+  },
   {
     name: "Inspections",
     subject: PermissionSubject.INSPECTIONS,