Bernt Christian Egeland 2 месяцев назад
Родитель
Сommit
6225d63e41
100 измененных файлов с 2787 добавлено и 830 удалено
  1. 12 0
      .github/workflows/deploy-cloud.yml
  2. 12 0
      .github/workflows/deploy-demo.yml
  3. 12 0
      .github/workflows/deploy-staging.yml
  4. 24 0
      messages/de/admin.json
  5. 8 3
      messages/de/auth.json
  6. 12 1
      messages/de/service.json
  7. 9 1
      messages/de/settings.json
  8. 24 0
      messages/en/admin.json
  9. 6 1
      messages/en/auth.json
  10. 12 1
      messages/en/service.json
  11. 9 1
      messages/en/settings.json
  12. 24 0
      messages/es/admin.json
  13. 8 3
      messages/es/auth.json
  14. 12 1
      messages/es/service.json
  15. 9 1
      messages/es/settings.json
  16. 24 0
      messages/fr/admin.json
  17. 8 3
      messages/fr/auth.json
  18. 8 8
      messages/fr/pdf.json
  19. 12 1
      messages/fr/service.json
  20. 9 1
      messages/fr/settings.json
  21. 24 0
      messages/it/admin.json
  22. 8 3
      messages/it/auth.json
  23. 12 1
      messages/it/service.json
  24. 9 1
      messages/it/settings.json
  25. 24 0
      messages/lt/admin.json
  26. 8 3
      messages/lt/auth.json
  27. 12 1
      messages/lt/service.json
  28. 9 1
      messages/lt/settings.json
  29. 24 0
      messages/nb/admin.json
  30. 8 3
      messages/nb/auth.json
  31. 12 1
      messages/nb/service.json
  32. 9 1
      messages/nb/settings.json
  33. 24 0
      messages/nl/admin.json
  34. 8 3
      messages/nl/auth.json
  35. 12 1
      messages/nl/service.json
  36. 9 1
      messages/nl/settings.json
  37. 24 0
      messages/pl/admin.json
  38. 8 3
      messages/pl/auth.json
  39. 12 1
      messages/pl/service.json
  40. 9 1
      messages/pl/settings.json
  41. 24 0
      messages/pt-BR/admin.json
  42. 31 26
      messages/pt-BR/auth.json
  43. 12 1
      messages/pt-BR/service.json
  44. 9 1
      messages/pt-BR/settings.json
  45. 24 0
      messages/ru/admin.json
  46. 8 3
      messages/ru/auth.json
  47. 12 1
      messages/ru/service.json
  48. 9 1
      messages/ru/settings.json
  49. 24 0
      messages/tr/admin.json
  50. 8 3
      messages/tr/auth.json
  51. 12 1
      messages/tr/service.json
  52. 9 1
      messages/tr/settings.json
  53. 348 252
      package-lock.json
  54. 18 21
      package.json
  55. 2 0
      prisma/migrations/20260525102721_part_markup/migration.sql
  56. 1 0
      prisma/schema.prisma
  57. 136 0
      src/__tests__/features/inventory/reconcile-stock.test.ts
  58. 13 5
      src/__tests__/features/workorders/add-part.test.ts
  59. 14 10
      src/__tests__/features/workorders/create-workorder.test.ts
  60. 8 0
      src/__tests__/multitenancy/vehicle-service-isolation.test.ts
  61. 18 0
      src/app/(authenticated)/admin/organizations/[id]/page.tsx
  62. 18 0
      src/app/(authenticated)/admin/users/[id]/page.tsx
  63. 25 10
      src/app/(authenticated)/admin/users/page.tsx
  64. 56 0
      src/app/(authenticated)/settings/invoice/invoice-settings.tsx
  65. 6 0
      src/app/(authenticated)/settings/subscription/page.tsx
  66. 12 0
      src/app/(authenticated)/vehicles/[id]/service/[serviceId]/page.tsx
  67. 24 5
      src/app/(public)/auth/sign-in/sign-in-form.tsx
  68. 75 23
      src/app/(public)/auth/sign-up/sign-up-form.tsx
  69. 1 13
      src/app/(public)/share/invoice/[orgId]/[token]/invoice-view.tsx
  70. 1 1
      src/app/(public)/share/quote/[orgId]/[token]/quote-view.tsx
  71. 1 1
      src/components/posthog-provider.tsx
  72. 90 0
      src/features/admin/Actions/getOrganizationDetails.ts
  73. 63 0
      src/features/admin/Actions/getUserDetails.ts
  74. 30 17
      src/features/admin/Actions/getUsers.ts
  75. 205 0
      src/features/admin/Components/admin-organization-details.tsx
  76. 163 0
      src/features/admin/Components/admin-user-details.tsx
  77. 200 107
      src/features/admin/Components/admin-users.tsx
  78. 17 12
      src/features/admin/Schema/adminSchema.ts
  79. 1 1
      src/features/email/Components/SendEmailDialog.tsx
  80. 4 1
      src/features/inventory/Actions/inventoryActions.ts
  81. 80 0
      src/features/inventory/Lib/reconcileStock.ts
  82. 1 2
      src/features/settings/Components/InvoiceLayoutPreviewRenderer.tsx
  83. 0 2
      src/features/settings/Schema/invoiceLayoutSchema.ts
  84. 2 0
      src/features/settings/Schema/settingsSchema.ts
  85. 36 8
      src/features/subscription/Components/subscription-settings.tsx
  86. 51 48
      src/features/vehicles/Actions/addPartToServiceRecord.ts
  87. 31 7
      src/features/vehicles/Actions/serviceActions.ts
  88. 1 10
      src/features/vehicles/Components/invoice-pdf/InvoicePDF.tsx
  89. 0 72
      src/features/vehicles/Components/invoice-pdf/Notes.tsx
  90. 1 1
      src/features/vehicles/Components/service-detail/ShareDialog.tsx
  91. 28 3
      src/features/vehicles/Components/service-edit/InventoryPickerDialog.tsx
  92. 13 1
      src/features/vehicles/Components/service-edit/InvoiceDetailsSection.tsx
  93. 77 8
      src/features/vehicles/Components/service-edit/PartsEditor.tsx
  94. 2 1
      src/features/vehicles/Components/service-edit/form-types.ts
  95. 6 0
      src/features/vehicles/Components/service-page/DetailsLeftColumn.tsx
  96. 150 98
      src/features/vehicles/Components/service-page/ServicePageClient.tsx
  97. 2 0
      src/features/vehicles/Components/service-page/service-page-types.ts
  98. 30 1
      src/features/vehicles/Components/service-page/useServiceFormState.ts
  99. 1 0
      src/features/vehicles/Schema/serviceSchema.ts
  100. 24 0
      src/features/workboard/Actions/boardActions/assignments.ts

+ 12 - 0
.github/workflows/deploy-cloud.yml

@@ -73,6 +73,18 @@ jobs:
           mkdir -p "$DATA_DIR"
           docker run --rm -v "$DATA_DIR":/data alpine chown -R 1001:1001 /data
 
+      - name: Check free disk space
+        run: |
+          MIN_GB=5
+          DOCKER_ROOT=$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || echo /var/lib/docker)
+          AVAIL_KB=$(df -Pk "$DOCKER_ROOT" | awk 'NR==2 {print $4}')
+          AVAIL_GB=$(awk "BEGIN {printf \"%.1f\", $AVAIL_KB/1024/1024}")
+          echo "Free space on $DOCKER_ROOT: ${AVAIL_GB} GB (minimum ${MIN_GB} GB)"
+          if [ "$AVAIL_KB" -lt $((MIN_GB * 1024 * 1024)) ]; then
+            echo "::error::Not enough disk space to pull image: ${AVAIL_GB} GB free, need ${MIN_GB} GB. Aborting deploy."
+            exit 1
+          fi
+
       - name: Deploy
         run: |
           cd $HOME/torqvoice-deploy/prod

+ 12 - 0
.github/workflows/deploy-demo.yml

@@ -125,6 +125,18 @@ jobs:
           mkdir -p "$DATA_DIR"
           docker run --rm -v "$DATA_DIR":/data alpine chown -R 1001:1001 /data
 
+      - name: Check free disk space
+        run: |
+          MIN_GB=5
+          DOCKER_ROOT=$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || echo /var/lib/docker)
+          AVAIL_KB=$(df -Pk "$DOCKER_ROOT" | awk 'NR==2 {print $4}')
+          AVAIL_GB=$(awk "BEGIN {printf \"%.1f\", $AVAIL_KB/1024/1024}")
+          echo "Free space on $DOCKER_ROOT: ${AVAIL_GB} GB (minimum ${MIN_GB} GB)"
+          if [ "$AVAIL_KB" -lt $((MIN_GB * 1024 * 1024)) ]; then
+            echo "::error::Not enough disk space to pull image: ${AVAIL_GB} GB free, need ${MIN_GB} GB. Aborting deploy."
+            exit 1
+          fi
+
       - name: Deploy demo
         run: |
           cd $HOME/torqvoice-deploy/demo

+ 12 - 0
.github/workflows/deploy-staging.yml

@@ -75,6 +75,18 @@ jobs:
           mkdir -p "$DATA_DIR"
           docker run --rm -v "$DATA_DIR":/data alpine chown -R 1001:1001 /data
 
+      - name: Check free disk space
+        run: |
+          MIN_GB=5
+          DOCKER_ROOT=$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || echo /var/lib/docker)
+          AVAIL_KB=$(df -Pk "$DOCKER_ROOT" | awk 'NR==2 {print $4}')
+          AVAIL_GB=$(awk "BEGIN {printf \"%.1f\", $AVAIL_KB/1024/1024}")
+          echo "Free space on $DOCKER_ROOT: ${AVAIL_GB} GB (minimum ${MIN_GB} GB)"
+          if [ "$AVAIL_KB" -lt $((MIN_GB * 1024 * 1024)) ]; then
+            echo "::error::Not enough disk space to pull image: ${AVAIL_GB} GB free, need ${MIN_GB} GB. Aborting deploy."
+            exit 1
+          fi
+
       - name: Deploy staging
         run: |
           cd $HOME/torqvoice-deploy/staging

+ 24 - 0
messages/de/admin.json

@@ -119,5 +119,29 @@
   },
   "common": {
     "openMenu": "Open menu"
+  },
+  "userDetails": {
+    "back": "Zurück zu Benutzern",
+    "never": "Nie",
+    "lastLogin": "Letzte Anmeldung",
+    "organizations": "Organisationen",
+    "noOrganizations": "Kein Mitglied einer Organisation",
+    "role": "Rolle"
+  },
+  "orgDetails": {
+    "back": "Zurück",
+    "vehicles": "Fahrzeuge",
+    "workOrders": "Arbeitsaufträge",
+    "customers": "Kunden",
+    "quotes": "Angebote",
+    "inspections": "Inspektionen",
+    "inventoryParts": "Lagerteile",
+    "members": "Mitglieder",
+    "owner": "Inhaber",
+    "noOwner": "Kein Inhaber",
+    "subscription": "Abonnement",
+    "noSubscription": "Kein aktives Abonnement",
+    "renews": "Verlängert sich",
+    "cancelsAt": "Endet am"
   }
 }

+ 8 - 3
messages/de/auth.json

@@ -14,7 +14,8 @@
       "tooManyAttempts": "Zu viele Versuche, bitte versuchen Sie es später erneut.",
       "invalidCredentials": "Ungültige E-Mail-Adresse oder ungültiges Passwort",
       "passkeyFailed": "Passkey-Anmeldung fehlgeschlagen. Versuchen Sie es erneut oder verwenden Sie E-Mail und Passwort."
-    }
+    },
+    "resetPasswordCta": "Hier zurücksetzen"
   },
   "signUp": {
     "title": "Konto erstellen",
@@ -30,8 +31,12 @@
     "errors": {
       "couldNotCreate": "Konto konnte nicht erstellt werden",
       "registrationDisabled": "Die Registrierung ist derzeit deaktiviert. Bitte wenden Sie sich an Ihren Administrator.",
-      "invitationFailed": "Einladung konnte nicht angenommen werden"
-    }
+      "invitationFailed": "Einladung konnte nicht angenommen werden",
+      "termsRequired": "Bitte akzeptieren Sie die Nutzungsbedingungen, um fortzufahren",
+      "emailExists": "Ein Konto mit dieser E-Mail-Adresse existiert bereits."
+    },
+    "passwordHint": "Mindestens 8 Zeichen",
+    "signInInstead": "Stattdessen anmelden"
   },
   "forgotPassword": {
     "title": "Passwort zurücksetzen",

+ 12 - 1
messages/de/service.json

@@ -11,6 +11,9 @@
     "datesExpiredDescription": "Das Rechnungsdatum oder Fälligkeitsdatum ist überschritten. Möchten Sie die Daten vor dem Fortfahren aktualisieren?",
     "datesExpiredUpdate": "Daten aktualisieren und fortfahren",
     "datesExpiredProceed": "Ohne Änderungen fortfahren",
+    "datesExpiredUseToday": "Auf heutiges Datum aktualisieren",
+    "datesExpiredCustomize": "Daten manuell auswählen",
+    "datesExpiredBack": "Zurück",
     "datesExpiredSetToday": "Auf heute setzen",
     "customFieldsInvalid": "Bitte füllen Sie alle erforderlichen benutzerdefinierten Felder aus",
     "customFieldsSaveFailed": "Benutzerdefinierte Felder konnten nicht gespeichert werden"
@@ -84,6 +87,12 @@
     "partNumber": "Teil #",
     "name": "Name",
     "qty": "Menge",
+    "unitCost": "Kosten",
+    "unitCostHint": "Einkaufspreis (intern — wird dem Kunden nicht angezeigt)",
+    "markupPercent": "Aufschlag %",
+    "markupPercentHint": "Aufschlag auf den Einkaufspreis (intern — wird dem Kunden nicht angezeigt)",
+    "applyMarkup": "{percent}% anwenden",
+    "applyMarkupHint": "{percent}% Aufschlag auf den Einkaufspreis in allen Zeilen erneut anwenden",
     "unitPrice": "Stückpreis",
     "total": "Gesamt",
     "namePlaceholder": "Name *",
@@ -96,7 +105,9 @@
     "scanTitle": "Barcode scannen",
     "partFound": "\"{name}\" aus dem Lager hinzugefügt",
     "partNotFound": "Kein Teil für Barcode \"{barcode}\" gefunden",
-    "deleteRow": "Delete row"
+    "deleteRow": "Delete row",
+    "outOfStock": "Nicht auf Lager",
+    "onBackorder": "Im Rückstand ({count})"
   },
   "labor": {
     "title": "Arbeit",

+ 9 - 1
messages/de/settings.json

@@ -390,6 +390,12 @@
     "customFooter": "Benutzerdefinierte Rechnungsfusszeile",
     "footerPlaceholder": "Vielen Dank fur Ihren Auftrag!",
     "footerHint": "Dieser Text erscheint am Ende jeder Rechnung",
+    "partsMarkupTitle": "Teile-Aufschlag",
+    "partsMarkupDescription": "Aufschlag auf den Einkaufspreis beim Hinzufügen von Teilen. Nur intern — der Kunde sieht weder Kosten noch Aufschlag, nur den Endpreis.",
+    "defaultMarkupPercent": "Standard-Aufschlag %",
+    "defaultMarkupPercentHint": "Neue manuell hinzugefügte Teile werden mit diesem Aufschlag vorausgefüllt. Auf 0 lassen, um Preise manuell einzugeben.",
+    "markupAppliesToInventory": "Aufschlag auf Lagerteile anwenden",
+    "markupAppliesToInventoryHint": "Wenn aktiviert, verwenden aus dem Lager gewählte Teile ebenfalls Einkaufspreis + Standard-Aufschlag (überschreibt den Verkaufspreis des Lagers).",
     "saveInvoice": "Rechnungseinstellungen speichern",
     "saved": "Rechnungseinstellungen gespeichert",
     "layoutDescription": "Passen Sie die Reihenfolge und Sichtbarkeit der Abschnitte in Ihren Rechnungen und Angeboten an.",
@@ -1126,7 +1132,9 @@
     "upgradeCalculating": "Anteiligen Betrag berechnen…",
     "upgradeAmountDue": "Jetzt fälliger Betrag",
     "upgradeSuccess": "Erfolgreich auf Enterprise upgegradet!",
-    "upgradeError": "Abonnement konnte nicht upgegradet werden"
+    "upgradeError": "Abonnement konnte nicht upgegradet werden",
+    "demoBadge": "Demo",
+    "demoExpiresIn": "Läuft in {days, plural, =0 {weniger als einem Tag} one {# Tag} other {# Tagen}} ab ({date})"
   },
   "ai": {
     "title": "KI-Assistent",

+ 24 - 0
messages/en/admin.json

@@ -59,6 +59,30 @@
     "deleteUser": "Delete User",
     "emailVerified": "Email verified"
   },
+  "userDetails": {
+    "back": "Back to users",
+    "never": "Never",
+    "lastLogin": "Last login",
+    "organizations": "Organizations",
+    "noOrganizations": "Not a member of any organization",
+    "role": "Role"
+  },
+  "orgDetails": {
+    "back": "Back",
+    "vehicles": "Vehicles",
+    "workOrders": "Work Orders",
+    "customers": "Customers",
+    "quotes": "Quotes",
+    "inspections": "Inspections",
+    "inventoryParts": "Inventory Parts",
+    "members": "Members",
+    "owner": "Owner",
+    "noOwner": "No owner",
+    "subscription": "Subscription",
+    "noSubscription": "No active subscription",
+    "renews": "Renews",
+    "cancelsAt": "Cancels at"
+  },
   "organizations": {
     "searchPlaceholder": "Search by organization name...",
     "name": "Name",

+ 6 - 1
messages/en/auth.json

@@ -4,6 +4,7 @@
     "description": "Sign in to your workshop",
     "passwordPlaceholder": "Enter your password",
     "forgotPassword": "Forgot password?",
+    "resetPasswordCta": "Reset it here",
     "noAccount": "Don't have an account?",
     "createOne": "Create one",
     "termsAgreement": "By using this service you agree to our",
@@ -23,14 +24,18 @@
     "fullName": "Full Name",
     "fullNamePlaceholder": "John Doe",
     "passwordPlaceholder": "Create a strong password",
+    "passwordHint": "Must be at least 8 characters",
     "agreeToTerms": "I agree to the",
     "createAccountJoin": "Create Account & Join Team",
     "createAccount": "Create Account",
     "alreadyHaveAccount": "Already have an account?",
+    "signInInstead": "Sign in instead",
     "errors": {
       "couldNotCreate": "Could not create account",
       "registrationDisabled": "Registration is currently disabled. Please contact your administrator.",
-      "invitationFailed": "Failed to accept invitation"
+      "invitationFailed": "Failed to accept invitation",
+      "termsRequired": "Please accept the terms of service to continue",
+      "emailExists": "An account with this email already exists."
     }
   },
   "forgotPassword": {

+ 12 - 1
messages/en/service.json

@@ -11,6 +11,9 @@
     "datesExpiredDescription": "The invoice date or due date has passed. Would you like to update them before proceeding?",
     "datesExpiredUpdate": "Update dates and proceed",
     "datesExpiredProceed": "Proceed without changes",
+    "datesExpiredUseToday": "Update to today's date",
+    "datesExpiredCustomize": "Choose dates manually",
+    "datesExpiredBack": "Back",
     "datesExpiredSetToday": "Set to today",
     "customFieldsInvalid": "Please fill in all required custom fields",
     "customFieldsSaveFailed": "Failed to save custom fields"
@@ -84,6 +87,12 @@
     "partNumber": "Part #",
     "name": "Name",
     "qty": "Qty",
+    "unitCost": "Cost",
+    "unitCostHint": "Vendor cost (internal — not shown to customer)",
+    "markupPercent": "Markup %",
+    "markupPercentHint": "Markup applied to cost (internal — not shown to customer)",
+    "applyMarkup": "Apply {percent}%",
+    "applyMarkupHint": "Re-apply {percent}% markup to cost on every row",
     "unitPrice": "Unit Price",
     "total": "Total",
     "namePlaceholder": "Name *",
@@ -96,7 +105,9 @@
     "scanTitle": "Scan Barcode",
     "partFound": "Added \"{name}\" from inventory",
     "partNotFound": "No part found for barcode \"{barcode}\"",
-    "deleteRow": "Delete row"
+    "deleteRow": "Delete row",
+    "outOfStock": "Out of stock",
+    "onBackorder": "On backorder ({count})"
   },
   "labor": {
     "title": "Labor & Services",

+ 9 - 1
messages/en/settings.json

@@ -390,6 +390,12 @@
     "customFooter": "Custom Invoice Footer",
     "footerPlaceholder": "Thank you for your business!",
     "footerHint": "This text appears at the bottom of every invoice",
+    "partsMarkupTitle": "Parts Markup",
+    "partsMarkupDescription": "Markup applied to vendor cost when adding parts. Internal only — the customer never sees the cost or markup, only the final price.",
+    "defaultMarkupPercent": "Default Markup %",
+    "defaultMarkupPercentHint": "New manually-added parts pre-fill with this markup. Leave at 0 to enter prices manually.",
+    "markupAppliesToInventory": "Apply markup to inventory parts",
+    "markupAppliesToInventoryHint": "When enabled, parts picked from inventory also use cost + default markup (overrides the inventory sell price).",
     "saveInvoice": "Save Invoice Settings",
     "saved": "Invoice settings saved",
     "layoutDescription": "Customize the order and visibility of sections in your invoices and quotes.",
@@ -1126,7 +1132,9 @@
     "upgradeCalculating": "Calculating prorated amount…",
     "upgradeAmountDue": "Amount due now",
     "upgradeSuccess": "Successfully upgraded to Enterprise!",
-    "upgradeError": "Failed to upgrade subscription"
+    "upgradeError": "Failed to upgrade subscription",
+    "demoBadge": "Demo",
+    "demoExpiresIn": "Expires in {days, plural, =0 {less than a day} one {# day} other {# days}} ({date})"
   },
   "ai": {
     "title": "AI Assistant",

+ 24 - 0
messages/es/admin.json

@@ -119,5 +119,29 @@
   },
   "common": {
     "openMenu": "Open menu"
+  },
+  "userDetails": {
+    "back": "Volver a usuarios",
+    "never": "Nunca",
+    "lastLogin": "Último inicio de sesión",
+    "organizations": "Organizaciones",
+    "noOrganizations": "No es miembro de ninguna organización",
+    "role": "Rol"
+  },
+  "orgDetails": {
+    "back": "Volver",
+    "vehicles": "Vehículos",
+    "workOrders": "Órdenes de trabajo",
+    "customers": "Clientes",
+    "quotes": "Presupuestos",
+    "inspections": "Inspecciones",
+    "inventoryParts": "Piezas de inventario",
+    "members": "Miembros",
+    "owner": "Propietario",
+    "noOwner": "Sin propietario",
+    "subscription": "Suscripción",
+    "noSubscription": "Sin suscripción activa",
+    "renews": "Se renueva",
+    "cancelsAt": "Termina el"
   }
 }

+ 8 - 3
messages/es/auth.json

@@ -14,7 +14,8 @@
       "tooManyAttempts": "Demasiados intentos, vuelva a intentarlo mas tarde.",
       "invalidCredentials": "Correo electronico o contrasena no validos",
       "passkeyFailed": "Error al iniciar sesión con passkey. Inténtelo de nuevo o use correo y contraseña."
-    }
+    },
+    "resetPasswordCta": "Restablécela aquí"
   },
   "signUp": {
     "title": "Crear una cuenta",
@@ -30,8 +31,12 @@
     "errors": {
       "couldNotCreate": "No se pudo crear la cuenta",
       "registrationDisabled": "El registro esta deshabilitado actualmente. Por favor, contacte a su administrador.",
-      "invitationFailed": "No se pudo aceptar la invitacion"
-    }
+      "invitationFailed": "No se pudo aceptar la invitacion",
+      "termsRequired": "Acepta los términos del servicio para continuar",
+      "emailExists": "Ya existe una cuenta con este correo electrónico."
+    },
+    "passwordHint": "Debe tener al menos 8 caracteres",
+    "signInInstead": "Inicia sesión en su lugar"
   },
   "forgotPassword": {
     "title": "Restablecer su contrasena",

+ 12 - 1
messages/es/service.json

@@ -11,6 +11,9 @@
     "datesExpiredDescription": "La fecha de la factura o la fecha de vencimiento ha pasado. ¿Desea actualizarlas antes de continuar?",
     "datesExpiredUpdate": "Actualizar fechas y continuar",
     "datesExpiredProceed": "Continuar sin cambios",
+    "datesExpiredUseToday": "Actualizar a la fecha de hoy",
+    "datesExpiredCustomize": "Elegir fechas manualmente",
+    "datesExpiredBack": "Atrás",
     "datesExpiredSetToday": "Establecer a hoy",
     "customFieldsInvalid": "Por favor complete todos los campos personalizados obligatorios",
     "customFieldsSaveFailed": "Error al guardar los campos personalizados"
@@ -84,6 +87,12 @@
     "partNumber": "N° de Pieza",
     "name": "Nombre",
     "qty": "Cantidad",
+    "unitCost": "Costo",
+    "unitCostHint": "Costo del proveedor (interno — no se muestra al cliente)",
+    "markupPercent": "Margen %",
+    "markupPercentHint": "Margen aplicado al costo (interno — no se muestra al cliente)",
+    "applyMarkup": "Aplicar {percent}%",
+    "applyMarkupHint": "Volver a aplicar {percent}% de margen al costo en cada fila",
     "unitPrice": "Precio Unitario",
     "total": "Total",
     "namePlaceholder": "Nombre *",
@@ -96,7 +105,9 @@
     "scanTitle": "Escanear código de barras",
     "partFound": "\"{name}\" añadido del inventario",
     "partNotFound": "No se encontró pieza para el código de barras \"{barcode}\"",
-    "deleteRow": "Delete row"
+    "deleteRow": "Delete row",
+    "outOfStock": "Agotado",
+    "onBackorder": "En pedido pendiente ({count})"
   },
   "labor": {
     "title": "Mano de Obra",

+ 9 - 1
messages/es/settings.json

@@ -390,6 +390,12 @@
     "customFooter": "Pie de pagina personalizado",
     "footerPlaceholder": "Gracias por su preferencia!",
     "footerHint": "Este texto aparece al final de cada factura",
+    "partsMarkupTitle": "Margen de Repuestos",
+    "partsMarkupDescription": "Margen aplicado al costo del proveedor al agregar repuestos. Solo interno — el cliente nunca ve el costo ni el margen, solo el precio final.",
+    "defaultMarkupPercent": "Margen Predeterminado %",
+    "defaultMarkupPercentHint": "Los repuestos nuevos agregados manualmente se prellenan con este margen. Déjelo en 0 para ingresar precios manualmente.",
+    "markupAppliesToInventory": "Aplicar margen a repuestos de inventario",
+    "markupAppliesToInventoryHint": "Cuando está habilitado, los repuestos seleccionados del inventario también usan costo + margen predeterminado (anula el precio de venta del inventario).",
     "saveInvoice": "Guardar configuracion de factura",
     "saved": "Configuracion de factura guardada",
     "layoutDescription": "Personaliza el orden y la visibilidad de las secciones en tus facturas y presupuestos.",
@@ -1126,7 +1132,9 @@
     "upgradeCalculating": "Calculando el monto prorrateado…",
     "upgradeAmountDue": "Monto a pagar ahora",
     "upgradeSuccess": "¡Mejorado a Enterprise con éxito!",
-    "upgradeError": "Error al mejorar la suscripción"
+    "upgradeError": "Error al mejorar la suscripción",
+    "demoBadge": "Demo",
+    "demoExpiresIn": "Caduca en {days, plural, =0 {menos de un día} one {# día} other {# días}} ({date})"
   },
   "ai": {
     "title": "Asistente de IA",

+ 24 - 0
messages/fr/admin.json

@@ -119,5 +119,29 @@
   },
   "common": {
     "openMenu": "Open menu"
+  },
+  "userDetails": {
+    "back": "Retour aux utilisateurs",
+    "never": "Jamais",
+    "lastLogin": "Dernière connexion",
+    "organizations": "Organisations",
+    "noOrganizations": "Membre d'aucune organisation",
+    "role": "Rôle"
+  },
+  "orgDetails": {
+    "back": "Retour",
+    "vehicles": "Véhicules",
+    "workOrders": "Ordres de travail",
+    "customers": "Clients",
+    "quotes": "Devis",
+    "inspections": "Inspections",
+    "inventoryParts": "Pièces en stock",
+    "members": "Membres",
+    "owner": "Propriétaire",
+    "noOwner": "Aucun propriétaire",
+    "subscription": "Abonnement",
+    "noSubscription": "Aucun abonnement actif",
+    "renews": "Renouvellement",
+    "cancelsAt": "Se termine le"
   }
 }

+ 8 - 3
messages/fr/auth.json

@@ -14,7 +14,8 @@
       "tooManyAttempts": "Trop de tentatives, veuillez réessayer plus tard.",
       "invalidCredentials": "Adresse e-mail ou mot de passe invalide",
       "passkeyFailed": "Échec de la connexion par passkey. Réessayez ou utilisez l'email et le mot de passe."
-    }
+    },
+    "resetPasswordCta": "Réinitialisez-le ici"
   },
   "signUp": {
     "title": "Créer un compte",
@@ -30,8 +31,12 @@
     "errors": {
       "couldNotCreate": "Impossible de créer le compte",
       "registrationDisabled": "L'inscription est actuellement désactivée. Veuillez contacter votre administrateur.",
-      "invitationFailed": "L'acceptation de l'invitation a échoué"
-    }
+      "invitationFailed": "L'acceptation de l'invitation a échoué",
+      "termsRequired": "Veuillez accepter les conditions d'utilisation pour continuer",
+      "emailExists": "Un compte avec cet e-mail existe déjà."
+    },
+    "passwordHint": "Doit comporter au moins 8 caractères",
+    "signInInstead": "Connectez-vous plutôt"
   },
   "forgotPassword": {
     "title": "Réinitialiser votre mot de passe",

+ 8 - 8
messages/fr/pdf.json

@@ -22,8 +22,8 @@
     "total": "Total",
     "labor": "Main-d'œuvre",
     "hours": "Heures",
-    "hrs": "hrs",
-    "unit": "unit",
+    "hrs": "h",
+    "unit": "unité",
     "rate": "Taux",
     "ratePerHour": "{rate}/h",
     "subtotal": "Sous-total",
@@ -36,7 +36,7 @@
     "paidInFull": "ENTIÈREMENT PAYÉ",
     "amountDue": "Montant dû",
     "notes": "Notes",
-    "bankAccount": "Til Konto / Compte bancaire",
+    "bankAccount": "Compte bancaire",
     "paymentInformation": "Informations de paiement",
     "orgNumberLabel": "N° d'org.",
     "paymentTermsLabel": "Conditions de paiement",
@@ -64,7 +64,7 @@
     "professionalWorkshop": "Gestion professionnelle d'atelier",
     "tel": "Tél. : {phone}",
     "org": "Org. : {org}",
-    "qtyOrHours": "Unit / Hours"
+    "qtyOrHours": "Unité / Heures"
   },
   "quote": {
     "title": "DEVIS",
@@ -82,8 +82,8 @@
     "total": "Total",
     "labor": "Main-d'œuvre",
     "hours": "Heures",
-    "hrs": "hrs",
-    "unit": "unit",
+    "hrs": "h",
+    "unit": "unité",
     "rate": "Taux",
     "ratePerHour": "{rate}/h",
     "subtotal": "Sous-total",
@@ -100,7 +100,7 @@
     "validityFooter30": "Ce devis est valable 30 jours",
     "viewPortal": "Consultez votre portail : {url}",
     "tel": "Tél. : {phone}",
-    "qtyOrHours": "Unit / Hours"
+    "qtyOrHours": "Unité / Heures"
   },
   "inspection": {
     "title": "INSPECTION DU VÉHICULE",
@@ -127,7 +127,7 @@
   },
   "serviceHistory": {
     "serviceHistoryTitle": "HISTORIQUE D'ENTRETIEN",
-    "totalServices": "Total Services",
+    "totalServices": "Total des services",
     "totalPartsUsed": "Pièces Utilisées",
     "totalLaborItems": "Éléments Main-d'œuvre",
     "grandTotal": "Total Général",

+ 12 - 1
messages/fr/service.json

@@ -11,6 +11,9 @@
     "datesExpiredDescription": "La date de facturation ou la date d'échéance est dépassée. Souhaitez-vous les mettre à jour avant de continuer ?",
     "datesExpiredUpdate": "Mettre à jour les dates et continuer",
     "datesExpiredProceed": "Continuer sans modifications",
+    "datesExpiredUseToday": "Mettre à jour à la date du jour",
+    "datesExpiredCustomize": "Choisir les dates manuellement",
+    "datesExpiredBack": "Retour",
     "datesExpiredSetToday": "Définir à aujourd'hui",
     "customFieldsInvalid": "Veuillez remplir tous les champs personnalisés obligatoires",
     "customFieldsSaveFailed": "Échec de l'enregistrement des champs personnalisés"
@@ -84,6 +87,12 @@
     "partNumber": "Nº de Pièce",
     "name": "Nom",
     "qty": "Qté",
+    "unitCost": "Coût",
+    "unitCostHint": "Coût fournisseur (interne — non visible par le client)",
+    "markupPercent": "Marge %",
+    "markupPercentHint": "Marge appliquée au coût (interne — non visible par le client)",
+    "applyMarkup": "Appliquer {percent}%",
+    "applyMarkupHint": "Réappliquer la marge de {percent}% au coût sur chaque ligne",
     "unitPrice": "Prix Unitaire",
     "total": "Total",
     "namePlaceholder": "Nom *",
@@ -96,7 +105,9 @@
     "scanTitle": "Scanner le code-barres",
     "partFound": "\"{name}\" ajouté depuis l'inventaire",
     "partNotFound": "Aucune pièce trouvée pour le code-barres \"{barcode}\"",
-    "deleteRow": "Delete row"
+    "deleteRow": "Delete row",
+    "outOfStock": "En rupture de stock",
+    "onBackorder": "En réapprovisionnement ({count})"
   },
   "labor": {
     "title": "Main-d'œuvre",

+ 9 - 1
messages/fr/settings.json

@@ -390,6 +390,12 @@
     "customFooter": "Pied de page personnalise",
     "footerPlaceholder": "Merci pour votre confiance !",
     "footerHint": "Ce texte apparait en bas de chaque facture",
+    "partsMarkupTitle": "Marge sur Pièces",
+    "partsMarkupDescription": "Marge appliquée au coût fournisseur lors de l'ajout de pièces. Interne uniquement — le client ne voit jamais le coût ou la marge, seulement le prix final.",
+    "defaultMarkupPercent": "Marge par Défaut %",
+    "defaultMarkupPercentHint": "Les nouvelles pièces ajoutées manuellement sont préremplies avec cette marge. Laisser à 0 pour saisir les prix manuellement.",
+    "markupAppliesToInventory": "Appliquer la marge aux pièces du stock",
+    "markupAppliesToInventoryHint": "Lorsque activé, les pièces sélectionnées dans le stock utilisent aussi coût + marge par défaut (remplace le prix de vente du stock).",
     "saveInvoice": "Enregistrer les parametres de facture",
     "saved": "Parametres de facture enregistres",
     "layoutDescription": "Personnalisez l'ordre et la visibilité des sections dans vos factures et devis.",
@@ -1126,7 +1132,9 @@
     "upgradeCalculating": "Calcul du montant au prorata…",
     "upgradeAmountDue": "Montant dû maintenant",
     "upgradeSuccess": "Mis à niveau vers Enterprise avec succès !",
-    "upgradeError": "Échec de la mise à niveau de l'abonnement"
+    "upgradeError": "Échec de la mise à niveau de l'abonnement",
+    "demoBadge": "Démo",
+    "demoExpiresIn": "Expire dans {days, plural, =0 {moins d'un jour} one {# jour} other {# jours}} ({date})"
   },
   "ai": {
     "title": "Assistant IA",

+ 24 - 0
messages/it/admin.json

@@ -119,5 +119,29 @@
   },
   "common": {
     "openMenu": "Open menu"
+  },
+  "userDetails": {
+    "back": "Torna agli utenti",
+    "never": "Mai",
+    "lastLogin": "Ultimo accesso",
+    "organizations": "Organizzazioni",
+    "noOrganizations": "Non è membro di alcuna organizzazione",
+    "role": "Ruolo"
+  },
+  "orgDetails": {
+    "back": "Indietro",
+    "vehicles": "Veicoli",
+    "workOrders": "Ordini di lavoro",
+    "customers": "Clienti",
+    "quotes": "Preventivi",
+    "inspections": "Ispezioni",
+    "inventoryParts": "Parti in magazzino",
+    "members": "Membri",
+    "owner": "Proprietario",
+    "noOwner": "Nessun proprietario",
+    "subscription": "Abbonamento",
+    "noSubscription": "Nessun abbonamento attivo",
+    "renews": "Si rinnova",
+    "cancelsAt": "Termina il"
   }
 }

+ 8 - 3
messages/it/auth.json

@@ -14,7 +14,8 @@
       "tooManyAttempts": "Troppi tentativi, riprovi più tardi.",
       "invalidCredentials": "Email o password non validi",
       "passkeyFailed": "Accesso con passkey fallito. Riprova o usa email e password."
-    }
+    },
+    "resetPasswordCta": "Reimpostala qui"
   },
   "signUp": {
     "title": "Crei un account",
@@ -30,8 +31,12 @@
     "errors": {
       "couldNotCreate": "Impossibile creare l'account",
       "registrationDisabled": "La registrazione è attualmente disabilitata. La preghiamo di contattare l'amministratore.",
-      "invitationFailed": "Impossibile accettare l'invito"
-    }
+      "invitationFailed": "Impossibile accettare l'invito",
+      "termsRequired": "Accetta i termini di servizio per continuare",
+      "emailExists": "Esiste già un account con questa email."
+    },
+    "passwordHint": "Deve contenere almeno 8 caratteri",
+    "signInInstead": "Accedi invece"
   },
   "forgotPassword": {
     "title": "Reimposti la Sua password",

+ 12 - 1
messages/it/service.json

@@ -11,6 +11,9 @@
     "datesExpiredDescription": "La data della fattura o la data di scadenza è passata. Vuoi aggiornare le date prima di procedere?",
     "datesExpiredUpdate": "Aggiorna le date e procedi",
     "datesExpiredProceed": "Procedi senza modifiche",
+    "datesExpiredUseToday": "Aggiorna alla data di oggi",
+    "datesExpiredCustomize": "Scegli le date manualmente",
+    "datesExpiredBack": "Indietro",
     "datesExpiredSetToday": "Imposta a oggi",
     "customFieldsInvalid": "Compila tutti i campi personalizzati obbligatori",
     "customFieldsSaveFailed": "Impossibile salvare i campi personalizzati"
@@ -84,6 +87,12 @@
     "partNumber": "Numero Pezzo",
     "name": "Nome",
     "qty": "Qty",
+    "unitCost": "Costo",
+    "unitCostHint": "Costo fornitore (interno — non mostrato al cliente)",
+    "markupPercent": "Ricarico %",
+    "markupPercentHint": "Ricarico applicato al costo (interno — non mostrato al cliente)",
+    "applyMarkup": "Applica {percent}%",
+    "applyMarkupHint": "Riapplicare il ricarico del {percent}% al costo su ogni riga",
     "unitPrice": "Prezzo Unitario",
     "total": "Totale",
     "namePlaceholder": "Nome *",
@@ -96,7 +105,9 @@
     "scanTitle": "Scansiona codice a barre",
     "partFound": "\"{name}\" aggiunto dall'inventario",
     "partNotFound": "Nessun pezzo trovato per il codice a barre \"{barcode}\"",
-    "deleteRow": "Delete row"
+    "deleteRow": "Delete row",
+    "outOfStock": "Esaurito",
+    "onBackorder": "In arretrato ({count})"
   },
   "labor": {
     "title": "Manodopera",

+ 9 - 1
messages/it/settings.json

@@ -390,6 +390,12 @@
     "customFooter": "Pie di pagina fattura personalizzato",
     "footerPlaceholder": "Grazie per la vostra fiducia!",
     "footerHint": "Questo testo appare in fondo a ogni fattura",
+    "partsMarkupTitle": "Ricarico Ricambi",
+    "partsMarkupDescription": "Ricarico applicato al costo del fornitore quando si aggiungono ricambi. Solo interno — il cliente non vede mai il costo o il ricarico, solo il prezzo finale.",
+    "defaultMarkupPercent": "Ricarico Predefinito %",
+    "defaultMarkupPercentHint": "I nuovi ricambi aggiunti manualmente vengono precompilati con questo ricarico. Lasciare a 0 per inserire i prezzi manualmente.",
+    "markupAppliesToInventory": "Applica ricarico ai ricambi a magazzino",
+    "markupAppliesToInventoryHint": "Quando attivato, i ricambi selezionati dal magazzino utilizzano anche costo + ricarico predefinito (sovrascrive il prezzo di vendita del magazzino).",
     "saveInvoice": "Salva impostazioni fattura",
     "saved": "Impostazioni fattura salvate",
     "layoutDescription": "Personalizza l'ordine e la visibilità delle sezioni nelle tue fatture e preventivi.",
@@ -1126,7 +1132,9 @@
     "upgradeCalculating": "Calcolo dell'importo proporzionale…",
     "upgradeAmountDue": "Importo dovuto ora",
     "upgradeSuccess": "Aggiornato a Enterprise con successo!",
-    "upgradeError": "Aggiornamento dell'abbonamento non riuscito"
+    "upgradeError": "Aggiornamento dell'abbonamento non riuscito",
+    "demoBadge": "Demo",
+    "demoExpiresIn": "Scade tra {days, plural, =0 {meno di un giorno} one {# giorno} other {# giorni}} ({date})"
   },
   "ai": {
     "title": "Assistente IA",

+ 24 - 0
messages/lt/admin.json

@@ -119,5 +119,29 @@
   },
   "common": {
     "openMenu": "Open menu"
+  },
+  "userDetails": {
+    "back": "Atgal į vartotojus",
+    "never": "Niekada",
+    "lastLogin": "Paskutinis prisijungimas",
+    "organizations": "Organizacijos",
+    "noOrganizations": "Nepriklauso jokiai organizacijai",
+    "role": "Rolė"
+  },
+  "orgDetails": {
+    "back": "Atgal",
+    "vehicles": "Transporto priemonės",
+    "workOrders": "Darbo užsakymai",
+    "customers": "Klientai",
+    "quotes": "Pasiūlymai",
+    "inspections": "Patikrinimai",
+    "inventoryParts": "Atsargų dalys",
+    "members": "Nariai",
+    "owner": "Savininkas",
+    "noOwner": "Nėra savininko",
+    "subscription": "Prenumerata",
+    "noSubscription": "Nėra aktyvios prenumeratos",
+    "renews": "Atsinaujina",
+    "cancelsAt": "Baigiasi"
   }
 }

+ 8 - 3
messages/lt/auth.json

@@ -14,7 +14,8 @@
       "tooManyAttempts": "Per daug bandymų, pabandykite vėliau.",
       "invalidCredentials": "Neteisingas el. paštas arba slaptažodis",
       "passkeyFailed": "Prisijungimas su prieigos raktu nepavyko. Bandykite dar kartą arba naudokite el. paštą ir slaptažodį."
-    }
+    },
+    "resetPasswordCta": "Iš naujo nustatykite čia"
   },
   "signUp": {
     "title": "Sukurti paskyrą",
@@ -30,8 +31,12 @@
     "errors": {
       "couldNotCreate": "Nepavyko sukurti paskyros",
       "registrationDisabled": "Registracija šiuo metu išjungta. Kreipkitės į administratorių.",
-      "invitationFailed": "Nepavyko priimti kvietimo"
-    }
+      "invitationFailed": "Nepavyko priimti kvietimo",
+      "termsRequired": "Norėdami tęsti, sutikite su paslaugų teikimo sąlygomis",
+      "emailExists": "Paskyra su šiuo el. paštu jau egzistuoja."
+    },
+    "passwordHint": "Turi būti bent 8 simboliai",
+    "signInInstead": "Vietoj to prisijunkite"
   },
   "forgotPassword": {
     "title": "Atstatyti slaptažodį",

+ 12 - 1
messages/lt/service.json

@@ -11,6 +11,9 @@
     "datesExpiredDescription": "Sąskaitos data arba mokėjimo terminas pasibaigė. Ar norite jas atnaujinti prieš tęsiant?",
     "datesExpiredUpdate": "Atnaujinti datas ir tęsti",
     "datesExpiredProceed": "Tęsti be pakeitimų",
+    "datesExpiredUseToday": "Atnaujinti į šiandienos datą",
+    "datesExpiredCustomize": "Pasirinkti datas rankiniu būdu",
+    "datesExpiredBack": "Atgal",
     "datesExpiredSetToday": "Nustatyti šiandienos datą",
     "customFieldsInvalid": "Užpildykite visus privalomus pasirinktinius laukus",
     "customFieldsSaveFailed": "Nepavyko išsaugoti pasirinktinių laukų"
@@ -84,6 +87,12 @@
     "partNumber": "Dalies Nr.",
     "name": "Pavadinimas",
     "qty": "Kiekis",
+    "unitCost": "Savikaina",
+    "unitCostHint": "Tiekėjo kaina (vidinė — nematoma klientui)",
+    "markupPercent": "Antkainis %",
+    "markupPercentHint": "Antkainis pridėtas prie savikainos (vidinis — nematomas klientui)",
+    "applyMarkup": "Taikyti {percent}%",
+    "applyMarkupHint": "Iš naujo taikyti {percent}% antkainį savikainai kiekvienoje eilutėje",
     "unitPrice": "Vnt. kaina",
     "total": "Suma",
     "namePlaceholder": "Pavadinimas *",
@@ -96,7 +105,9 @@
     "scanTitle": "Nuskaityti brūkšninį kodą",
     "partFound": "Pridėta \"{name}\" iš inventoriaus",
     "partNotFound": "Dalis brūkšniniam kodui \"{barcode}\" nerasta",
-    "deleteRow": "Delete row"
+    "deleteRow": "Delete row",
+    "outOfStock": "Nėra sandėlyje",
+    "onBackorder": "Užsakoma ({count})"
   },
   "labor": {
     "title": "Darbai ir paslaugos",

+ 9 - 1
messages/lt/settings.json

@@ -390,6 +390,12 @@
     "customFooter": "Pasirinktinė sąskaitos poraštė",
     "footerPlaceholder": "Dėkojame už jūsų pasitikėjimą!",
     "footerHint": "Šis tekstas rodomas kiekvienos sąskaitos apačioje",
+    "partsMarkupTitle": "Dalių Antkainis",
+    "partsMarkupDescription": "Antkainis taikomas tiekėjo savikainai pridedant dalis. Tik vidinis — klientas niekada nemato savikainos ar antkainio, tik galutinę kainą.",
+    "defaultMarkupPercent": "Numatytasis Antkainis %",
+    "defaultMarkupPercentHint": "Naujos rankiniu būdu pridėtos dalys užpildomos šiuo antkainiu. Palikite 0, kad įvestumėte kainas rankiniu būdu.",
+    "markupAppliesToInventory": "Taikyti antkainį sandėlio dalims",
+    "markupAppliesToInventoryHint": "Įjungus, dalys parinktos iš sandėlio taip pat naudoja savikainą + numatytąjį antkainį (perrašo sandėlio pardavimo kainą).",
     "saveInvoice": "Išsaugoti sąskaitos nustatymus",
     "saved": "Sąskaitos nustatymai išsaugoti",
     "layoutDescription": "Pritaikykite sekcijų tvarką ir matomumą sąskaitose ir pasiūlymuose.",
@@ -1126,7 +1132,9 @@
     "upgradeCalculating": "Skaičiuojama proporcinga suma…",
     "upgradeAmountDue": "Mokėtina suma dabar",
     "upgradeSuccess": "Sėkmingai atnaujinta į Enterprise!",
-    "upgradeError": "Nepavyko atnaujinti prenumeratos"
+    "upgradeError": "Nepavyko atnaujinti prenumeratos",
+    "demoBadge": "Demo",
+    "demoExpiresIn": "Baigsis po {days, plural, =0 {mažiau nei dienos} one {# dienos} other {# dienų}} ({date})"
   },
   "ai": {
     "title": "DI asistentas",

+ 24 - 0
messages/nb/admin.json

@@ -119,5 +119,29 @@
   },
   "common": {
     "openMenu": "Open menu"
+  },
+  "userDetails": {
+    "back": "Tilbake til brukere",
+    "never": "Aldri",
+    "lastLogin": "Sist innlogget",
+    "organizations": "Organisasjoner",
+    "noOrganizations": "Ikke medlem av noen organisasjon",
+    "role": "Rolle"
+  },
+  "orgDetails": {
+    "back": "Tilbake",
+    "vehicles": "Kjøretøy",
+    "workOrders": "Arbeidsordrer",
+    "customers": "Kunder",
+    "quotes": "Tilbud",
+    "inspections": "Inspeksjoner",
+    "inventoryParts": "Lagerdeler",
+    "members": "Medlemmer",
+    "owner": "Eier",
+    "noOwner": "Ingen eier",
+    "subscription": "Abonnement",
+    "noSubscription": "Ingen aktivt abonnement",
+    "renews": "Fornyes",
+    "cancelsAt": "Avsluttes"
   }
 }

+ 8 - 3
messages/nb/auth.json

@@ -14,7 +14,8 @@
       "tooManyAttempts": "For mange forsøk, prøv igjen senere.",
       "invalidCredentials": "Ugyldig e-post eller passord",
       "passkeyFailed": "Passkey-innlogging mislyktes. Prøv igjen eller bruk e-post og passord."
-    }
+    },
+    "resetPasswordCta": "Tilbakestill det her"
   },
   "signUp": {
     "title": "Opprett en konto",
@@ -30,8 +31,12 @@
     "errors": {
       "couldNotCreate": "Kunne ikke opprette konto",
       "registrationDisabled": "Registrering er for øyeblikket deaktivert. Vennligst kontakt administratoren din.",
-      "invitationFailed": "Kunne ikke godta invitasjonen"
-    }
+      "invitationFailed": "Kunne ikke godta invitasjonen",
+      "termsRequired": "Godta vilkårene for tjenesten for å fortsette",
+      "emailExists": "En konto med denne e-postadressen finnes allerede."
+    },
+    "passwordHint": "Må være minst 8 tegn",
+    "signInInstead": "Logg inn i stedet"
   },
   "forgotPassword": {
     "title": "Tilbakestill passordet ditt",

+ 12 - 1
messages/nb/service.json

@@ -11,6 +11,9 @@
     "datesExpiredDescription": "Fakturadatoen eller forfallsdatoen har passert. Vil du oppdatere dem før du fortsetter?",
     "datesExpiredUpdate": "Oppdater datoer og fortsett",
     "datesExpiredProceed": "Fortsett uten endringer",
+    "datesExpiredUseToday": "Oppdater til dagens dato",
+    "datesExpiredCustomize": "Velg datoer manuelt",
+    "datesExpiredBack": "Tilbake",
     "datesExpiredSetToday": "Sett til i dag",
     "customFieldsInvalid": "Vennligst fyll ut alle obligatoriske egendefinerte felt",
     "customFieldsSaveFailed": "Kunne ikke lagre egendefinerte felt"
@@ -84,6 +87,12 @@
     "partNumber": "Del #",
     "name": "Navn",
     "qty": "Antall",
+    "unitCost": "Kostnad",
+    "unitCostHint": "Innkjøpspris (intern — vises ikke til kunde)",
+    "markupPercent": "Påslag %",
+    "markupPercentHint": "Påslag på kostnad (intern — vises ikke til kunde)",
+    "applyMarkup": "Bruk {percent}%",
+    "applyMarkupHint": "Bruk {percent}% påslag på kostnad på alle rader på nytt",
     "unitPrice": "Enhetspris",
     "total": "Total",
     "namePlaceholder": "Navn *",
@@ -96,7 +105,9 @@
     "scanTitle": "Skann strekkode",
     "partFound": "\"{name}\" lagt til fra lageret",
     "partNotFound": "Ingen del funnet for strekkode \"{barcode}\"",
-    "deleteRow": "Delete row"
+    "deleteRow": "Delete row",
+    "outOfStock": "Utsolgt",
+    "onBackorder": "På restordre ({count})"
   },
   "labor": {
     "title": "Arbeid",

+ 9 - 1
messages/nb/settings.json

@@ -390,6 +390,12 @@
     "customFooter": "Egendefinert fakturabunnstykke",
     "footerPlaceholder": "Takk for handelen!",
     "footerHint": "Denne teksten vises nederst pa hver faktura",
+    "partsMarkupTitle": "Delepåslag",
+    "partsMarkupDescription": "Påslag på innkjøpspris når deler legges til. Kun internt — kunden ser aldri kostnad eller påslag, bare sluttpris.",
+    "defaultMarkupPercent": "Standard påslag %",
+    "defaultMarkupPercentHint": "Nye manuelt lagt til deler fylles ut med dette påslaget. La stå på 0 for å skrive priser manuelt.",
+    "markupAppliesToInventory": "Bruk påslag på lagerdeler",
+    "markupAppliesToInventoryHint": "Når aktivert, bruker deler valgt fra lager også kostnad + standardpåslag (overstyrer lagerets salgspris).",
     "saveInvoice": "Lagre fakturainnstillinger",
     "saved": "Fakturainnstillinger lagret",
     "layoutDescription": "Tilpass rekkefølgen og synligheten av seksjoner i fakturaer og tilbud.",
@@ -1126,7 +1132,9 @@
     "upgradeCalculating": "Beregner forholdsmessig beløp…",
     "upgradeAmountDue": "Beløp å betale nå",
     "upgradeSuccess": "Vellykket oppgradert til Enterprise!",
-    "upgradeError": "Kunne ikke oppgradere abonnementet"
+    "upgradeError": "Kunne ikke oppgradere abonnementet",
+    "demoBadge": "Demo",
+    "demoExpiresIn": "Utløper om {days, plural, =0 {mindre enn én dag} one {# dag} other {# dager}} ({date})"
   },
   "ai": {
     "title": "AI-assistent",

+ 24 - 0
messages/nl/admin.json

@@ -119,5 +119,29 @@
   },
   "common": {
     "openMenu": "Open menu"
+  },
+  "userDetails": {
+    "back": "Terug naar gebruikers",
+    "never": "Nooit",
+    "lastLogin": "Laatste aanmelding",
+    "organizations": "Organisaties",
+    "noOrganizations": "Geen lid van een organisatie",
+    "role": "Rol"
+  },
+  "orgDetails": {
+    "back": "Terug",
+    "vehicles": "Voertuigen",
+    "workOrders": "Werkorders",
+    "customers": "Klanten",
+    "quotes": "Offertes",
+    "inspections": "Inspecties",
+    "inventoryParts": "Voorraadonderdelen",
+    "members": "Leden",
+    "owner": "Eigenaar",
+    "noOwner": "Geen eigenaar",
+    "subscription": "Abonnement",
+    "noSubscription": "Geen actief abonnement",
+    "renews": "Verlengt",
+    "cancelsAt": "Eindigt op"
   }
 }

+ 8 - 3
messages/nl/auth.json

@@ -14,7 +14,8 @@
       "tooManyAttempts": "Te veel pogingen, probeer het later opnieuw.",
       "invalidCredentials": "Ongeldig e-mailadres of wachtwoord",
       "passkeyFailed": "Passkey-aanmelding mislukt. Probeer het opnieuw of gebruik e-mail en wachtwoord."
-    }
+    },
+    "resetPasswordCta": "Stel het hier opnieuw in"
   },
   "signUp": {
     "title": "Account aanmaken",
@@ -30,8 +31,12 @@
     "errors": {
       "couldNotCreate": "Kon account niet aanmaken",
       "registrationDisabled": "Registratie is momenteel uitgeschakeld. Neem contact op met uw beheerder.",
-      "invitationFailed": "Uitnodiging accepteren is mislukt"
-    }
+      "invitationFailed": "Uitnodiging accepteren is mislukt",
+      "termsRequired": "Accepteer de servicevoorwaarden om door te gaan",
+      "emailExists": "Er bestaat al een account met dit e-mailadres."
+    },
+    "passwordHint": "Moet minstens 8 tekens bevatten",
+    "signInInstead": "Meld in plaats daarvan aan"
   },
   "forgotPassword": {
     "title": "Wachtwoord herstellen",

+ 12 - 1
messages/nl/service.json

@@ -11,6 +11,9 @@
     "datesExpiredDescription": "De factuurdatum of vervaldatum is verstreken. Wilt u de datums bijwerken voordat u verdergaat?",
     "datesExpiredUpdate": "Datums bijwerken en doorgaan",
     "datesExpiredProceed": "Doorgaan zonder wijzigingen",
+    "datesExpiredUseToday": "Bijwerken naar vandaag",
+    "datesExpiredCustomize": "Datums handmatig kiezen",
+    "datesExpiredBack": "Terug",
     "datesExpiredSetToday": "Instellen op vandaag",
     "customFieldsInvalid": "Vul alle verplichte aangepaste velden in",
     "customFieldsSaveFailed": "Kan aangepaste velden niet opslaan"
@@ -84,6 +87,12 @@
     "partNumber": "Onderdeel #",
     "name": "Naam",
     "qty": "Hoeveelheid",
+    "unitCost": "Inkoop",
+    "unitCostHint": "Inkoopprijs (intern — niet zichtbaar voor klant)",
+    "markupPercent": "Marge %",
+    "markupPercentHint": "Marge toegepast op inkoopprijs (intern — niet zichtbaar voor klant)",
+    "applyMarkup": "{percent}% toepassen",
+    "applyMarkupHint": "{percent}% marge opnieuw toepassen op inkoop op elke rij",
     "unitPrice": "Eenheidsprijs",
     "total": "Totaal",
     "namePlaceholder": "Naam *",
@@ -96,7 +105,9 @@
     "scanTitle": "Barcode scannen",
     "partFound": "\"{name}\" toegevoegd uit voorraad",
     "partNotFound": "Geen onderdeel gevonden voor barcode \"{barcode}\"",
-    "deleteRow": "Delete row"
+    "deleteRow": "Delete row",
+    "outOfStock": "Niet op voorraad",
+    "onBackorder": "In nabestelling ({count})"
   },
   "labor": {
     "title": "Arbeidskosten",

+ 9 - 1
messages/nl/settings.json

@@ -390,6 +390,12 @@
     "customFooter": "Aangepaste factuurvoettekst",
     "footerPlaceholder": "Bedankt voor uw vertrouwen!",
     "footerHint": "Deze tekst verschijnt onderaan elke factuur",
+    "partsMarkupTitle": "Onderdelenmarge",
+    "partsMarkupDescription": "Marge toegepast op inkoopprijs bij het toevoegen van onderdelen. Alleen intern — de klant ziet nooit de inkoopprijs of de marge, alleen de eindprijs.",
+    "defaultMarkupPercent": "Standaardmarge %",
+    "defaultMarkupPercentHint": "Nieuwe handmatig toegevoegde onderdelen worden voorgevuld met deze marge. Laat op 0 staan om prijzen handmatig in te voeren.",
+    "markupAppliesToInventory": "Marge toepassen op voorraadonderdelen",
+    "markupAppliesToInventoryHint": "Indien ingeschakeld gebruiken onderdelen uit de voorraad ook inkoop + standaardmarge (overschrijft de verkoopprijs van de voorraad).",
     "saveInvoice": "Factuurinstellingen opslaan",
     "saved": "Factuurinstellingen opgeslagen",
     "layoutDescription": "Pas de volgorde en zichtbaarheid van secties in uw facturen en offertes aan.",
@@ -1126,7 +1132,9 @@
     "upgradeCalculating": "Evenredig bedrag berekenen…",
     "upgradeAmountDue": "Nu te betalen bedrag",
     "upgradeSuccess": "Succesvol geüpgraded naar Enterprise!",
-    "upgradeError": "Upgrade van abonnement mislukt"
+    "upgradeError": "Upgrade van abonnement mislukt",
+    "demoBadge": "Demo",
+    "demoExpiresIn": "Verloopt over {days, plural, =0 {minder dan een dag} one {# dag} other {# dagen}} ({date})"
   },
   "ai": {
     "title": "AI-assistent",

+ 24 - 0
messages/pl/admin.json

@@ -119,5 +119,29 @@
   },
   "common": {
     "openMenu": "Open menu"
+  },
+  "userDetails": {
+    "back": "Powrót do użytkowników",
+    "never": "Nigdy",
+    "lastLogin": "Ostatnie logowanie",
+    "organizations": "Organizacje",
+    "noOrganizations": "Nie jest członkiem żadnej organizacji",
+    "role": "Rola"
+  },
+  "orgDetails": {
+    "back": "Powrót",
+    "vehicles": "Pojazdy",
+    "workOrders": "Zlecenia pracy",
+    "customers": "Klienci",
+    "quotes": "Wyceny",
+    "inspections": "Inspekcje",
+    "inventoryParts": "Części magazynowe",
+    "members": "Członkowie",
+    "owner": "Właściciel",
+    "noOwner": "Brak właściciela",
+    "subscription": "Subskrypcja",
+    "noSubscription": "Brak aktywnej subskrypcji",
+    "renews": "Odnawia się",
+    "cancelsAt": "Kończy się"
   }
 }

+ 8 - 3
messages/pl/auth.json

@@ -14,7 +14,8 @@
       "tooManyAttempts": "Zbyt wiele prób, spróbuj ponownie później.",
       "invalidCredentials": "Nieprawidłowy e-mail lub hasło",
       "passkeyFailed": "Logowanie za pomocą passkey nie powiodło się. Spróbuj ponownie lub użyj adresu e-mail i hasła."
-    }
+    },
+    "resetPasswordCta": "Zresetuj je tutaj"
   },
   "signUp": {
     "title": "Utwórz konto",
@@ -30,8 +31,12 @@
     "errors": {
       "couldNotCreate": "Nie udało się utworzyć konta",
       "registrationDisabled": "Rejestracja jest obecnie wyłączona. Skontaktuj się z administratorem.",
-      "invitationFailed": "Nie udało się przyjąć zaproszenia"
-    }
+      "invitationFailed": "Nie udało się przyjąć zaproszenia",
+      "termsRequired": "Zaakceptuj warunki świadczenia usług, aby kontynuować",
+      "emailExists": "Konto z tym adresem e-mail już istnieje."
+    },
+    "passwordHint": "Musi mieć co najmniej 8 znaków",
+    "signInInstead": "Zaloguj się zamiast tego"
   },
   "forgotPassword": {
     "title": "Zresetuj hasło",

+ 12 - 1
messages/pl/service.json

@@ -11,6 +11,9 @@
     "datesExpiredDescription": "Data faktury lub termin płatności już minął. Czy chcesz zaktualizować daty przed kontynuowaniem?",
     "datesExpiredUpdate": "Zaktualizuj daty i kontynuuj",
     "datesExpiredProceed": "Kontynuuj bez zmian",
+    "datesExpiredUseToday": "Zaktualizuj do dzisiejszej daty",
+    "datesExpiredCustomize": "Wybierz daty ręcznie",
+    "datesExpiredBack": "Wstecz",
     "datesExpiredSetToday": "Ustaw na dziś",
     "customFieldsInvalid": "Proszę wypełnić wszystkie wymagane pola niestandardowe",
     "customFieldsSaveFailed": "Nie udało się zapisać pól niestandardowych"
@@ -84,6 +87,12 @@
     "partNumber": "Nr Części",
     "name": "Nazwa",
     "qty": "Ilość",
+    "unitCost": "Koszt",
+    "unitCostHint": "Cena zakupu (wewnętrzne — nie pokazywane klientowi)",
+    "markupPercent": "Marża %",
+    "markupPercentHint": "Marża doliczana do kosztu (wewnętrzne — nie pokazywane klientowi)",
+    "applyMarkup": "Zastosuj {percent}%",
+    "applyMarkupHint": "Zastosuj ponownie marżę {percent}% do kosztu w każdym wierszu",
     "unitPrice": "Cena Jednostkowa",
     "total": "Razem",
     "namePlaceholder": "Nazwa *",
@@ -96,7 +105,9 @@
     "scanTitle": "Skanuj kod kreskowy",
     "partFound": "Dodano \"{name}\" z magazynu",
     "partNotFound": "Nie znaleziono części dla kodu kreskowego \"{barcode}\"",
-    "deleteRow": "Delete row"
+    "deleteRow": "Delete row",
+    "outOfStock": "Brak w magazynie",
+    "onBackorder": "W zamówieniu ({count})"
   },
   "labor": {
     "title": "Praca",

+ 9 - 1
messages/pl/settings.json

@@ -390,6 +390,12 @@
     "customFooter": "Niestandardowa stopka faktury",
     "footerPlaceholder": "Dziekujemy za zamowienie!",
     "footerHint": "Ten tekst pojawia sie na dole kazdej faktury",
+    "partsMarkupTitle": "Marża na Części",
+    "partsMarkupDescription": "Marża doliczana do ceny zakupu przy dodawaniu części. Tylko wewnętrznie — klient nigdy nie widzi kosztu ani marży, tylko cenę końcową.",
+    "defaultMarkupPercent": "Domyślna Marża %",
+    "defaultMarkupPercentHint": "Nowe ręcznie dodane części są wstępnie wypełniane tą marżą. Pozostaw 0, aby wprowadzać ceny ręcznie.",
+    "markupAppliesToInventory": "Zastosuj marżę do części z magazynu",
+    "markupAppliesToInventoryHint": "Gdy włączone, części wybrane z magazynu również używają kosztu + domyślnej marży (zastępuje cenę sprzedaży z magazynu).",
     "saveInvoice": "Zapisz ustawienia faktury",
     "saved": "Ustawienia faktury zapisane",
     "layoutDescription": "Dostosuj kolejność i widoczność sekcji w fakturach i wycenach.",
@@ -1126,7 +1132,9 @@
     "upgradeCalculating": "Obliczanie proporcjonalnej kwoty…",
     "upgradeAmountDue": "Kwota do zapłaty teraz",
     "upgradeSuccess": "Pomyślnie ulepszono do Enterprise!",
-    "upgradeError": "Nie udało się ulepszyć subskrypcji"
+    "upgradeError": "Nie udało się ulepszyć subskrypcji",
+    "demoBadge": "Demo",
+    "demoExpiresIn": "Wygasa za {days, plural, =0 {mniej niż dzień} one {# dzień} other {# dni}} ({date})"
   },
   "ai": {
     "title": "Asystent AI",

+ 24 - 0
messages/pt-BR/admin.json

@@ -119,5 +119,29 @@
   },
   "common": {
     "openMenu": "Open menu"
+  },
+  "userDetails": {
+    "back": "Voltar para usuários",
+    "never": "Nunca",
+    "lastLogin": "Último login",
+    "organizations": "Organizações",
+    "noOrganizations": "Não é membro de nenhuma organização",
+    "role": "Função"
+  },
+  "orgDetails": {
+    "back": "Voltar",
+    "vehicles": "Veículos",
+    "workOrders": "Ordens de serviço",
+    "customers": "Clientes",
+    "quotes": "Orçamentos",
+    "inspections": "Inspeções",
+    "inventoryParts": "Peças de estoque",
+    "members": "Membros",
+    "owner": "Proprietário",
+    "noOwner": "Sem proprietário",
+    "subscription": "Assinatura",
+    "noSubscription": "Sem assinatura ativa",
+    "renews": "Renova",
+    "cancelsAt": "Termina em"
   }
 }

+ 31 - 26
messages/pt-BR/auth.json

@@ -4,41 +4,46 @@
     "description": "Entre na sua oficina",
     "passwordPlaceholder": "Digite sua senha",
     "forgotPassword": "Esqueceu a senha?",
-    "noAccount": "Ainda n\u00e3o tem uma conta?",
+    "noAccount": "Ainda não tem uma conta?",
     "createOne": "Crie uma",
-    "termsAgreement": "Ao utilizar este servi\u00e7o, voc\u00ea concorda com nossos",
+    "termsAgreement": "Ao utilizar este serviço, você concorda com nossos",
     "or": "ou",
     "passkey": "Entrar com Passkey",
     "passkeyDescription": "Use sua impressão digital, rosto ou chave de segurança",
     "errors": {
       "tooManyAttempts": "Muitas tentativas. Tente novamente mais tarde.",
-      "invalidCredentials": "E-mail ou senha inv\u00e1lidos",
+      "invalidCredentials": "E-mail ou senha inválidos",
       "passkeyFailed": "Falha ao entrar com passkey. Tente novamente ou use e-mail e senha."
-    }
+    },
+    "resetPasswordCta": "Redefina aqui"
   },
   "signUp": {
     "title": "Crie sua conta",
     "descriptionInvite": "Crie sua conta para entrar na equipe",
-    "descriptionDefault": "Configure sua oficina para gerenciar os ve\u00edculos dos clientes",
+    "descriptionDefault": "Configure sua oficina para gerenciar os veículos dos clientes",
     "fullName": "Nome Completo",
-    "fullNamePlaceholder": "Jo\u00e3o da Silva",
+    "fullNamePlaceholder": "João da Silva",
     "passwordPlaceholder": "Crie uma senha forte",
     "agreeToTerms": "Eu concordo com os",
     "createAccountJoin": "Criar Conta e Entrar na Equipe",
     "createAccount": "Criar Conta",
-    "alreadyHaveAccount": "J\u00e1 tem uma conta?",
+    "alreadyHaveAccount": "Já tem uma conta?",
     "errors": {
-      "couldNotCreate": "N\u00e3o foi poss\u00edvel criar a conta",
-      "registrationDisabled": "O cadastro est\u00e1 desativado no momento. Entre em contato com o administrador.",
-      "invitationFailed": "Falha ao aceitar o convite"
-    }
+      "couldNotCreate": "Não foi possível criar a conta",
+      "registrationDisabled": "O cadastro está desativado no momento. Entre em contato com o administrador.",
+      "invitationFailed": "Falha ao aceitar o convite",
+      "termsRequired": "Aceite os termos de serviço para continuar",
+      "emailExists": "Já existe uma conta com este e-mail."
+    },
+    "passwordHint": "Deve ter pelo menos 8 caracteres",
+    "signInInstead": "Faça login em vez disso"
   },
   "forgotPassword": {
     "title": "Redefinir sua senha",
-    "description": "Digite seu e-mail e enviaremos um link de redefini\u00e7\u00e3o",
-    "sendResetLink": "Enviar Link de Redefini\u00e7\u00e3o",
+    "description": "Digite seu e-mail e enviaremos um link de redefinição",
+    "sendResetLink": "Enviar Link de Redefinição",
     "backToSignIn": "Voltar para o Login",
-    "successMessage": "Se existir uma conta com esse e-mail, um link de redefini\u00e7\u00e3o de senha foi enviado. Verifique sua caixa de entrada."
+    "successMessage": "Se existir uma conta com esse e-mail, um link de redefinição de senha foi enviado. Verifique sua caixa de entrada."
   },
   "resetPassword": {
     "title": "Definir nova senha",
@@ -50,12 +55,12 @@
     "resetButton": "Redefinir Senha",
     "backToSignIn": "Voltar para o Login",
     "requestNewLink": "Solicitar Novo Link",
-    "invalidToken": "Token de redefini\u00e7\u00e3o inv\u00e1lido ou ausente. Solicite um novo link de redefini\u00e7\u00e3o de senha.",
-    "successMessage": "Sua senha foi redefinida com sucesso. Agora voc\u00ea pode entrar com sua nova senha.",
+    "invalidToken": "Token de redefinição inválido ou ausente. Solicite um novo link de redefinição de senha.",
+    "successMessage": "Sua senha foi redefinida com sucesso. Agora você pode entrar com sua nova senha.",
     "errors": {
-      "passwordsMismatch": "As senhas n\u00e3o coincidem.",
+      "passwordsMismatch": "As senhas não coincidem.",
       "passwordTooShort": "A senha deve ter pelo menos 8 caracteres.",
-      "resetFailed": "N\u00e3o foi poss\u00edvel redefinir a senha. O link pode ter expirado."
+      "resetFailed": "Não foi possível redefinir a senha. O link pode ter expirado."
     }
   },
   "verifyEmail": {
@@ -68,19 +73,19 @@
     "checkSpam": "Se você não encontrar o e-mail, verifique sua pasta de spam."
   },
   "verify2fa": {
-    "title": "Autentica\u00e7\u00e3o de Dois Fatores",
-    "descriptionTotp": "Digite o c\u00f3digo de 6 d\u00edgitos do seu aplicativo autenticador",
-    "descriptionBackup": "Digite um dos seus c\u00f3digos de backup",
-    "authCode": "C\u00f3digo de Autentica\u00e7\u00e3o",
-    "backupCode": "C\u00f3digo de Backup",
-    "backupCodePlaceholder": "Digite o c\u00f3digo de backup",
+    "title": "Autenticação de Dois Fatores",
+    "descriptionTotp": "Digite o código de 6 dígitos do seu aplicativo autenticador",
+    "descriptionBackup": "Digite um dos seus códigos de backup",
+    "authCode": "Código de Autenticação",
+    "backupCode": "Código de Backup",
+    "backupCodePlaceholder": "Digite o código de backup",
     "totpPlaceholder": "000000",
     "trustDevice": "Confiar neste dispositivo por 30 dias",
     "verify": "Verificar",
     "useAuthenticator": "Usar aplicativo autenticador",
-    "useBackupCode": "Usar um c\u00f3digo de backup",
+    "useBackupCode": "Usar um código de backup",
     "errors": {
-      "invalidCode": "C\u00f3digo inv\u00e1lido"
+      "invalidCode": "Código inválido"
     }
   }
 }

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

@@ -11,6 +11,9 @@
     "datesExpiredDescription": "A data da fatura ou a data de vencimento já passou. Deseja atualizar as datas antes de prosseguir?",
     "datesExpiredUpdate": "Atualizar datas e prosseguir",
     "datesExpiredProceed": "Prosseguir sem alterações",
+    "datesExpiredUseToday": "Atualizar para a data de hoje",
+    "datesExpiredCustomize": "Escolher datas manualmente",
+    "datesExpiredBack": "Voltar",
     "datesExpiredSetToday": "Definir para hoje",
     "customFieldsInvalid": "Preencha todos os campos personalizados obrigatórios",
     "customFieldsSaveFailed": "Falha ao salvar campos personalizados"
@@ -84,6 +87,12 @@
     "partNumber": "Nº da Peça",
     "name": "Nome",
     "qty": "Qtd",
+    "unitCost": "Custo",
+    "unitCostHint": "Custo do fornecedor (interno — não exibido ao cliente)",
+    "markupPercent": "Margem %",
+    "markupPercentHint": "Margem aplicada ao custo (interno — não exibido ao cliente)",
+    "applyMarkup": "Aplicar {percent}%",
+    "applyMarkupHint": "Reaplicar margem de {percent}% ao custo em cada linha",
     "unitPrice": "Preço Unitário",
     "total": "Total",
     "namePlaceholder": "Nome *",
@@ -96,7 +105,9 @@
     "scanTitle": "Escanear código de barras",
     "partFound": "\"{name}\" adicionado do estoque",
     "partNotFound": "Nenhuma peça encontrada para o código de barras \"{barcode}\"",
-    "deleteRow": "Delete row"
+    "deleteRow": "Delete row",
+    "outOfStock": "Fora de estoque",
+    "onBackorder": "Sob encomenda ({count})"
   },
   "labor": {
     "title": "Mão de Obra",

+ 9 - 1
messages/pt-BR/settings.json

@@ -390,6 +390,12 @@
     "customFooter": "Rodape personalizado da fatura",
     "footerPlaceholder": "Obrigado pela preferencia!",
     "footerHint": "Este texto aparece no final de cada fatura",
+    "partsMarkupTitle": "Margem de Peças",
+    "partsMarkupDescription": "Margem aplicada ao custo do fornecedor ao adicionar peças. Apenas interno — o cliente nunca vê o custo ou a margem, somente o preço final.",
+    "defaultMarkupPercent": "Margem Padrão %",
+    "defaultMarkupPercentHint": "Novas peças adicionadas manualmente são pré-preenchidas com esta margem. Deixe em 0 para inserir preços manualmente.",
+    "markupAppliesToInventory": "Aplicar margem a peças do estoque",
+    "markupAppliesToInventoryHint": "Quando ativado, peças selecionadas do estoque também usam custo + margem padrão (substitui o preço de venda do estoque).",
     "saveInvoice": "Salvar configuracoes da fatura",
     "saved": "Configuracoes da fatura salvas",
     "layoutDescription": "Personalize a ordem e visibilidade das seções em suas faturas e orçamentos.",
@@ -1126,7 +1132,9 @@
     "upgradeCalculating": "Calculando o valor proporcional…",
     "upgradeAmountDue": "Valor a pagar agora",
     "upgradeSuccess": "Atualizado para Enterprise com sucesso!",
-    "upgradeError": "Falha ao atualizar a assinatura"
+    "upgradeError": "Falha ao atualizar a assinatura",
+    "demoBadge": "Demo",
+    "demoExpiresIn": "Expira em {days, plural, =0 {menos de um dia} one {# dia} other {# dias}} ({date})"
   },
   "ai": {
     "title": "Assistente de IA",

+ 24 - 0
messages/ru/admin.json

@@ -119,5 +119,29 @@
   },
   "common": {
     "openMenu": "Open menu"
+  },
+  "userDetails": {
+    "back": "Назад к пользователям",
+    "never": "Никогда",
+    "lastLogin": "Последний вход",
+    "organizations": "Организации",
+    "noOrganizations": "Не состоит ни в одной организации",
+    "role": "Роль"
+  },
+  "orgDetails": {
+    "back": "Назад",
+    "vehicles": "Транспортные средства",
+    "workOrders": "Рабочие заказы",
+    "customers": "Клиенты",
+    "quotes": "Предложения",
+    "inspections": "Осмотры",
+    "inventoryParts": "Складские детали",
+    "members": "Участники",
+    "owner": "Владелец",
+    "noOwner": "Нет владельца",
+    "subscription": "Подписка",
+    "noSubscription": "Нет активной подписки",
+    "renews": "Продлевается",
+    "cancelsAt": "Заканчивается"
   }
 }

+ 8 - 3
messages/ru/auth.json

@@ -14,7 +14,8 @@
       "tooManyAttempts": "Слишком много попыток, попробуйте позже.",
       "invalidCredentials": "Неверный адрес эл. почты или пароль",
       "passkeyFailed": "Вход по ключу доступа не удался. Попробуйте снова или используйте эл. почту и пароль."
-    }
+    },
+    "resetPasswordCta": "Сбросьте его здесь"
   },
   "signUp": {
     "title": "Создать аккаунт",
@@ -30,8 +31,12 @@
     "errors": {
       "couldNotCreate": "Не удалось создать аккаунт",
       "registrationDisabled": "Регистрация в настоящее время отключена. Обратитесь к администратору.",
-      "invitationFailed": "Не удалось принять приглашение"
-    }
+      "invitationFailed": "Не удалось принять приглашение",
+      "termsRequired": "Пожалуйста, примите условия использования, чтобы продолжить",
+      "emailExists": "Учётная запись с этим адресом электронной почты уже существует."
+    },
+    "passwordHint": "Не менее 8 символов",
+    "signInInstead": "Войдите вместо этого"
   },
   "forgotPassword": {
     "title": "Сброс пароля",

+ 12 - 1
messages/ru/service.json

@@ -11,6 +11,9 @@
     "datesExpiredDescription": "Дата счёта или срок оплаты истёк. Хотите обновить даты перед продолжением?",
     "datesExpiredUpdate": "Обновить даты и продолжить",
     "datesExpiredProceed": "Продолжить без изменений",
+    "datesExpiredUseToday": "Обновить до сегодняшней даты",
+    "datesExpiredCustomize": "Выбрать даты вручную",
+    "datesExpiredBack": "Назад",
     "datesExpiredSetToday": "Установить на сегодня",
     "customFieldsInvalid": "Заполните все обязательные пользовательские поля",
     "customFieldsSaveFailed": "Не удалось сохранить пользовательские поля"
@@ -84,6 +87,12 @@
     "partNumber": "Арт. №",
     "name": "Название",
     "qty": "Кол-во",
+    "unitCost": "Себестоимость",
+    "unitCostHint": "Закупочная цена (внутренняя — клиенту не показывается)",
+    "markupPercent": "Наценка %",
+    "markupPercentHint": "Наценка к себестоимости (внутренняя — клиенту не показывается)",
+    "applyMarkup": "Применить {percent}%",
+    "applyMarkupHint": "Повторно применить наценку {percent}% к себестоимости в каждой строке",
     "unitPrice": "Цена за ед.",
     "total": "Итого",
     "namePlaceholder": "Название *",
@@ -96,7 +105,9 @@
     "scanTitle": "Сканировать штрихкод",
     "partFound": "\"{name}\" добавлено из склада",
     "partNotFound": "Деталь для штрихкода \"{barcode}\" не найдена",
-    "deleteRow": "Delete row"
+    "deleteRow": "Delete row",
+    "outOfStock": "Нет в наличии",
+    "onBackorder": "Под заказ ({count})"
   },
   "labor": {
     "title": "Работа",

+ 9 - 1
messages/ru/settings.json

@@ -390,6 +390,12 @@
     "customFooter": "Пользовательский подвал счёта",
     "footerPlaceholder": "Спасибо за выбор нашего сервиса!",
     "footerHint": "Этот текст отображается внизу каждого счёта",
+    "partsMarkupTitle": "Наценка на запчасти",
+    "partsMarkupDescription": "Наценка к закупочной цене при добавлении запчастей. Только внутреннее — клиент никогда не видит себестоимость или наценку, только итоговую цену.",
+    "defaultMarkupPercent": "Наценка по умолчанию %",
+    "defaultMarkupPercentHint": "Новые вручную добавленные запчасти заполняются этой наценкой. Оставьте 0, чтобы вводить цены вручную.",
+    "markupAppliesToInventory": "Применить наценку к запчастям со склада",
+    "markupAppliesToInventoryHint": "Если включено, запчасти, выбранные со склада, также используют себестоимость + наценку по умолчанию (переопределяет цену продажи со склада).",
     "saveInvoice": "Сохранить настройки счёта",
     "saved": "Настройки счёта сохранены",
     "layoutDescription": "Настройте порядок и видимость разделов в счетах и предложениях.",
@@ -1126,7 +1132,9 @@
     "upgradeCalculating": "Расчёт пропорциональной суммы...",
     "upgradeAmountDue": "К оплате сейчас",
     "upgradeSuccess": "Успешно обновлено до корпоративного тарифа!",
-    "upgradeError": "Не удалось обновить подписку"
+    "upgradeError": "Не удалось обновить подписку",
+    "demoBadge": "Демо",
+    "demoExpiresIn": "Истекает через {days, plural, =0 {меньше дня} one {# день} other {# дней}} ({date})"
   },
   "ai": {
     "title": "ИИ-ассистент",

+ 24 - 0
messages/tr/admin.json

@@ -119,5 +119,29 @@
   },
   "common": {
     "openMenu": "Open menu"
+  },
+  "userDetails": {
+    "back": "Kullanıcılara dön",
+    "never": "Hiçbir zaman",
+    "lastLogin": "Son giriş",
+    "organizations": "Organizasyonlar",
+    "noOrganizations": "Hiçbir organizasyonun üyesi değil",
+    "role": "Rol"
+  },
+  "orgDetails": {
+    "back": "Geri",
+    "vehicles": "Araçlar",
+    "workOrders": "İş emirleri",
+    "customers": "Müşteriler",
+    "quotes": "Teklifler",
+    "inspections": "Muayeneler",
+    "inventoryParts": "Stok parçaları",
+    "members": "Üyeler",
+    "owner": "Sahibi",
+    "noOwner": "Sahibi yok",
+    "subscription": "Abonelik",
+    "noSubscription": "Etkin abonelik yok",
+    "renews": "Yenileniyor",
+    "cancelsAt": "Sona eriyor"
   }
 }

+ 8 - 3
messages/tr/auth.json

@@ -14,7 +14,8 @@
       "tooManyAttempts": "Çok fazla deneme yapıldı, lütfen daha sonra tekrar deneyin.",
       "invalidCredentials": "Geçersiz e-posta veya şifre",
       "passkeyFailed": "Passkey ile giriş başarısız. Tekrar deneyin veya e-posta ve şifre kullanın."
-    }
+    },
+    "resetPasswordCta": "Buradan sıfırlayın"
   },
   "signUp": {
     "title": "Hesap oluşturun",
@@ -30,8 +31,12 @@
     "errors": {
       "couldNotCreate": "Hesap oluşturulamadı",
       "registrationDisabled": "Kayıt şu anda devre dışı. Lütfen yöneticinizle iletişime geçin.",
-      "invitationFailed": "Davet kabul edilemedi"
-    }
+      "invitationFailed": "Davet kabul edilemedi",
+      "termsRequired": "Devam etmek için hizmet şartlarını kabul edin",
+      "emailExists": "Bu e-posta ile zaten bir hesap mevcut."
+    },
+    "passwordHint": "En az 8 karakter olmalı",
+    "signInInstead": "Bunun yerine giriş yapın"
   },
   "forgotPassword": {
     "title": "Şifrenizi sıfırlayın",

+ 12 - 1
messages/tr/service.json

@@ -11,6 +11,9 @@
     "datesExpiredDescription": "Fatura tarihi veya vade tarihi geçmiş. Devam etmeden önce tarihleri güncellemek ister misiniz?",
     "datesExpiredUpdate": "Tarihleri güncelle ve devam et",
     "datesExpiredProceed": "Değişiklik yapmadan devam et",
+    "datesExpiredUseToday": "Bugünün tarihine güncelle",
+    "datesExpiredCustomize": "Tarihleri manuel olarak seç",
+    "datesExpiredBack": "Geri",
     "datesExpiredSetToday": "Bugüne ayarla",
     "customFieldsInvalid": "Lütfen tüm zorunlu özel alanları doldurun",
     "customFieldsSaveFailed": "Özel alanlar kaydedilemedi"
@@ -84,6 +87,12 @@
     "partNumber": "Parça #",
     "name": "Ad",
     "qty": "Mik.",
+    "unitCost": "Maliyet",
+    "unitCostHint": "Tedarikçi maliyeti (dahili — müşteriye gösterilmez)",
+    "markupPercent": "Kâr Marjı %",
+    "markupPercentHint": "Maliyete uygulanan kâr marjı (dahili — müşteriye gösterilmez)",
+    "applyMarkup": "{percent}% Uygula",
+    "applyMarkupHint": "Her satırdaki maliyete {percent}% marjı yeniden uygula",
     "unitPrice": "Birim Fiyatı",
     "total": "Toplam",
     "namePlaceholder": "Ad *",
@@ -96,7 +105,9 @@
     "scanTitle": "Barkod tara",
     "partFound": "\"{name}\" envanterden eklendi",
     "partNotFound": "\"{barcode}\" barkodu için parça bulunamadı",
-    "deleteRow": "Delete row"
+    "deleteRow": "Delete row",
+    "outOfStock": "Stokta yok",
+    "onBackorder": "Ön siparişte ({count})"
   },
   "labor": {
     "title": "İşçilik",

+ 9 - 1
messages/tr/settings.json

@@ -390,6 +390,12 @@
     "customFooter": "Özel Fatura Alt Bilgisi",
     "footerPlaceholder": "İşiniz için teşekkür ederiz!",
     "footerHint": "Bu metin her faturanın altında görünür",
+    "partsMarkupTitle": "Parça Kâr Marjı",
+    "partsMarkupDescription": "Parça eklenirken tedarikçi maliyetine uygulanan kâr marjı. Yalnızca dahili — müşteri maliyeti veya marjı asla görmez, sadece son fiyatı.",
+    "defaultMarkupPercent": "Varsayılan Kâr Marjı %",
+    "defaultMarkupPercentHint": "Yeni elle eklenen parçalar bu marjla önceden doldurulur. Fiyatları elle girmek için 0 bırakın.",
+    "markupAppliesToInventory": "Stok parçalarına kâr marjı uygula",
+    "markupAppliesToInventoryHint": "Etkinleştirildiğinde, stoktan seçilen parçalar da maliyet + varsayılan marj kullanır (stoğun satış fiyatını geçersiz kılar).",
     "saveInvoice": "Fatura Ayarlarını Kaydet",
     "saved": "Fatura ayarları kaydedildi",
     "layoutDescription": "Fatura ve tekliflerinizdeki bölümlerin sırasını ve görünürlüğünü özelleştirin.",
@@ -1126,7 +1132,9 @@
     "upgradeCalculating": "Orantılı tutar hesaplanıyor…",
     "upgradeAmountDue": "Şimdi ödenecek tutar",
     "upgradeSuccess": "Enterprise'a başarıyla yükseltildi!",
-    "upgradeError": "Abonelik yükseltilemedi"
+    "upgradeError": "Abonelik yükseltilemedi",
+    "demoBadge": "Demo",
+    "demoExpiresIn": "{days, plural, =0 {bir günden az} one {# gün} other {# gün}} içinde sona erer ({date})"
   },
   "ai": {
     "title": "Yapay Zeka Asistani",

Разница между файлами не показана из-за своего большого размера
+ 348 - 252
package-lock.json


+ 18 - 21
package.json

@@ -18,14 +18,13 @@
     "seed:demo": "npx tsx prisma/seed_dummy_data.ts"
   },
   "dependencies": {
-    "@aws-sdk/client-ses": "^3.1037.0",
-    "@better-auth/passkey": "^1.5.6",
+    "@aws-sdk/client-ses": "^3.1045.0",
+    "@better-auth/passkey": "^1.6.19",
     "@dnd-kit/core": "^6.3.1",
     "@dnd-kit/sortable": "^10.0.0",
     "@dnd-kit/utilities": "^3.2.2",
-    "@hookform/resolvers": "^5.2.2",
-    "@prisma/adapter-pg": "^7.7.0",
-    "@prisma/client": "^7.7.0",
+    "@prisma/adapter-pg": "^7.8.0",
+    "@prisma/client": "^7.8.0",
     "@react-pdf/renderer": "^4.3.2",
     "@sendgrid/mail": "^8.1.6",
     "@tanstack/react-query": "^5.90.21",
@@ -36,8 +35,7 @@
     "@tiptap/react": "^3.19.0",
     "@tiptap/starter-kit": "^3.19.0",
     "@types/qrcode": "^1.5.6",
-    "archiver": "^7.0.1",
-    "better-auth": "^1.5.6",
+    "better-auth": "^1.6.19",
     "bson": "^7.2.0",
     "class-variance-authority": "^0.7.1",
     "clsx": "^2.1.1",
@@ -45,19 +43,19 @@
     "cron": "^4.4.0",
     "date-fns": "^4.1.0",
     "dotenv": "^17.2.4",
-    "form-data": "^4.0.5",
+    "form-data": "^4.0.6",
     "html5-qrcode": "^2.3.8",
     "jszip": "^3.10.1",
     "lucide-react": "^0.563.0",
     "mailgun.js": "^12.7.1",
-    "next": "^16.2.4",
-    "next-intl": "^4.9.1",
+    "next": "^16.2.9",
+    "next-intl": "^4.11.1",
     "next-themes": "^0.4.6",
-    "next-ws": "^2.2.5",
-    "nodemailer": "^8.0.5",
+    "next-ws": "^2.2.7",
+    "nodemailer": "^9.0.1",
     "openai": "^6.27.0",
     "pdf-lib": "^1.17.1",
-    "posthog-js": "^1.372.1",
+    "posthog-js": "^1.390.2",
     "postmark": "^4.0.7",
     "prisma": "^7.8.0",
     "qrcode": "^1.5.4",
@@ -71,35 +69,34 @@
     "react-resizable-panels": "^4.6.4",
     "recharts": "^3.7.0",
     "remark-gfm": "^4.0.1",
-    "resend": "^6.12.2",
+    "resend": "^6.14.0",
     "sonner": "^2.0.7",
     "stripe": "^20.3.1",
     "tailwind-merge": "^3.4.0",
     "vaul": "^1.1.2",
-    "ws": "^8.19.0",
+    "ws": "^8.21.0",
     "zod": "^4.3.6",
     "zustand": "^5.0.11"
   },
   "devDependencies": {
     "@biomejs/biome": "2.3.15",
-    "@tailwindcss/postcss": "^4",
+    "@tailwindcss/postcss": "^4.3.0",
     "@testing-library/jest-dom": "^6.9.1",
     "@testing-library/react": "^16.3.2",
     "@testing-library/user-event": "^14.6.1",
-    "@types/archiver": "^7.0.0",
     "@types/cron": "^2.0.1",
     "@types/node": "^20",
     "@types/nodemailer": "^7.0.9",
     "@types/react": "^19",
     "@types/react-dom": "^19",
     "@types/ws": "^8.18.1",
-    "@vitest/coverage-v8": "^4.1.3",
-    "jsdom": "^29.0.0",
-    "shadcn": "^4.5.0",
+    "@vitest/coverage-v8": "^4.1.9",
+    "jsdom": "^29.1.1",
+    "shadcn": "^4.11.0",
     "tailwindcss": "^4",
     "tw-animate-css": "^1.4.0",
     "typescript": "^5",
-    "vitest": "^4.1.3"
+    "vitest": "^4.1.9"
   },
   "overrides": {
     "fast-xml-parser": ">=5.3.6",

+ 2 - 0
prisma/migrations/20260525102721_part_markup/migration.sql

@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "service_parts" ADD COLUMN     "markupPercent" DOUBLE PRECISION NOT NULL DEFAULT 0;

+ 1 - 0
prisma/schema.prisma

@@ -392,6 +392,7 @@ model ServicePart {
   unitPrice       Float   @default(0)
   total           Float   @default(0)
   unitCost        Float   @default(0)
+  markupPercent   Float   @default(0)
   inventoryPartId String?
 
   serviceRecordId String

+ 136 - 0
src/__tests__/features/inventory/reconcile-stock.test.ts

@@ -0,0 +1,136 @@
+/**
+ * Tests for reconcileInventoryForParts — the single source of truth for how
+ * work-order parts move inventory stock.
+ *
+ * The helper must apply the NET delta between a record's previous and next
+ * linked parts: adding/increasing consumes stock, removing/reducing restocks,
+ * and unchanged quantities touch nothing. Every write is scoped to the
+ * caller's organization and applied atomically via `{ decrement }`.
+ */
+
+import { describe, it, expect, vi } from "vitest";
+import { reconcileInventoryForParts } from "@/features/inventory/Lib/reconcileStock";
+
+const ORG = "org-1";
+
+/** A fake transaction client that records inventoryPart.updateMany calls. */
+function makeTx() {
+  const updateMany = vi.fn().mockResolvedValue({ count: 1 });
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  const tx = { inventoryPart: { updateMany } } as any;
+  return { tx, updateMany };
+}
+
+/** Extract {id, decrement} pairs from recorded updateMany calls. */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function movements(updateMany: any) {
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  return updateMany.mock.calls.map((c: any[]) => ({
+    id: c[0].where.id,
+    organizationId: c[0].where.organizationId,
+    decrement: c[0].data.quantity.decrement,
+  }));
+}
+
+describe("reconcileInventoryForParts", () => {
+  it("deducts stock for newly added linked parts (delta from empty)", async () => {
+    const { tx, updateMany } = makeTx();
+    await reconcileInventoryForParts(tx, ORG, [], [{ inventoryPartId: "p1", quantity: 3 }]);
+    expect(movements(updateMany)).toEqual([{ id: "p1", organizationId: ORG, decrement: 3 }]);
+  });
+
+  it("ignores free-text parts that are not linked to inventory", async () => {
+    const { tx, updateMany } = makeTx();
+    await reconcileInventoryForParts(tx, ORG, [], [
+      { inventoryPartId: null, quantity: 5 },
+      { quantity: 2 },
+    ]);
+    expect(updateMany).not.toHaveBeenCalled();
+  });
+
+  it("restocks fully when a part is removed (delete work order)", async () => {
+    const { tx, updateMany } = makeTx();
+    await reconcileInventoryForParts(tx, ORG, [{ inventoryPartId: "p1", quantity: 4 }], []);
+    // Negative decrement = increment = restock.
+    expect(movements(updateMany)).toEqual([{ id: "p1", organizationId: ORG, decrement: -4 }]);
+  });
+
+  it("applies only the delta when a quantity increases", async () => {
+    const { tx, updateMany } = makeTx();
+    await reconcileInventoryForParts(
+      tx,
+      ORG,
+      [{ inventoryPartId: "p1", quantity: 2 }],
+      [{ inventoryPartId: "p1", quantity: 5 }],
+    );
+    expect(movements(updateMany)).toEqual([{ id: "p1", organizationId: ORG, decrement: 3 }]);
+  });
+
+  it("restocks the delta when a quantity decreases", async () => {
+    const { tx, updateMany } = makeTx();
+    await reconcileInventoryForParts(
+      tx,
+      ORG,
+      [{ inventoryPartId: "p1", quantity: 5 }],
+      [{ inventoryPartId: "p1", quantity: 2 }],
+    );
+    expect(movements(updateMany)).toEqual([{ id: "p1", organizationId: ORG, decrement: -3 }]);
+  });
+
+  it("does nothing when the linked parts are unchanged (no double-count on re-save)", async () => {
+    const { tx, updateMany } = makeTx();
+    const same = [{ inventoryPartId: "p1", quantity: 3 }];
+    await reconcileInventoryForParts(tx, ORG, same, [...same]);
+    expect(updateMany).not.toHaveBeenCalled();
+  });
+
+  it("aggregates multiple lines that reference the same inventory part", async () => {
+    const { tx, updateMany } = makeTx();
+    await reconcileInventoryForParts(tx, ORG, [], [
+      { inventoryPartId: "p1", quantity: 2 },
+      { inventoryPartId: "p1", quantity: 3 },
+    ]);
+    expect(movements(updateMany)).toEqual([{ id: "p1", organizationId: ORG, decrement: 5 }]);
+  });
+
+  it("handles several different parts changing at once", async () => {
+    const { tx, updateMany } = makeTx();
+    await reconcileInventoryForParts(
+      tx,
+      ORG,
+      [
+        { inventoryPartId: "p1", quantity: 2 },
+        { inventoryPartId: "p2", quantity: 1 },
+      ],
+      [
+        { inventoryPartId: "p1", quantity: 2 }, // unchanged -> skipped
+        { inventoryPartId: "p3", quantity: 4 }, // new -> deduct 4
+        // p2 removed -> restock 1
+      ],
+    );
+    const moves = movements(updateMany);
+    expect(moves).toContainEqual({ id: "p2", organizationId: ORG, decrement: -1 });
+    expect(moves).toContainEqual({ id: "p3", organizationId: ORG, decrement: 4 });
+    expect(moves.find((m: { id: string }) => m.id === "p1")).toBeUndefined();
+    expect(moves).toHaveLength(2);
+  });
+
+  it("rounds fractional quantities to whole stock units", async () => {
+    const { tx, updateMany } = makeTx();
+    await reconcileInventoryForParts(tx, ORG, [], [
+      { inventoryPartId: "p1", quantity: 2.4 },
+      { inventoryPartId: "p2", quantity: 2.6 },
+    ]);
+    const moves = movements(updateMany);
+    expect(moves).toContainEqual({ id: "p1", organizationId: ORG, decrement: 2 });
+    expect(moves).toContainEqual({ id: "p2", organizationId: ORG, decrement: 3 });
+  });
+
+  it("always scopes writes to the caller's organization", async () => {
+    const { tx, updateMany } = makeTx();
+    await reconcileInventoryForParts(tx, ORG, [], [{ inventoryPartId: "p1", quantity: 1 }]);
+    expect(updateMany).toHaveBeenCalledWith(
+      expect.objectContaining({ where: expect.objectContaining({ organizationId: ORG }) }),
+    );
+  });
+});

+ 13 - 5
src/__tests__/features/workorders/add-part.test.ts

@@ -16,15 +16,19 @@ vi.mock("@/lib/cached-session", () => ({
 
 vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
 
-vi.mock("@/lib/db", () => ({
-  db: {
+vi.mock("@/lib/db", () => {
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  const db: any = {
     user: { findUnique: vi.fn() },
     serviceRecord: { findFirst: vi.fn(), update: vi.fn() },
     servicePart: { create: vi.fn(), aggregate: vi.fn() },
     serviceLabor: { aggregate: vi.fn() },
-    inventoryPart: { update: vi.fn() },
-  },
-}));
+    inventoryPart: { update: vi.fn(), updateMany: vi.fn() },
+  };
+  // Run the transaction callback against the same mock db (tx === db).
+  db.$transaction = vi.fn(async (cb: any) => cb(db));
+  return { db };
+});
 
 import { getCachedSession, getCachedMembership } from "@/lib/cached-session";
 import { db } from "@/lib/db";
@@ -51,6 +55,10 @@ function setupAuth() {
 
 beforeEach(() => {
   vi.resetAllMocks();
+  // resetAllMocks clears implementations, so restore the transaction runner
+  // (invoke the callback with the mock db as the transaction client).
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  vi.mocked(db.$transaction).mockImplementation(async (cb: any) => cb(db));
 });
 
 describe("addPartToServiceRecord — totals recalculation", () => {

+ 14 - 10
src/__tests__/features/workorders/create-workorder.test.ts

@@ -257,14 +257,14 @@ describe("createServiceRecord — parts and inventory", () => {
 
     const mockCreate = vi.fn().mockResolvedValue({ id: "sr-inv" });
     const mockInvFind = vi.fn().mockResolvedValue({ id: "inv-1", quantity: 10, organizationId: ORG });
-    const mockInvUpdate = vi.fn();
+    const mockInvUpdateMany = vi.fn().mockResolvedValue({ count: 1 });
     vi.mocked(db.$transaction).mockImplementation(async (fn: any) =>
       fn({
         serviceRecord: { create: mockCreate },
         servicePart: { createMany: vi.fn() },
         serviceLabor: { createMany: vi.fn() },
         serviceAttachment: { createMany: vi.fn() },
-        inventoryPart: { findFirst: mockInvFind, update: mockInvUpdate },
+        inventoryPart: { findFirst: mockInvFind, updateMany: mockInvUpdateMany },
       })
     );
 
@@ -280,28 +280,29 @@ describe("createServiceRecord — parts and inventory", () => {
       totalAmount: 15,
     });
 
-    expect(mockInvUpdate).toHaveBeenCalledWith(
+    // Stock is deducted atomically and scoped to the org.
+    expect(mockInvUpdateMany).toHaveBeenCalledWith(
       expect.objectContaining({
-        where: { id: "inv-1" },
-        data: { quantity: 9 }, // 10 - 1
+        where: { id: "inv-1", organizationId: ORG },
+        data: { quantity: { decrement: 1 } },
       })
     );
   });
 
-  it("does not allow negative inventory (floors at 0)", async () => {
+  it("allows stock to go negative when consuming more than on hand (reversible over-consumption)", async () => {
     setupAuth();
     setupCommonMocks();
 
     const mockCreate = vi.fn().mockResolvedValue({ id: "sr-neg" });
     const mockInvFind = vi.fn().mockResolvedValue({ id: "inv-2", quantity: 1, organizationId: ORG });
-    const mockInvUpdate = vi.fn();
+    const mockInvUpdateMany = vi.fn().mockResolvedValue({ count: 1 });
     vi.mocked(db.$transaction).mockImplementation(async (fn: any) =>
       fn({
         serviceRecord: { create: mockCreate },
         servicePart: { createMany: vi.fn() },
         serviceLabor: { createMany: vi.fn() },
         serviceAttachment: { createMany: vi.fn() },
-        inventoryPart: { findFirst: mockInvFind, update: mockInvUpdate },
+        inventoryPart: { findFirst: mockInvFind, updateMany: mockInvUpdateMany },
       })
     );
 
@@ -317,9 +318,12 @@ describe("createServiceRecord — parts and inventory", () => {
       totalAmount: 500,
     });
 
-    expect(mockInvUpdate).toHaveBeenCalledWith(
+    // Decrement the full 5 (1 on hand -> -4). Clamping at 0 would corrupt the
+    // count once the record is later edited or deleted, so the true signed
+    // value is kept.
+    expect(mockInvUpdateMany).toHaveBeenCalledWith(
       expect.objectContaining({
-        data: { quantity: 0 },
+        data: { quantity: { decrement: 5 } },
       })
     );
   });

+ 8 - 0
src/__tests__/multitenancy/vehicle-service-isolation.test.ts

@@ -38,6 +38,9 @@ vi.mock("@/lib/db", () => ({
       findFirst: vi.fn(),
       delete: vi.fn(),
     },
+    servicePart: { findMany: vi.fn() },
+    inventoryPart: { updateMany: vi.fn() },
+    $transaction: vi.fn(),
   },
 }));
 
@@ -91,6 +94,11 @@ const ORG_A_VEHICLE = {
 
 beforeEach(() => {
   vi.resetAllMocks();
+  // deleteServiceRecord restocks inventory inside a transaction; run the
+  // callback against the mock db and default to a record with no parts.
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  vi.mocked(db.$transaction).mockImplementation(async (cb: any) => cb(db));
+  vi.mocked(db.servicePart.findMany).mockResolvedValue([] as any);
 });
 
 // ---------------------------------------------------------------------------

+ 18 - 0
src/app/(authenticated)/admin/organizations/[id]/page.tsx

@@ -0,0 +1,18 @@
+import { notFound } from 'next/navigation'
+import { getOrganizationDetails } from '@/features/admin/Actions/getOrganizationDetails'
+import { AdminOrganizationDetails } from '@/features/admin/Components/admin-organization-details'
+
+export default async function AdminOrganizationDetailsPage({
+  params,
+}: {
+  params: Promise<{ id: string }>
+}) {
+  const { id } = await params
+  const result = await getOrganizationDetails(id)
+
+  if (!result.success || !result.data) {
+    notFound()
+  }
+
+  return <AdminOrganizationDetails org={result.data} />
+}

+ 18 - 0
src/app/(authenticated)/admin/users/[id]/page.tsx

@@ -0,0 +1,18 @@
+import { notFound } from 'next/navigation'
+import { getUserDetails } from '@/features/admin/Actions/getUserDetails'
+import { AdminUserDetails } from '@/features/admin/Components/admin-user-details'
+
+export default async function AdminUserDetailsPage({
+  params,
+}: {
+  params: Promise<{ id: string }>
+}) {
+  const { id } = await params
+  const result = await getUserDetails(id)
+
+  if (!result.success || !result.data) {
+    notFound()
+  }
+
+  return <AdminUserDetails user={result.data} />
+}

+ 25 - 10
src/app/(authenticated)/admin/users/page.tsx

@@ -1,19 +1,34 @@
-import { getUsers } from "@/features/admin/Actions/getUsers";
-import { AdminUsers } from "@/features/admin/Components/admin-users";
+import { getUsers } from '@/features/admin/Actions/getUsers'
+import { AdminUsers } from '@/features/admin/Components/admin-users'
 
 export default async function AdminUsersPage({
   searchParams,
 }: {
-  searchParams: Promise<{ search?: string; page?: string; pageSize?: string }>;
+  searchParams: Promise<{
+    search?: string
+    page?: string
+    pageSize?: string
+    sortBy?: string
+    sortOrder?: string
+  }>
 }) {
-  const params = await searchParams;
-  const page = Number(params.page) || 1;
-  const pageSize = Number(params.pageSize) || 20;
-  const search = params.search || "";
+  const params = await searchParams
+  const page = Number(params.page) || 1
+  const pageSize = Number(params.pageSize) || 20
+  const search = params.search || ''
+  const sortBy = params.sortBy
+  const sortOrder = params.sortOrder
 
-  const result = await getUsers({ search, page, pageSize });
+  const result = await getUsers({ search, page, pageSize, sortBy, sortOrder })
 
-  const data = result.data ?? { users: [], total: 0, page: 1, pageSize: 20, totalPages: 0 };
+  const data = result.data ?? { users: [], total: 0, page: 1, pageSize: 20, totalPages: 0 }
 
-  return <AdminUsers data={data} search={search} />;
+  return (
+    <AdminUsers
+      data={data}
+      search={search}
+      sortBy={sortBy ?? 'lastSeen'}
+      sortOrder={sortOrder === 'asc' ? 'asc' : 'desc'}
+    />
+  )
 }

+ 56 - 0
src/app/(authenticated)/settings/invoice/invoice-settings.tsx

@@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
 import { Button } from '@/components/ui/button'
 import { Separator } from '@/components/ui/separator'
+import { Switch } from '@/components/ui/switch'
 import { Textarea } from '@/components/ui/textarea'
 import { toast } from 'sonner'
 import { setSettings } from '@/features/settings/Actions/settingsActions'
@@ -87,6 +88,12 @@ export function InvoiceSettings({
   )
   const [dueDays, setDueDays] = useState(settings[SETTING_KEYS.INVOICE_DUE_DAYS] || '14')
   const [footerNote, setFooterNote] = useState(settings[SETTING_KEYS.INVOICE_FOOTER_NOTE] || '')
+  const [defaultMarkupPercent, setDefaultMarkupPercent] = useState(
+    settings[SETTING_KEYS.PARTS_DEFAULT_MARKUP_PERCENT] || '0'
+  )
+  const [markupAppliesToInventory, setMarkupAppliesToInventory] = useState(
+    settings[SETTING_KEYS.PARTS_MARKUP_APPLIES_TO_INVENTORY] === 'true'
+  )
 
   // Sections to hide from layout editor when features are disabled
   const hiddenLayoutSections = useMemo(() => {
@@ -121,6 +128,8 @@ export function InvoiceSettings({
       [SETTING_KEYS.INVOICE_START_NUMBER]: invoiceStartNumber,
       [SETTING_KEYS.INVOICE_DUE_DAYS]: dueDays,
       [SETTING_KEYS.INVOICE_FOOTER_NOTE]: footerNote,
+      [SETTING_KEYS.PARTS_DEFAULT_MARKUP_PERCENT]: defaultMarkupPercent,
+      [SETTING_KEYS.PARTS_MARKUP_APPLIES_TO_INVENTORY]: markupAppliesToInventory ? 'true' : 'false',
     })
     setSaving(false)
     router.refresh()
@@ -272,6 +281,53 @@ export function InvoiceSettings({
                 <p className="text-xs text-muted-foreground">{t('invoice.footerHint')}</p>
               </div>
 
+              <Separator />
+
+              <div className="space-y-3">
+                <div>
+                  <h3 className="text-sm font-semibold">{t('invoice.partsMarkupTitle')}</h3>
+                  <p className="text-xs text-muted-foreground">
+                    {t('invoice.partsMarkupDescription')}
+                  </p>
+                </div>
+                <div className="grid gap-4 sm:grid-cols-2">
+                  <div className="space-y-2">
+                    <Label htmlFor="defaultMarkupPercent">
+                      {t('invoice.defaultMarkupPercent')}
+                    </Label>
+                    <Input
+                      id="defaultMarkupPercent"
+                      type="number"
+                      min="0"
+                      step="0.1"
+                      placeholder="0"
+                      value={defaultMarkupPercent}
+                      onChange={(e) => setDefaultMarkupPercent(e.target.value)}
+                      className="w-32"
+                    />
+                    <p className="text-xs text-muted-foreground">
+                      {t('invoice.defaultMarkupPercentHint')}
+                    </p>
+                  </div>
+                  <div className="space-y-2">
+                    <Label
+                      htmlFor="markupAppliesToInventory"
+                      className="flex items-center justify-between gap-3"
+                    >
+                      <span>{t('invoice.markupAppliesToInventory')}</span>
+                      <Switch
+                        id="markupAppliesToInventory"
+                        checked={markupAppliesToInventory}
+                        onCheckedChange={setMarkupAppliesToInventory}
+                      />
+                    </Label>
+                    <p className="text-xs text-muted-foreground">
+                      {t('invoice.markupAppliesToInventoryHint')}
+                    </p>
+                  </div>
+                </div>
+              </div>
+
               <SaveButton>
                 <Separator />
                 <div className="flex items-center gap-3">

+ 6 - 0
src/app/(authenticated)/settings/subscription/page.tsx

@@ -21,6 +21,11 @@ export default async function SubscriptionPage() {
     ? (subscription.plan.name.toLowerCase() === "enterprise" ? "enterprise" : "pro")
     : "free";
 
+  // A demo is a trialing subscription not backed by Stripe (granted from the
+  // admin panel). It carries full plan features but expires at currentPeriodEnd.
+  const isDemo =
+    subscription?.status === "trialing" && !subscription?.stripeSubscriptionId;
+
   const features = PLAN_FEATURES[plan];
 
   const [customerCount, memberCount] = await Promise.all([
@@ -35,6 +40,7 @@ export default async function SubscriptionPage() {
   return (
     <SubscriptionSettings
       plan={plan}
+      isDemo={isDemo}
       status={subscription?.status ?? null}
       cancelAtPeriodEnd={subscription?.cancelAtPeriodEnd ?? false}
       currentPeriodEnd={subscription?.currentPeriodEnd?.toISOString() ?? null}

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

@@ -46,6 +46,8 @@ export default async function ServiceDetailPage({
       SETTING_KEYS.TAX_ENABLED,
       SETTING_KEYS.DEFAULT_LABOR_RATE,
       SETTING_KEYS.INVOICE_DUE_DAYS,
+      SETTING_KEYS.PARTS_DEFAULT_MARKUP_PERCENT,
+      SETTING_KEYS.PARTS_MARKUP_APPLIES_TO_INVENTORY,
     ]),
     getInventoryPartsList(),
     getTechnicians(),
@@ -78,6 +80,9 @@ export default async function ServiceDetailPage({
   const defaultTaxRate = taxEnabled ? Number(settings[SETTING_KEYS.DEFAULT_TAX_RATE]) || 0 : 0
   const defaultLaborRate = Number(settings[SETTING_KEYS.DEFAULT_LABOR_RATE]) || 0
   const defaultDueDays = Number(settings[SETTING_KEYS.INVOICE_DUE_DAYS]) || 0
+  const defaultMarkupPercent = Number(settings[SETTING_KEYS.PARTS_DEFAULT_MARKUP_PERCENT]) || 0
+  const markupAppliesToInventory =
+    settings[SETTING_KEYS.PARTS_MARKUP_APPLIES_TO_INVENTORY] === 'true'
   const inventoryParts = inventoryResult.success && inventoryResult.data ? inventoryResult.data : []
   const laborPresets = presetsResult.success && presetsResult.data ? presetsResult.data : []
   const initialVehicle = {
@@ -156,6 +161,11 @@ export default async function ServiceDetailPage({
       unitPrice: p.unitPrice,
       total: p.total,
       unitCost: p.unitCost ?? 0,
+      markupPercent: p.markupPercent ?? 0,
+      // Preserve the inventory link so editing/saving a work order keeps
+      // deducting the right stock. Without this, a saved edit sends unlinked
+      // parts and inventory reconciliation restocks the "removed" part.
+      inventoryPartId: p.inventoryPartId ?? undefined,
     })),
     laborItems: record.laborItems.map((l) => ({
       description: l.description,
@@ -244,6 +254,8 @@ export default async function ServiceDetailPage({
         telegramEnabled={features?.telegram ?? false}
         aiEnabled={aiEnabled}
         defaultDueDays={defaultDueDays}
+        defaultMarkupPercent={defaultMarkupPercent}
+        markupAppliesToInventory={markupAppliesToInventory}
         statusReports={(statusReportsResult.success && statusReportsResult.data
           ? statusReportsResult.data
           : []

+ 24 - 5
src/app/(public)/auth/sign-in/sign-in-form.tsx

@@ -23,6 +23,7 @@ function SignInFormInner({ registrationDisabled, demoMode = false }: { registrat
   const [error, setError] = useState('')
   const [loading, setLoading] = useState(false)
   const [passkeyLoading, setPasskeyLoading] = useState(false)
+  const passwordRef = useRef<HTMLInputElement>(null)
   const router = useRouter()
   const searchParams = useSearchParams()
 
@@ -39,6 +40,9 @@ function SignInFormInner({ registrationDisabled, demoMode = false }: { registrat
         } else {
           setError(result.error.message || t('errors.invalidCredentials'))
         }
+        // Clear password and refocus so the retry is one keystroke away
+        setPassword('')
+        passwordRef.current?.focus()
       } else {
         const redirect = searchParams.get('redirect') || '/'
         router.push(redirect)
@@ -112,9 +116,17 @@ function SignInFormInner({ registrationDisabled, demoMode = false }: { registrat
       </div>
 
       {error && (
-        <div className="mb-4 flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-sm text-destructive">
-          <XCircle className="h-4 w-4 shrink-0" />
-          <span>{error}</span>
+        <div className="mb-4 flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-sm text-destructive">
+          <XCircle className="mt-0.5 h-4 w-4 shrink-0" />
+          <div className="flex-1">
+            <p>{error}</p>
+            <p className="mt-1 text-xs">
+              {t('forgotPassword')}{' '}
+              <Link href="/auth/forgot-password" className="font-medium underline">
+                {t('resetPasswordCta')}
+              </Link>
+            </p>
+          </div>
         </div>
       )}
 
@@ -158,6 +170,7 @@ function SignInFormInner({ registrationDisabled, demoMode = false }: { registrat
         <div className="space-y-2">
           <Label htmlFor="password">{tc('form.password')}</Label>
           <Input
+            ref={passwordRef}
             id="password"
             type="password"
             placeholder={t('passwordPlaceholder')}
@@ -167,7 +180,10 @@ function SignInFormInner({ registrationDisabled, demoMode = false }: { registrat
             className="h-11 bg-background/50"
           />
           <div className="flex justify-end">
-            <Link href="/auth/forgot-password" className="text-xs text-muted-foreground hover:underline">
+            <Link
+              href="/auth/forgot-password"
+              className="text-xs text-muted-foreground hover:underline"
+            >
               {t('forgotPassword')}
             </Link>
           </div>
@@ -206,7 +222,10 @@ function SignInFormInner({ registrationDisabled, demoMode = false }: { registrat
       {!registrationDisabled && (
         <p className="mt-6 text-center text-sm text-muted-foreground">
           {t('noAccount')}{' '}
-          <Link href={`/auth/sign-up${searchParams.get('redirect') ? `?redirect=${encodeURIComponent(searchParams.get('redirect')!)}` : ''}`} className="font-medium text-primary hover:underline">
+          <Link
+            href={`/auth/sign-up${searchParams.get('redirect') ? `?redirect=${encodeURIComponent(searchParams.get('redirect')!)}` : ''}`}
+            className="font-medium text-primary hover:underline"
+          >
             {t('createOne')}
           </Link>
         </p>

+ 75 - 23
src/app/(public)/auth/sign-up/sign-up-form.tsx

@@ -1,6 +1,6 @@
 'use client'
 
-import { useState } from 'react'
+import { useRef, useState } from 'react'
 import { useRouter } from 'next/navigation'
 import Link from 'next/link'
 import { useTranslations } from 'next-intl'
@@ -12,7 +12,15 @@ import { Loader2, XCircle } from 'lucide-react'
 import { AuthLogo } from '@/components/auth-logo'
 import { acceptInvitation } from '@/features/team/Actions/acceptInvitation'
 
-export function SignUpForm({ inviteToken, emailVerificationRequired, redirectTo }: { inviteToken?: string; emailVerificationRequired?: boolean; redirectTo?: string }) {
+export function SignUpForm({
+  inviteToken,
+  emailVerificationRequired,
+  redirectTo,
+}: {
+  inviteToken?: string
+  emailVerificationRequired?: boolean
+  redirectTo?: string
+}) {
   const t = useTranslations('auth.signUp')
   const tc = useTranslations('common')
   const [name, setName] = useState('')
@@ -20,26 +28,47 @@ export function SignUpForm({ inviteToken, emailVerificationRequired, redirectTo
   const [password, setPassword] = useState('')
   const [termsAccepted, setTermsAccepted] = useState(false)
   const [error, setError] = useState('')
+  const [emailAlreadyExists, setEmailAlreadyExists] = useState(false)
+  const [showTermsError, setShowTermsError] = useState(false)
   const [loading, setLoading] = useState(false)
+  const termsRef = useRef<HTMLInputElement>(null)
   const router = useRouter()
 
   const handleSubmit = async (e: React.FormEvent) => {
     e.preventDefault()
+
+    // Validate terms before hitting the API — gives clear feedback instead of a silent disabled button
+    if (!termsAccepted) {
+      setError(t('errors.termsRequired'))
+      setShowTermsError(true)
+      termsRef.current?.focus()
+      termsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
+      return
+    }
+
     setLoading(true)
     setError('')
+    setEmailAlreadyExists(false)
+    setShowTermsError(false)
 
     try {
       const result = await signUp.email({ name, email, password })
       if (result.error) {
         const message = result.error.message || t('errors.couldNotCreate')
-        const isDisabled =
-          message.toLowerCase().includes('disabled') ||
-          message.toLowerCase().includes('failed to create')
-        setError(
-          isDisabled
-            ? t('errors.registrationDisabled')
-            : message,
-        )
+        const lower = message.toLowerCase()
+        const isDisabled = lower.includes('disabled') || lower.includes('failed to create')
+        const isDuplicate =
+          ('code' in result.error &&
+            typeof result.error.code === 'string' &&
+            result.error.code.toLowerCase().includes('exist')) ||
+          lower.includes('already') ||
+          lower.includes('exist')
+        if (isDuplicate) {
+          setEmailAlreadyExists(true)
+          setError(t('errors.emailExists'))
+        } else {
+          setError(isDisabled ? t('errors.registrationDisabled') : message)
+        }
       } else if (inviteToken) {
         const acceptResult = await acceptInvitation({ token: inviteToken })
         if (acceptResult.success) {
@@ -48,14 +77,18 @@ export function SignUpForm({ inviteToken, emailVerificationRequired, redirectTo
           router.refresh()
         } else {
           setError(acceptResult.error || t('errors.invitationFailed'))
-          router.push(redirectTo ? `/onboarding?redirect=${encodeURIComponent(redirectTo)}` : '/onboarding')
+          router.push(
+            redirectTo ? `/onboarding?redirect=${encodeURIComponent(redirectTo)}` : '/onboarding'
+          )
           router.refresh()
         }
       } else if (emailVerificationRequired) {
         router.push('/auth/verify-email')
         router.refresh()
       } else {
-        router.push(redirectTo ? `/onboarding?redirect=${encodeURIComponent(redirectTo)}` : '/onboarding')
+        router.push(
+          redirectTo ? `/onboarding?redirect=${encodeURIComponent(redirectTo)}` : '/onboarding'
+        )
         router.refresh()
       }
     } catch {
@@ -79,16 +112,23 @@ export function SignUpForm({ inviteToken, emailVerificationRequired, redirectTo
           </div>
           <h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
           <p className="mt-1 text-sm text-muted-foreground">
-            {inviteToken
-              ? t('descriptionInvite')
-              : t('descriptionDefault')}
+            {inviteToken ? t('descriptionInvite') : t('descriptionDefault')}
           </p>
         </div>
 
         {error && (
-          <div className="mb-4 flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-sm text-destructive">
-            <XCircle className="h-4 w-4 shrink-0" />
-            <span>{error}</span>
+          <div className="mb-4 flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-sm text-destructive">
+            <XCircle className="mt-0.5 h-4 w-4 shrink-0" />
+            <div className="flex-1">
+              <p>{error}</p>
+              {emailAlreadyExists && (
+                <p className="mt-1 text-xs">
+                  <Link href="/auth/sign-in" className="font-medium underline">
+                    {t('signInInstead')}
+                  </Link>
+                </p>
+              )}
+            </div>
           </div>
         )}
 
@@ -131,26 +171,38 @@ export function SignUpForm({ inviteToken, emailVerificationRequired, redirectTo
               minLength={8}
               className="h-11 bg-background/50"
             />
+            <p className="text-xs text-muted-foreground">{t('passwordHint')}</p>
           </div>
 
-          <div className="flex items-center gap-2">
+          <div
+            className={`flex items-center gap-2 rounded-md p-2 transition-colors ${
+              showTermsError ? 'bg-destructive/10 ring-1 ring-destructive/40' : ''
+            }`}
+          >
             <input
+              ref={termsRef}
               id="terms"
               type="checkbox"
               checked={termsAccepted}
-              onChange={(e) => setTermsAccepted(e.target.checked)}
-              required
+              onChange={(e) => {
+                setTermsAccepted(e.target.checked)
+                if (e.target.checked) setShowTermsError(false)
+              }}
               className="h-4 w-4 shrink-0 rounded border-border accent-primary"
             />
             <Label htmlFor="terms" className="text-sm font-normal text-muted-foreground">
               {t('agreeToTerms')}{' '}
-              <Link href="/terms" target="_blank" className="font-medium text-primary hover:underline">
+              <Link
+                href="/terms"
+                target="_blank"
+                className="font-medium text-primary hover:underline"
+              >
                 {tc('terms.termsOfService')}
               </Link>
             </Label>
           </div>
 
-          <Button type="submit" className="h-11 w-full" disabled={loading || !termsAccepted}>
+          <Button type="submit" className="h-11 w-full" disabled={loading}>
             {loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
             {inviteToken ? t('createAccountJoin') : t('createAccount')}
           </Button>

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

@@ -139,7 +139,7 @@ function isFieldVisible(config: InvoiceLayoutConfig | undefined, sectionId: stri
 }
 
 function getSectionOrder(config: InvoiceLayoutConfig | undefined): string[] {
-  if (!config) return ['header', 'customer', 'vehicle', 'service', 'parts_table', 'labor_table', 'findings', 'totals', 'general', 'notes', 'diagnostic_notes', 'bank_account', 'footer']
+  if (!config) return ['header', 'customer', 'vehicle', 'service', 'parts_table', 'labor_table', 'findings', 'totals', 'general', 'notes', 'bank_account', 'footer']
   return [...config.sections].sort((a, b) => a.order - b.order).map(s => s.id)
 }
 
@@ -1021,18 +1021,6 @@ export function InvoiceView({
                 </div>
               )
 
-            case 'diagnostic_notes':
-              if (!hasContent(record.diagnosticNotes)) return null
-              return (
-                <div key="diagnostic_notes" className="mt-4 rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
-                  <p className="mb-1 text-xs font-bold uppercase" style={{ color: primaryColor }}>{t('diagnosticNotes')}</p>
-                  <div
-                    className="notes-content text-sm text-gray-600 dark:text-gray-400"
-                    dangerouslySetInnerHTML={{ __html: sanitizeHtml(record.diagnosticNotes!) }}
-                  />
-                </div>
-              )
-
             case 'warranty': {
               if (!record.warrantyMonths && !record.warrantyNotes) return null
               const parts: string[] = []

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

@@ -82,7 +82,7 @@ function isSectionVisible(config: InvoiceLayoutConfig | undefined, sectionId: st
 }
 
 function getSectionOrder(config: InvoiceLayoutConfig | undefined): string[] {
-  if (!config) return ['header', 'customer', 'vehicle', 'service', 'parts_table', 'labor_table', 'totals', 'general', 'notes', 'diagnostic_notes', 'bank_account', 'footer'];
+  if (!config) return ['header', 'customer', 'vehicle', 'service', 'parts_table', 'labor_table', 'totals', 'general', 'notes', 'bank_account', 'footer'];
   return [...config.sections].sort((a, b) => a.order - b.order).map(s => s.id);
 }
 

+ 1 - 1
src/components/posthog-provider.tsx

@@ -22,7 +22,7 @@ export function PostHogProvider({
       posthog.init(posthogKey, {
         api_host: posthogHost || 'https://us.i.posthog.com',
         person_profiles: 'identified_only',
-        capture_pageview: true,
+        capture_pageview: 'history_change',
         capture_pageleave: true,
       })
     }

+ 90 - 0
src/features/admin/Actions/getOrganizationDetails.ts

@@ -0,0 +1,90 @@
+'use server'
+
+import { withSuperAdmin } from '@/lib/with-super-admin'
+import { db } from '@/lib/db'
+
+export async function getOrganizationDetails(organizationId: string) {
+  return withSuperAdmin(async () => {
+    const org = await db.organization.findUnique({
+      where: { id: organizationId },
+      select: {
+        id: true,
+        name: true,
+        createdAt: true,
+        subscription: {
+          select: {
+            status: true,
+            currentPeriodEnd: true,
+            cancelAtPeriodEnd: true,
+            plan: { select: { name: true } },
+          },
+        },
+        _count: {
+          select: {
+            members: true,
+            vehicles: true,
+            customers: true,
+            quotes: true,
+            inventoryParts: true,
+          },
+        },
+      },
+    })
+    if (!org) return null
+
+    // Service records and inspections are linked via Vehicle.organizationId
+    const [serviceRecordCount, inspectionCount, members] = await Promise.all([
+      db.serviceRecord.count({ where: { vehicle: { organizationId } } }),
+      db.inspection.count({ where: { vehicle: { organizationId } } }),
+      db.organizationMember.findMany({
+        where: { organizationId },
+        select: {
+          role: true,
+          user: {
+            select: { id: true, name: true, email: true, lastSeen: true },
+          },
+        },
+      }),
+    ])
+
+    const owner = members.find((m) => m.role === 'owner') ?? members[0] ?? null
+
+    return {
+      id: org.id,
+      name: org.name,
+      createdAt: org.createdAt.toISOString(),
+      subscription: org.subscription
+        ? {
+            status: org.subscription.status,
+            planName: org.subscription.plan.name,
+            currentPeriodEnd: org.subscription.currentPeriodEnd?.toISOString() ?? null,
+            cancelAtPeriodEnd: org.subscription.cancelAtPeriodEnd,
+          }
+        : null,
+      stats: {
+        members: org._count.members,
+        vehicles: org._count.vehicles,
+        customers: org._count.customers,
+        workOrders: serviceRecordCount,
+        quotes: org._count.quotes,
+        inspections: inspectionCount,
+        inventoryParts: org._count.inventoryParts,
+      },
+      owner: owner?.user
+        ? {
+            id: owner.user.id,
+            name: owner.user.name,
+            email: owner.user.email,
+            role: owner.role,
+          }
+        : null,
+      members: members.map((m) => ({
+        id: m.user.id,
+        name: m.user.name,
+        email: m.user.email,
+        role: m.role,
+        lastSeen: m.user.lastSeen?.toISOString() ?? null,
+      })),
+    }
+  })
+}

+ 63 - 0
src/features/admin/Actions/getUserDetails.ts

@@ -0,0 +1,63 @@
+'use server'
+
+import { withSuperAdmin } from '@/lib/with-super-admin'
+import { db } from '@/lib/db'
+
+export async function getUserDetails(userId: string) {
+  return withSuperAdmin(async () => {
+    const user = await db.user.findUnique({
+      where: { id: userId },
+      select: {
+        id: true,
+        name: true,
+        email: true,
+        emailVerified: true,
+        isSuperAdmin: true,
+        createdAt: true,
+        lastSeen: true,
+        lastLogin: true,
+        image: true,
+      },
+    })
+    if (!user) return null
+
+    const memberships = await db.organizationMember.findMany({
+      where: { userId },
+      select: {
+        role: true,
+        organization: {
+          select: {
+            id: true,
+            name: true,
+            createdAt: true,
+            _count: {
+              select: { members: true, vehicles: true },
+            },
+          },
+        },
+      },
+    })
+
+    return {
+      id: user.id,
+      name: user.name,
+      email: user.email,
+      emailVerified: user.emailVerified,
+      isSuperAdmin: user.isSuperAdmin,
+      image: user.image,
+      createdAt: user.createdAt.toISOString(),
+      lastSeen: user.lastSeen?.toISOString() ?? null,
+      lastLogin: user.lastLogin?.toISOString() ?? null,
+      organizations: memberships
+        .map((m) => ({
+          id: m.organization.id,
+          name: m.organization.name,
+          createdAt: m.organization.createdAt.toISOString(),
+          role: m.role,
+          memberCount: m.organization._count.members,
+          vehicleCount: m.organization._count.vehicles,
+        }))
+        .sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)),
+    }
+  })
+}

+ 30 - 17
src/features/admin/Actions/getUsers.ts

@@ -1,21 +1,34 @@
-"use server";
+'use server'
 
-import { withSuperAdmin } from "@/lib/with-super-admin";
-import { db } from "@/lib/db";
-import { adminSearchSchema } from "../Schema/adminSchema";
+import { withSuperAdmin } from '@/lib/with-super-admin'
+import { db } from '@/lib/db'
+import { adminSearchSchema } from '../Schema/adminSchema'
 
-export async function getUsers(input?: { search?: string; page?: number; pageSize?: number }) {
+export async function getUsers(input?: {
+  search?: string
+  page?: number
+  pageSize?: number
+  sortBy?: string
+  sortOrder?: string
+}) {
   return withSuperAdmin(async () => {
-    const { search, page, pageSize } = adminSearchSchema.parse(input ?? {});
+    const { search, page, pageSize, sortBy, sortOrder } = adminSearchSchema.parse(input ?? {})
 
     const where = search
       ? {
           OR: [
-            { name: { contains: search, mode: "insensitive" as const } },
-            { email: { contains: search, mode: "insensitive" as const } },
+            { name: { contains: search, mode: 'insensitive' as const } },
+            { email: { contains: search, mode: 'insensitive' as const } },
           ],
         }
-      : {};
+      : {}
+
+    // For sortable nullable date columns, place nulls last in desc order so users
+    // who have actually signed in are surfaced before never-signed-in accounts.
+    const orderBy =
+      sortBy === 'lastSeen'
+        ? { lastSeen: { sort: sortOrder, nulls: 'last' as const } }
+        : { [sortBy]: sortOrder }
 
     const [users, total] = await Promise.all([
       db.user.findMany({
@@ -29,21 +42,21 @@ export async function getUsers(input?: { search?: string; page?: number; pageSiz
           lastSeen: true,
           emailVerified: true,
         },
-        orderBy: { createdAt: "desc" },
+        orderBy,
         skip: (page - 1) * pageSize,
         take: pageSize,
       }),
       db.user.count({ where }),
-    ]);
+    ])
 
-    const userIds = users.map((u) => u.id);
+    const userIds = users.map((u) => u.id)
     const membershipCounts = await db.organizationMember.groupBy({
-      by: ["userId"],
+      by: ['userId'],
       where: { userId: { in: userIds } },
       _count: true,
-    });
+    })
 
-    const countMap = new Map(membershipCounts.map((m) => [m.userId, m._count]));
+    const countMap = new Map(membershipCounts.map((m) => [m.userId, m._count]))
 
     return {
       users: users.map((u) => ({
@@ -60,6 +73,6 @@ export async function getUsers(input?: { search?: string; page?: number; pageSiz
       page,
       pageSize,
       totalPages: Math.ceil(total / pageSize),
-    };
-  });
+    }
+  })
 }

+ 205 - 0
src/features/admin/Components/admin-organization-details.tsx

@@ -0,0 +1,205 @@
+'use client'
+
+import Link from 'next/link'
+import { useTranslations } from 'next-intl'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import {
+  ArrowLeft,
+  Boxes,
+  Building2,
+  Car,
+  ClipboardCheck,
+  CreditCard,
+  FileText,
+  Users,
+  Wrench,
+} from 'lucide-react'
+import { useFormatDate } from '@/lib/use-format-date'
+
+type OrgDetails = {
+  id: string
+  name: string
+  createdAt: string
+  subscription: {
+    status: string
+    planName: string
+    currentPeriodEnd: string | null
+    cancelAtPeriodEnd: boolean
+  } | null
+  stats: {
+    members: number
+    vehicles: number
+    customers: number
+    workOrders: number
+    quotes: number
+    inspections: number
+    inventoryParts: number
+  }
+  owner: {
+    id: string
+    name: string
+    email: string
+    role: string
+  } | null
+  members: Array<{
+    id: string
+    name: string
+    email: string
+    role: string
+    lastSeen: string | null
+  }>
+}
+
+function subscriptionVariant(status: string) {
+  switch (status) {
+    case 'active':
+      return 'default' as const
+    case 'trialing':
+      return 'secondary' as const
+    case 'past_due':
+      return 'destructive' as const
+    case 'canceled':
+      return 'outline' as const
+    default:
+      return 'secondary' as const
+  }
+}
+
+export function AdminOrganizationDetails({ org }: { org: OrgDetails }) {
+  const t = useTranslations('admin')
+  const { formatDate, formatDateTime } = useFormatDate()
+
+  const statCards = [
+    { key: 'vehicles', label: t('orgDetails.vehicles'), value: org.stats.vehicles, icon: Car },
+    {
+      key: 'workOrders',
+      label: t('orgDetails.workOrders'),
+      value: org.stats.workOrders,
+      icon: Wrench,
+    },
+    { key: 'customers', label: t('orgDetails.customers'), value: org.stats.customers, icon: Users },
+    { key: 'quotes', label: t('orgDetails.quotes'), value: org.stats.quotes, icon: FileText },
+    {
+      key: 'inspections',
+      label: t('orgDetails.inspections'),
+      value: org.stats.inspections,
+      icon: ClipboardCheck,
+    },
+    {
+      key: 'inventoryParts',
+      label: t('orgDetails.inventoryParts'),
+      value: org.stats.inventoryParts,
+      icon: Boxes,
+    },
+  ]
+
+  return (
+    <div className="space-y-6">
+      <Link
+        href="/admin/users"
+        className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
+      >
+        <ArrowLeft className="h-4 w-4" />
+        {t('orgDetails.back')}
+      </Link>
+
+      <Card>
+        <CardHeader>
+          <CardTitle className="flex items-center gap-2">
+            <Building2 className="h-5 w-5 text-muted-foreground" />
+            {org.name}
+          </CardTitle>
+        </CardHeader>
+        <CardContent className="grid gap-3 text-sm sm:grid-cols-2">
+          <div>
+            <p className="text-muted-foreground">{t('users.created')}</p>
+            <p className="font-medium">{formatDate(org.createdAt)}</p>
+          </div>
+          <div>
+            <p className="text-muted-foreground">{t('orgDetails.owner')}</p>
+            {org.owner ? (
+              <Link href={`/admin/users/${org.owner.id}`} className="font-medium hover:underline">
+                {org.owner.name} <span className="text-muted-foreground">({org.owner.email})</span>
+              </Link>
+            ) : (
+              <p className="text-muted-foreground">{t('orgDetails.noOwner')}</p>
+            )}
+          </div>
+          <div className="sm:col-span-2">
+            <p className="mb-1 text-muted-foreground">{t('orgDetails.subscription')}</p>
+            {org.subscription ? (
+              <div className="flex flex-wrap items-center gap-2">
+                <Badge variant={subscriptionVariant(org.subscription.status)}>
+                  {org.subscription.status}
+                </Badge>
+                <span className="text-sm font-medium">{org.subscription.planName}</span>
+                {org.subscription.currentPeriodEnd && (
+                  <span className="text-xs text-muted-foreground">
+                    {org.subscription.cancelAtPeriodEnd
+                      ? t('orgDetails.cancelsAt')
+                      : t('orgDetails.renews')}
+                    : {formatDate(org.subscription.currentPeriodEnd)}
+                  </span>
+                )}
+              </div>
+            ) : (
+              <p className="flex items-center gap-1.5 text-sm text-muted-foreground">
+                <CreditCard className="h-3.5 w-3.5" />
+                {t('orgDetails.noSubscription')}
+              </p>
+            )}
+          </div>
+        </CardContent>
+      </Card>
+
+      <div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-6">
+        {statCards.map((s) => (
+          <Card key={s.key}>
+            <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+              <CardTitle className="text-xs font-medium text-muted-foreground">{s.label}</CardTitle>
+              <s.icon className="h-4 w-4 text-muted-foreground" />
+            </CardHeader>
+            <CardContent>
+              <div className="text-2xl font-bold">{s.value}</div>
+            </CardContent>
+          </Card>
+        ))}
+      </div>
+
+      <div>
+        <h2 className="mb-3 flex items-center gap-2 text-lg font-semibold">
+          <Users className="h-4 w-4 text-muted-foreground" />
+          {t('orgDetails.members')}{' '}
+          <span className="text-sm font-normal text-muted-foreground">({org.stats.members})</span>
+        </h2>
+        <Card>
+          <CardContent className="divide-y p-0">
+            {org.members.map((m) => (
+              <Link
+                key={m.id}
+                href={`/admin/users/${m.id}`}
+                className="flex items-center justify-between gap-4 p-4 transition-colors hover:bg-muted/50"
+              >
+                <div>
+                  <p className="font-medium">{m.name}</p>
+                  <p className="text-xs text-muted-foreground">{m.email}</p>
+                </div>
+                <div className="flex items-center gap-2">
+                  {m.lastSeen && (
+                    <span className="hidden text-xs text-muted-foreground sm:inline">
+                      {formatDateTime(m.lastSeen)}
+                    </span>
+                  )}
+                  <Badge variant="outline" className="text-xs">
+                    {m.role}
+                  </Badge>
+                </div>
+              </Link>
+            ))}
+          </CardContent>
+        </Card>
+      </div>
+    </div>
+  )
+}

+ 163 - 0
src/features/admin/Components/admin-user-details.tsx

@@ -0,0 +1,163 @@
+'use client'
+
+import Link from 'next/link'
+import { useTranslations } from 'next-intl'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import {
+  ArrowLeft,
+  BadgeCheck,
+  Building2,
+  Car,
+  ChevronRight,
+  ShieldCheck,
+  Users,
+} from 'lucide-react'
+import { useFormatDate } from '@/lib/use-format-date'
+
+type Organization = {
+  id: string
+  name: string
+  createdAt: string
+  role: string
+  memberCount: number
+  vehicleCount: number
+}
+
+type UserDetails = {
+  id: string
+  name: string
+  email: string
+  emailVerified: boolean
+  isSuperAdmin: boolean
+  image: string | null
+  createdAt: string
+  lastSeen: string | null
+  lastLogin: string | null
+  organizations: Organization[]
+}
+
+function isOnline(lastSeen: string | null) {
+  if (!lastSeen) return false
+  return Date.now() - new Date(lastSeen).getTime() < 5 * 60 * 1000
+}
+
+export function AdminUserDetails({ user }: { user: UserDetails }) {
+  const t = useTranslations('admin')
+  const { formatDate, formatDateTime } = useFormatDate()
+  const online = isOnline(user.lastSeen)
+
+  return (
+    <div className="space-y-6">
+      <Link
+        href="/admin/users"
+        className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
+      >
+        <ArrowLeft className="h-4 w-4" />
+        {t('userDetails.back')}
+      </Link>
+
+      <Card>
+        <CardHeader>
+          <div className="flex items-center justify-between">
+            <CardTitle className="flex items-center gap-2">
+              {user.name}
+              {user.emailVerified && (
+                <span title={t('users.emailVerified')}>
+                  <BadgeCheck className="h-4 w-4 text-blue-500" />
+                </span>
+              )}
+              {online && (
+                <span className="relative flex h-2 w-2" title={t('users.online')}>
+                  <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75" />
+                  <span className="relative inline-flex h-2 w-2 rounded-full bg-green-500" />
+                </span>
+              )}
+            </CardTitle>
+            {user.isSuperAdmin ? (
+              <Badge variant="default" className="gap-1">
+                <ShieldCheck className="h-3 w-3" />
+                {t('users.superAdmin')}
+              </Badge>
+            ) : (
+              <Badge variant="secondary">{t('users.user')}</Badge>
+            )}
+          </div>
+        </CardHeader>
+        <CardContent className="grid gap-3 text-sm sm:grid-cols-2">
+          <div>
+            <p className="text-muted-foreground">{t('users.email')}</p>
+            <p className="font-medium">{user.email}</p>
+          </div>
+          <div>
+            <p className="text-muted-foreground">{t('users.created')}</p>
+            <p className="font-medium">{formatDate(user.createdAt)}</p>
+          </div>
+          <div>
+            <p className="text-muted-foreground">{t('users.lastSeen')}</p>
+            <p className="font-medium">
+              {user.lastSeen ? formatDateTime(user.lastSeen) : t('userDetails.never')}
+            </p>
+          </div>
+          <div>
+            <p className="text-muted-foreground">{t('userDetails.lastLogin')}</p>
+            <p className="font-medium">
+              {user.lastLogin ? formatDateTime(user.lastLogin) : t('userDetails.never')}
+            </p>
+          </div>
+        </CardContent>
+      </Card>
+
+      <div>
+        <h2 className="mb-3 text-lg font-semibold">{t('userDetails.organizations')}</h2>
+        {user.organizations.length === 0 ? (
+          <Card>
+            <CardContent className="py-8 text-center text-sm text-muted-foreground">
+              {t('userDetails.noOrganizations')}
+            </CardContent>
+          </Card>
+        ) : (
+          <div className="grid gap-3 sm:grid-cols-2">
+            {user.organizations.map((org) => (
+              <Link key={org.id} href={`/admin/organizations/${org.id}`}>
+                <Card className="h-full cursor-pointer transition-colors hover:bg-muted/50">
+                  <CardHeader className="pb-3">
+                    <div className="flex items-start justify-between gap-2">
+                      <CardTitle className="flex items-center gap-2 text-base">
+                        <Building2 className="h-4 w-4 text-muted-foreground" />
+                        {org.name}
+                      </CardTitle>
+                      <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
+                    </div>
+                  </CardHeader>
+                  <CardContent className="space-y-2 text-sm">
+                    <div className="flex items-center justify-between">
+                      <span className="text-muted-foreground">{t('userDetails.role')}</span>
+                      <Badge variant="outline" className="text-xs">
+                        {org.role}
+                      </Badge>
+                    </div>
+                    <div className="flex items-center justify-between">
+                      <span className="flex items-center gap-1.5 text-muted-foreground">
+                        <Users className="h-3.5 w-3.5" />
+                        {t('orgDetails.members')}
+                      </span>
+                      <span className="font-medium">{org.memberCount}</span>
+                    </div>
+                    <div className="flex items-center justify-between">
+                      <span className="flex items-center gap-1.5 text-muted-foreground">
+                        <Car className="h-3.5 w-3.5" />
+                        {t('orgDetails.vehicles')}
+                      </span>
+                      <span className="font-medium">{org.vehicleCount}</span>
+                    </div>
+                  </CardContent>
+                </Card>
+              </Link>
+            ))}
+          </div>
+        )}
+      </div>
+    </div>
+  )
+}

+ 200 - 107
src/features/admin/Components/admin-users.tsx

@@ -1,13 +1,13 @@
-"use client";
+'use client'
 
-import { useState, useCallback, useTransition } from "react";
-import { useRouter, usePathname, useSearchParams } from "next/navigation";
-import { useTranslations } from "next-intl";
-import { toast } from "sonner";
-import { useConfirm } from "@/components/confirm-dialog";
-import { Input } from "@/components/ui/input";
-import { Button } from "@/components/ui/button";
-import { Badge } from "@/components/ui/badge";
+import { useState, useCallback, useTransition } from 'react'
+import { useRouter, usePathname, useSearchParams } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { toast } from 'sonner'
+import { useConfirm } from '@/components/confirm-dialog'
+import { Input } from '@/components/ui/input'
+import { Button } from '@/components/ui/button'
+import { Badge } from '@/components/ui/badge'
 import {
   Table,
   TableBody,
@@ -15,140 +15,175 @@ import {
   TableHead,
   TableHeader,
   TableRow,
-} from "@/components/ui/table";
+} from '@/components/ui/table'
 import {
   DropdownMenu,
   DropdownMenuContent,
   DropdownMenuItem,
   DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { DataTablePagination } from "@/components/data-table-pagination";
-import { BadgeCheck, Loader2, MoreHorizontal, Search, ShieldCheck, ShieldOff, Trash2 } from "lucide-react";
-import { useFormatDate } from "@/lib/use-format-date";
-import { toggleSuperAdmin } from "../Actions/toggleSuperAdmin";
-import { deleteUser } from "../Actions/deleteUser";
+} from '@/components/ui/dropdown-menu'
+import { DataTablePagination } from '@/components/data-table-pagination'
+import {
+  ArrowDown,
+  ArrowUp,
+  ArrowUpDown,
+  BadgeCheck,
+  Loader2,
+  MoreHorizontal,
+  Search,
+  ShieldCheck,
+  ShieldOff,
+  Trash2,
+} from 'lucide-react'
+import { useFormatDate } from '@/lib/use-format-date'
+import { toggleSuperAdmin } from '../Actions/toggleSuperAdmin'
+import { deleteUser } from '../Actions/deleteUser'
 
 type UserRow = {
-  id: string;
-  name: string;
-  email: string;
-  isSuperAdmin: boolean;
-  emailVerified: boolean;
-  createdAt: string;
-  lastSeen: string | null;
-  organizationCount: number;
-};
+  id: string
+  name: string
+  email: string
+  isSuperAdmin: boolean
+  emailVerified: boolean
+  createdAt: string
+  lastSeen: string | null
+  organizationCount: number
+}
 
 function OnlineDot({ lastSeen }: { lastSeen: string | null }) {
-  if (!lastSeen) return null;
-  const isOnline = Date.now() - new Date(lastSeen).getTime() < 5 * 60 * 1000;
-  if (!isOnline) return null;
+  if (!lastSeen) return null
+  const isOnline = Date.now() - new Date(lastSeen).getTime() < 5 * 60 * 1000
+  if (!isOnline) return null
   return (
     <span className="relative flex h-2 w-2" title="Online">
       <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75" />
       <span className="relative inline-flex h-2 w-2 rounded-full bg-green-500" />
     </span>
-  );
+  )
 }
 
 type PaginatedData = {
-  users: UserRow[];
-  total: number;
-  page: number;
-  pageSize: number;
-  totalPages: number;
-};
+  users: UserRow[]
+  total: number
+  page: number
+  pageSize: number
+  totalPages: number
+}
 
 export function AdminUsers({
   data,
   search,
+  sortBy,
+  sortOrder,
 }: {
-  data: PaginatedData;
-  search: string;
+  data: PaginatedData
+  search: string
+  sortBy: string
+  sortOrder: 'asc' | 'desc'
 }) {
-  const t = useTranslations("admin");
-  const router = useRouter();
-  const pathname = usePathname();
-  const searchParams = useSearchParams();
-  const confirm = useConfirm();
-  const { formatDate, formatDateTime } = useFormatDate();
-  const [isPending, startTransition] = useTransition();
-  const [searchInput, setSearchInput] = useState(search);
+  const t = useTranslations('admin')
+  const router = useRouter()
+  const pathname = usePathname()
+  const searchParams = useSearchParams()
+  const confirm = useConfirm()
+  const { formatDate, formatDateTime } = useFormatDate()
+  const [isPending, startTransition] = useTransition()
+  const [searchInput, setSearchInput] = useState(search)
 
   const navigate = useCallback(
     (params: Record<string, string | number | undefined>) => {
-      const newParams = new URLSearchParams(searchParams.toString());
+      const newParams = new URLSearchParams(searchParams.toString())
       for (const [key, value] of Object.entries(params)) {
-        if (value === undefined || value === "") {
-          newParams.delete(key);
+        if (value === undefined || value === '') {
+          newParams.delete(key)
         } else {
-          newParams.set(key, String(value));
+          newParams.set(key, String(value))
         }
       }
-      if (!("page" in params) && "search" in params) {
-        newParams.delete("page");
+      if (!('page' in params) && 'search' in params) {
+        newParams.delete('page')
       }
       startTransition(() => {
-        router.push(`${pathname}?${newParams.toString()}`);
-      });
+        router.push(`${pathname}?${newParams.toString()}`)
+      })
     },
-    [router, pathname, searchParams],
-  );
+    [router, pathname, searchParams]
+  )
 
   const handleSearch = useCallback(
     (e: React.FormEvent) => {
-      e.preventDefault();
-      navigate({ search: searchInput || undefined });
+      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, page: undefined })
     },
-    [navigate, searchInput],
-  );
+    [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 handleToggleSuperAdmin = async (user: UserRow) => {
-    const action = user.isSuperAdmin ? "demote" : "promote";
+    const action = user.isSuperAdmin ? 'demote' : 'promote'
     const confirmed = await confirm({
-      title: action === "promote" ? t("users.promoteTitle") : t("users.demoteTitle"),
-      description: action === "promote" ? t("users.promoteConfirm", { name: user.name }) : t("users.demoteConfirm", { name: user.name }),
-      confirmLabel: action === "promote" ? t("users.promote") : t("users.demote"),
-      destructive: action === "demote",
-    });
+      title: action === 'promote' ? t('users.promoteTitle') : t('users.demoteTitle'),
+      description:
+        action === 'promote'
+          ? t('users.promoteConfirm', { name: user.name })
+          : t('users.demoteConfirm', { name: user.name }),
+      confirmLabel: action === 'promote' ? t('users.promote') : t('users.demote'),
+      destructive: action === 'demote',
+    })
 
-    if (!confirmed) return;
+    if (!confirmed) return
 
     startTransition(async () => {
       const result = await toggleSuperAdmin({
         userId: user.id,
         isSuperAdmin: !user.isSuperAdmin,
-      });
+      })
 
       if (result.success) {
-        toast.success(action === "promote" ? t("users.promotedSuccess") : t("users.demotedSuccess"));
-        router.refresh();
+        toast.success(action === 'promote' ? t('users.promotedSuccess') : t('users.demotedSuccess'))
+        router.refresh()
       } else {
-        toast.error(result.error ?? t("users.failedUpdate"));
+        toast.error(result.error ?? t('users.failedUpdate'))
       }
-    });
-  };
+    })
+  }
 
   const handleDeleteUser = async (user: UserRow) => {
     const confirmed = await confirm({
-      title: t("users.deleteTitle"),
-      description: t("users.deleteConfirm", { name: user.name, email: user.email }),
-      confirmLabel: t("users.delete"),
+      title: t('users.deleteTitle'),
+      description: t('users.deleteConfirm', { name: user.name, email: user.email }),
+      confirmLabel: t('users.delete'),
       destructive: true,
-    });
+    })
 
-    if (!confirmed) return;
+    if (!confirmed) return
 
     startTransition(async () => {
-      const result = await deleteUser({ userId: user.id });
+      const result = await deleteUser({ userId: user.id })
       if (result.success) {
-        toast.success(t("users.deletedSuccess"));
-        router.refresh();
+        toast.success(t('users.deletedSuccess'))
+        router.refresh()
       } else {
-        toast.error(result.error ?? t("users.failedDelete"));
+        toast.error(result.error ?? t('users.failedDelete'))
       }
-    });
-  };
+    })
+  }
 
   return (
     <div className="space-y-4">
@@ -156,7 +191,7 @@ export function AdminUsers({
         <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("users.searchPlaceholder")}
+            placeholder={t('users.searchPlaceholder')}
             value={searchInput}
             onChange={(e) => setSearchInput(e.target.value)}
             className="pl-9"
@@ -169,12 +204,57 @@ export function AdminUsers({
         <Table>
           <TableHeader>
             <TableRow>
-              <TableHead>{t("users.name")}</TableHead>
-              <TableHead>{t("users.email")}</TableHead>
-              <TableHead>{t("users.role")}</TableHead>
-              <TableHead className="text-center">{t("users.orgs")}</TableHead>
-              <TableHead>{t("users.created")}</TableHead>
-              <TableHead>{t("users.lastSeen")}</TableHead>
+              <TableHead>
+                <button
+                  type="button"
+                  className="flex items-center hover:text-foreground"
+                  onClick={() => handleSort('name')}
+                >
+                  {t('users.name')}
+                  <SortIcon column="name" />
+                </button>
+              </TableHead>
+              <TableHead>
+                <button
+                  type="button"
+                  className="flex items-center hover:text-foreground"
+                  onClick={() => handleSort('email')}
+                >
+                  {t('users.email')}
+                  <SortIcon column="email" />
+                </button>
+              </TableHead>
+              <TableHead>
+                <button
+                  type="button"
+                  className="flex items-center hover:text-foreground"
+                  onClick={() => handleSort('isSuperAdmin')}
+                >
+                  {t('users.role')}
+                  <SortIcon column="isSuperAdmin" />
+                </button>
+              </TableHead>
+              <TableHead className="text-center">{t('users.orgs')}</TableHead>
+              <TableHead>
+                <button
+                  type="button"
+                  className="flex items-center hover:text-foreground"
+                  onClick={() => handleSort('createdAt')}
+                >
+                  {t('users.created')}
+                  <SortIcon column="createdAt" />
+                </button>
+              </TableHead>
+              <TableHead>
+                <button
+                  type="button"
+                  className="flex items-center hover:text-foreground"
+                  onClick={() => handleSort('lastSeen')}
+                >
+                  {t('users.lastSeen')}
+                  <SortIcon column="lastSeen" />
+                </button>
+              </TableHead>
               <TableHead className="w-10" />
             </TableRow>
           </TableHeader>
@@ -182,18 +262,24 @@ export function AdminUsers({
             {data.users.length === 0 ? (
               <TableRow>
                 <TableCell colSpan={7} className="h-32 text-center text-muted-foreground">
-                  {search ? t("users.noResults") : t("users.noUsers")}
+                  {search ? t('users.noResults') : t('users.noUsers')}
                 </TableCell>
               </TableRow>
             ) : (
               data.users.map((user) => (
-                <TableRow key={user.id}>
+                <TableRow
+                  key={user.id}
+                  className="cursor-pointer"
+                  onClick={() => router.push(`/admin/users/${user.id}`)}
+                >
                   <TableCell className="font-medium">{user.name}</TableCell>
                   <TableCell>
                     <span className="flex items-center gap-1.5">
                       {user.email}
                       {user.emailVerified && (
-                        <span title={t("users.emailVerified")}><BadgeCheck className="h-4 w-4 text-blue-500" /></span>
+                        <span title={t('users.emailVerified')}>
+                          <BadgeCheck className="h-4 w-4 text-blue-500" />
+                        </span>
                       )}
                     </span>
                   </TableCell>
@@ -201,32 +287,39 @@ export function AdminUsers({
                     {user.isSuperAdmin ? (
                       <Badge variant="default" className="gap-1">
                         <ShieldCheck className="h-3 w-3" />
-                        {t("users.superAdmin")}
+                        {t('users.superAdmin')}
                       </Badge>
                     ) : (
-                      <Badge variant="secondary">{t("users.user")}</Badge>
+                      <Badge variant="secondary">{t('users.user')}</Badge>
                     )}
                   </TableCell>
                   <TableCell className="text-center">{user.organizationCount}</TableCell>
+                  <TableCell>{formatDate(user.createdAt)}</TableCell>
                   <TableCell>
-                    {formatDate(user.createdAt)}
-                  </TableCell>
-                  <TableCell>
-                    {user.lastSeen && Date.now() - new Date(user.lastSeen).getTime() < 5 * 60 * 1000 ? (
+                    {user.lastSeen &&
+                    Date.now() - new Date(user.lastSeen).getTime() < 5 * 60 * 1000 ? (
                       <span className="flex items-center gap-1.5">
                         <OnlineDot lastSeen={user.lastSeen} />
-                        <span className="text-sm text-green-600 dark:text-green-400">{t("users.online")}</span>
+                        <span className="text-sm text-green-600 dark:text-green-400">
+                          {t('users.online')}
+                        </span>
                       </span>
                     ) : (
                       <span className="text-sm text-muted-foreground">
-                        {user.lastSeen ? formatDateTime(user.lastSeen) : "—"}
+                        {user.lastSeen ? formatDateTime(user.lastSeen) : '—'}
                       </span>
                     )}
                   </TableCell>
-                  <TableCell>
+                  <TableCell onClick={(e) => e.stopPropagation()}>
                     <DropdownMenu>
                       <DropdownMenuTrigger asChild>
-                        <Button variant="ghost" size="icon" className="h-8 w-8" disabled={isPending} aria-label={t("common.openMenu")}>
+                        <Button
+                          variant="ghost"
+                          size="icon"
+                          className="h-8 w-8"
+                          disabled={isPending}
+                          aria-label={t('common.openMenu')}
+                        >
                           <MoreHorizontal className="h-4 w-4" />
                         </Button>
                       </DropdownMenuTrigger>
@@ -235,12 +328,12 @@ export function AdminUsers({
                           {user.isSuperAdmin ? (
                             <>
                               <ShieldOff className="mr-2 h-4 w-4" />
-                              {t("users.demoteFromSuperAdmin")}
+                              {t('users.demoteFromSuperAdmin')}
                             </>
                           ) : (
                             <>
                               <ShieldCheck className="mr-2 h-4 w-4" />
-                              {t("users.promoteToSuperAdmin")}
+                              {t('users.promoteToSuperAdmin')}
                             </>
                           )}
                         </DropdownMenuItem>
@@ -249,7 +342,7 @@ export function AdminUsers({
                           className="text-destructive focus:text-destructive"
                         >
                           <Trash2 className="mr-2 h-4 w-4" />
-                          {t("users.deleteUser")}
+                          {t('users.deleteUser')}
                         </DropdownMenuItem>
                       </DropdownMenuContent>
                     </DropdownMenu>
@@ -269,5 +362,5 @@ export function AdminUsers({
         onNavigate={navigate}
       />
     </div>
-  );
+  )
 }

+ 17 - 12
src/features/admin/Schema/adminSchema.ts

@@ -1,25 +1,30 @@
-import { z } from "zod";
+import { z } from 'zod'
 
 export const toggleSuperAdminSchema = z.object({
-  userId: z.string().min(1, "User ID is required"),
+  userId: z.string().min(1, 'User ID is required'),
   isSuperAdmin: z.boolean(),
-});
+})
 
 export const deleteUserSchema = z.object({
-  userId: z.string().min(1, "User ID is required"),
-});
+  userId: z.string().min(1, 'User ID is required'),
+})
 
 export const deleteOrganizationSchema = z.object({
-  organizationId: z.string().min(1, "Organization ID is required"),
-});
+  organizationId: z.string().min(1, 'Organization ID is required'),
+})
+
+export const userSortFields = ['name', 'email', 'createdAt', 'lastSeen', 'isSuperAdmin'] as const
+export type UserSortField = (typeof userSortFields)[number]
 
 export const adminSearchSchema = z.object({
   search: z.string().optional(),
   page: z.number().int().min(1).default(1),
   pageSize: z.number().int().min(1).max(100).default(20),
-});
+  sortBy: z.enum(userSortFields).default('lastSeen'),
+  sortOrder: z.enum(['asc', 'desc']).default('desc'),
+})
 
-export type ToggleSuperAdminInput = z.infer<typeof toggleSuperAdminSchema>;
-export type DeleteUserInput = z.infer<typeof deleteUserSchema>;
-export type DeleteOrganizationInput = z.infer<typeof deleteOrganizationSchema>;
-export type AdminSearchInput = z.infer<typeof adminSearchSchema>;
+export type ToggleSuperAdminInput = z.infer<typeof toggleSuperAdminSchema>
+export type DeleteUserInput = z.infer<typeof deleteUserSchema>
+export type DeleteOrganizationInput = z.infer<typeof deleteOrganizationSchema>
+export type AdminSearchInput = z.infer<typeof adminSearchSchema>

+ 1 - 1
src/features/email/Components/SendEmailDialog.tsx

@@ -51,7 +51,7 @@ export function SendEmailDialog({
 
   return (
     <Dialog open={open} onOpenChange={onOpenChange}>
-      <DialogContent className="max-w-md">
+      <DialogContent className="max-w-md" aria-describedby={undefined}>
         <DialogHeader>
           <DialogTitle className="flex items-center gap-2">
             <Mail className="h-5 w-5" />

+ 4 - 1
src/features/inventory/Actions/inventoryActions.ts

@@ -302,8 +302,11 @@ export async function getInventoryCategories() {
 
 export async function getInventoryPartsList() {
   return withAuth(async ({ userId, organizationId }) => {
+    // Include out-of-stock and backordered (negative) parts too — the picker
+    // shows their stock state so a part can still be added to a work order
+    // when depleted (which then correctly drives stock negative / backorder).
     return db.inventoryPart.findMany({
-      where: { organizationId, isArchived: false, quantity: { gt: 0 } },
+      where: { organizationId, isArchived: false },
       select: {
         id: true,
         partNumber: true,

+ 80 - 0
src/features/inventory/Lib/reconcileStock.ts

@@ -0,0 +1,80 @@
+import type { Prisma } from "@/generated/prisma/client";
+
+/**
+ * A part line that may be linked to an inventory item. Only lines with a
+ * non-null `inventoryPartId` move stock; free-text parts are ignored.
+ */
+export interface StockPartLine {
+  inventoryPartId?: string | null;
+  quantity: number;
+}
+
+/**
+ * Convert a (possibly fractional) part quantity into whole stock units.
+ *
+ * `InventoryPart.quantity` is an integer column while `ServicePart.quantity`
+ * is a float, so we round to the nearest whole unit. Rounding is applied
+ * identically to the previous and next sets, so any delta stays internally
+ * consistent across create / edit / delete.
+ */
+function stockUnits(quantity: number): number {
+  if (!Number.isFinite(quantity) || quantity <= 0) return 0;
+  return Math.round(quantity);
+}
+
+/**
+ * Apply the net inventory movement for a service record whose linked parts
+ * change from `previous` to `next`.
+ *
+ * Semantics:
+ *   - A part present in `next` but not `previous` (or with a larger quantity)
+ *     consumes stock → decrement.
+ *   - A part removed or reduced restocks → increment.
+ *   - Unchanged quantities produce a zero delta and touch nothing.
+ *
+ * The movement is computed per `inventoryPartId` as the sum of `next`
+ * quantities minus the sum of `previous` quantities, then applied with a
+ * single atomic `{ decrement }` per part (a negative delta increments, i.e.
+ * restocks). Because it is atomic and never reads-then-writes, concurrent
+ * deploys/requests cannot lose updates.
+ *
+ * Every write is scoped to `organizationId`, so a stale or cross-organization
+ * `inventoryPartId` simply matches no rows and is skipped — it can never
+ * mutate another tenant's inventory.
+ *
+ * Stock is allowed to go negative: over-consuming is real information
+ * ("you used more than you had on hand") and, crucially, keeps the operation
+ * reversible. Clamping at zero would corrupt the count as soon as the part is
+ * later removed or the record deleted.
+ *
+ * MUST be called inside a transaction so the stock movement commits or rolls
+ * back together with the ServicePart changes that caused it.
+ */
+export async function reconcileInventoryForParts(
+  tx: Prisma.TransactionClient,
+  organizationId: string,
+  previous: readonly StockPartLine[],
+  next: readonly StockPartLine[],
+): Promise<void> {
+  // inventoryPartId -> net units to DECREMENT (negative = restock)
+  const netDecrement = new Map<string, number>();
+
+  for (const line of previous) {
+    if (!line.inventoryPartId) continue;
+    const current = netDecrement.get(line.inventoryPartId) ?? 0;
+    netDecrement.set(line.inventoryPartId, current - stockUnits(line.quantity));
+  }
+  for (const line of next) {
+    if (!line.inventoryPartId) continue;
+    const current = netDecrement.get(line.inventoryPartId) ?? 0;
+    netDecrement.set(line.inventoryPartId, current + stockUnits(line.quantity));
+  }
+
+  for (const [inventoryPartId, delta] of netDecrement) {
+    if (delta === 0) continue;
+    await tx.inventoryPart.updateMany({
+      where: { id: inventoryPartId, organizationId },
+      data: { quantity: { decrement: delta } },
+    });
+  }
+}

+ 1 - 2
src/features/settings/Components/InvoiceLayoutPreviewRenderer.tsx

@@ -44,8 +44,7 @@ const DUMMY_INVOICE_DATA = {
   shopName: "Your Workshop",
   techName: "Mike Johnson",
   mileage: 45230,
-  diagnosticNotes:
-    "<p>Brake wear at 15%. Recommended replacement within 5,000 miles.</p>",
+  diagnosticNotes: null,
   findings: [
     { description: "Rear brake pads worn to 15%", severity: "needs_work", notes: "Recommend replacement within 5,000 miles" },
     { description: "Minor oil leak at valve cover gasket", severity: "monitor", notes: null },

+ 0 - 2
src/features/settings/Schema/invoiceLayoutSchema.ts

@@ -64,7 +64,6 @@ export const BUILTIN_SECTIONS = [
   { id: "totals", name: "Totals" },
   { id: "notes", name: "Notes" },
   { id: "warranty", name: "Warranty" },
-  { id: "diagnostic_notes", name: "Diagnostic Notes" },
   { id: "bank_account", name: "Bank Account" },
   { id: "footer", name: "Footer" },
   { id: "telegram_qr", name: "Telegram QR" },
@@ -139,7 +138,6 @@ export const COLUMN_ELIGIBLE_SECTIONS = new Set<string>([
   "service",
   "general",
   "notes",
-  "diagnostic_notes",
   "bank_account",
 ]);
 

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

@@ -66,6 +66,8 @@ export const SETTING_KEYS = {
   MAINTENANCE_SERVICE_INTERVAL: 'maintenance.serviceInterval',
   MAINTENANCE_APPROACHING_THRESHOLD: 'maintenance.approachingThreshold',
   INVENTORY_MARKUP_MULTIPLIER: 'inventory.markupMultiplier',
+  PARTS_DEFAULT_MARKUP_PERCENT: 'parts.defaultMarkupPercent',
+  PARTS_MARKUP_APPLIES_TO_INVENTORY: 'parts.markupAppliesToInventory',
   SMS_TEMPLATE_INVOICE_READY: 'sms.template.invoiceReady',
   SMS_TEMPLATE_QUOTE_READY: 'sms.template.quoteReady',
   SMS_TEMPLATE_INSPECTION_READY: 'sms.template.inspectionReady',

+ 36 - 8
src/features/subscription/Components/subscription-settings.tsx

@@ -40,6 +40,7 @@ import type { PlanFeatures } from "@/lib/features";
 
 type Props = {
   plan: string;
+  isDemo: boolean;
   status: string | null;
   cancelAtPeriodEnd: boolean;
   currentPeriodEnd: string | null;
@@ -53,6 +54,7 @@ type Props = {
 
 export function SubscriptionSettings({
   plan,
+  isDemo,
   status,
   cancelAtPeriodEnd,
   currentPeriodEnd,
@@ -82,6 +84,17 @@ export function SubscriptionSettings({
   const isCanceling = cancelAtPeriodEnd && status === "active";
   const isPastDue = status === "past_due";
 
+  // Whole days left until a demo expires (0 once past the expiry date).
+  const demoDaysRemaining =
+    isDemo && currentPeriodEnd
+      ? Math.max(
+          0,
+          Math.ceil(
+            (new Date(currentPeriodEnd).getTime() - Date.now()) / 86_400_000,
+          ),
+        )
+      : null;
+
   const handleCheckout = async (selectedPlan: "pro" | "enterprise") => {
     setCheckoutLoading(selectedPlan);
     try {
@@ -271,16 +284,31 @@ export function SubscriptionSettings({
           <div className="flex items-center gap-3">
             <Label>{t("subscription.currentPlan")}:</Label>
             {planBadge}
-            {statusBadge}
+            {isDemo ? (
+              <Badge variant="outline" className="border-amber-500 text-amber-600">
+                {t("subscription.demoBadge")}
+              </Badge>
+            ) : (
+              statusBadge
+            )}
           </div>
 
-          {isPaid && planPrice > 0 && (
+          {isDemo && currentPeriodEnd && (
+            <div className="text-sm text-amber-600">
+              {t("subscription.demoExpiresIn", {
+                days: demoDaysRemaining ?? 0,
+                date: formatDate(currentPeriodEnd),
+              })}
+            </div>
+          )}
+
+          {isPaid && !isDemo && planPrice > 0 && (
             <div className="text-sm text-muted-foreground">
               ${planPrice}/{intervalLabel}
             </div>
           )}
 
-          {isPaid && currentPeriodStart && currentPeriodEnd && (
+          {isPaid && !isDemo && currentPeriodStart && currentPeriodEnd && (
             <div className="flex flex-col gap-1 text-sm">
               <div className="flex items-center gap-2">
                 <span className="text-muted-foreground">{t("subscription.billingPeriod")}:</span>
@@ -353,8 +381,8 @@ export function SubscriptionSettings({
         </CardContent>
       </Card>
 
-      {/* Card 3: Manage Subscription */}
-      {isPaid && (
+      {/* Card 3: Manage Subscription — demos have no Stripe billing to manage */}
+      {isPaid && !isDemo && (
         <Card>
           <CardHeader>
             <CardTitle>{t("subscription.manageTitle")}</CardTitle>
@@ -420,8 +448,8 @@ export function SubscriptionSettings({
         </Card>
       )}
 
-      {/* Upgrade Section */}
-      {plan === "free" && (
+      {/* Upgrade Section — free users and demo users subscribe via Stripe Checkout */}
+      {(plan === "free" || isDemo) && (
         <Card>
           <CardHeader>
             <CardTitle>{t("subscription.upgradeTitle")}</CardTitle>
@@ -460,7 +488,7 @@ export function SubscriptionSettings({
         </Card>
       )}
 
-      {plan === "pro" && (
+      {plan === "pro" && !isDemo && (
         <Card>
           <CardHeader>
             <CardTitle>{t("subscription.upgradeTitle")}</CardTitle>

+ 51 - 48
src/features/vehicles/Actions/addPartToServiceRecord.ts

@@ -5,6 +5,7 @@ import { withAuth } from "@/lib/with-auth";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
 import { revalidatePath } from "next/cache";
 import { calculateTotals } from "@/lib/tax";
+import { reconcileInventoryForParts } from "@/features/inventory/Lib/reconcileStock";
 
 export async function addPartToServiceRecord(input: {
   serviceRecordId: string;
@@ -24,58 +25,60 @@ export async function addPartToServiceRecord(input: {
       });
       if (!record) throw new Error("Service record not found");
 
-      const part = await db.servicePart.create({
-        data: {
-          partNumber: input.partNumber || null,
-          name: input.name,
-          quantity: input.quantity,
-          unitPrice: input.unitPrice,
-          total: input.total,
-          unitCost: input.unitCost,
-          inventoryPartId: input.inventoryPartId || null,
-          serviceRecordId: record.id,
-        },
-      });
+      // Create the part, recalculate totals and deduct inventory stock in one
+      // transaction so the line and its stock movement commit together.
+      const part = await db.$transaction(async (tx) => {
+        const created = await tx.servicePart.create({
+          data: {
+            partNumber: input.partNumber || null,
+            name: input.name,
+            quantity: input.quantity,
+            unitPrice: input.unitPrice,
+            total: input.total,
+            unitCost: input.unitCost,
+            inventoryPartId: input.inventoryPartId || null,
+            serviceRecordId: record.id,
+          },
+        });
 
-      // Recalculate totals
-      const [partsAgg, laborAgg] = await Promise.all([
-        db.servicePart.aggregate({
-          where: { serviceRecordId: record.id },
-          _sum: { total: true },
-        }),
-        db.serviceLabor.aggregate({
-          where: { serviceRecordId: record.id },
-          _sum: { total: true },
-        }),
-      ]);
+        // Recalculate totals
+        const [partsAgg, laborAgg] = await Promise.all([
+          tx.servicePart.aggregate({
+            where: { serviceRecordId: record.id },
+            _sum: { total: true },
+          }),
+          tx.serviceLabor.aggregate({
+            where: { serviceRecordId: record.id },
+            _sum: { total: true },
+          }),
+        ]);
 
-      const subtotal = (partsAgg._sum.total || 0) + (laborAgg._sum.total || 0);
-      const discountAmount =
-        record.discountType === "percentage"
-          ? subtotal * ((record.discountValue ?? 0) / 100)
-          : record.discountType === "fixed"
-            ? Math.min(record.discountValue ?? 0, subtotal)
-            : 0;
-      const { taxAmount, totalAmount } = calculateTotals({
-        subtotal,
-        discountAmount,
-        taxRate: record.taxRate,
-        taxInclusive: record.taxInclusive,
-      });
-
-      await db.serviceRecord.update({
-        where: { id: record.id },
-        data: { subtotal, taxAmount, totalAmount },
-      });
+        const subtotal = (partsAgg._sum.total || 0) + (laborAgg._sum.total || 0);
+        const discountAmount =
+          record.discountType === "percentage"
+            ? subtotal * ((record.discountValue ?? 0) / 100)
+            : record.discountType === "fixed"
+              ? Math.min(record.discountValue ?? 0, subtotal)
+              : 0;
+        const { taxAmount, totalAmount } = calculateTotals({
+          subtotal,
+          discountAmount,
+          taxRate: record.taxRate,
+          taxInclusive: record.taxInclusive,
+        });
 
-      // Deduct inventory stock if linked
-      if (input.inventoryPartId) {
-        const invResult = await db.inventoryPart.updateMany({
-          where: { id: input.inventoryPartId, organizationId },
-          data: { quantity: { decrement: input.quantity } },
+        await tx.serviceRecord.update({
+          where: { id: record.id },
+          data: { subtotal, taxAmount, totalAmount },
         });
-        if (invResult.count === 0) throw new Error("Inventory part not found");
-      }
+
+        // Deduct inventory stock for the newly added line (delta from empty).
+        await reconcileInventoryForParts(tx, organizationId, [], [
+          { inventoryPartId: input.inventoryPartId, quantity: input.quantity },
+        ]);
+
+        return created;
+      });
 
       revalidatePath(`/vehicles/${record.vehicleId}/service/${record.id}`);
 

+ 31 - 7
src/features/vehicles/Actions/serviceActions.ts

@@ -10,6 +10,7 @@ import { resolveUploadPath } from "@/lib/resolve-upload-path";
 import { resolveInvoicePrefix } from "@/lib/invoice-utils";
 import { notificationBus } from "@/lib/notification-bus";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { reconcileInventoryForParts } from "@/features/inventory/Lib/reconcileStock";
 
 export async function getServiceRecords(vehicleId: string) {
   return withAuth(async ({ organizationId }) => {
@@ -300,7 +301,8 @@ export async function createServiceRecord(input: unknown) {
       });
 
       if (partItems && partItems.length > 0) {
-        // Enrich parts with unitCost from inventory and deduct stock
+        // Enrich parts with unitCost from inventory (stock movement is handled
+        // by reconcileInventoryForParts below).
         const enrichedParts = [];
         for (const p of partItems) {
           let resolvedUnitCost = p.unitCost ?? 0;
@@ -310,11 +312,6 @@ export async function createServiceRecord(input: unknown) {
             });
             if (invPart) {
               resolvedUnitCost = invPart.unitCost ?? resolvedUnitCost;
-              const newQty = invPart.quantity - Math.ceil(p.quantity);
-              await tx.inventoryPart.update({
-                where: { id: p.inventoryPartId },
-                data: { quantity: Math.max(0, newQty) },
-              });
             }
           }
           enrichedParts.push({
@@ -324,12 +321,16 @@ export async function createServiceRecord(input: unknown) {
             unitPrice: p.unitPrice,
             total: p.total,
             unitCost: resolvedUnitCost,
+            markupPercent: p.markupPercent ?? 0,
             inventoryPartId: p.inventoryPartId || null,
             serviceRecordId: created.id,
           });
         }
 
         await tx.servicePart.createMany({ data: enrichedParts });
+
+        // Deduct stock for inventory-linked parts (delta from an empty set).
+        await reconcileInventoryForParts(tx, organizationId, [], partItems);
       }
 
       if (laborItems && laborItems.length > 0) {
@@ -448,6 +449,15 @@ export async function updateServiceRecord(input: unknown) {
 
       // Replace parts if provided
       if (partItems !== undefined) {
+        // Snapshot the parts being replaced BEFORE deleting them, so inventory
+        // can be reconciled by the delta between the old and new linked parts
+        // (this is what keeps stock correct when parts are added, edited,
+        // reduced or removed on an existing work order).
+        const previousParts = await tx.servicePart.findMany({
+          where: { serviceRecordId: id },
+          select: { inventoryPartId: true, quantity: true },
+        });
+
         await tx.servicePart.deleteMany({ where: { serviceRecordId: id } });
         if (partItems.length > 0) {
           await tx.servicePart.createMany({
@@ -458,11 +468,14 @@ export async function updateServiceRecord(input: unknown) {
               unitPrice: p.unitPrice,
               total: p.total,
               unitCost: p.unitCost ?? 0,
+              markupPercent: p.markupPercent ?? 0,
               inventoryPartId: p.inventoryPartId || null,
               serviceRecordId: id,
             })),
           });
         }
+
+        await reconcileInventoryForParts(tx, organizationId, previousParts, partItems);
       }
 
       // Replace labor if provided
@@ -726,7 +739,18 @@ export async function deleteServiceRecord(recordId: string) {
       }
     }
 
-    await db.serviceRecord.delete({ where: { id: recordId } });
+    // Restock any inventory-linked parts, then delete the record (its parts
+    // cascade-delete). Both happen in one transaction so stock is only
+    // returned if the delete actually commits.
+    await db.$transaction(async (tx) => {
+      const parts = await tx.servicePart.findMany({
+        where: { serviceRecordId: recordId },
+        select: { inventoryPartId: true, quantity: true },
+      });
+      await reconcileInventoryForParts(tx, organizationId, parts, []);
+      await tx.serviceRecord.delete({ where: { id: recordId } });
+    });
+
     revalidatePath("/");
     revalidatePath(`/vehicles/${record.vehicleId}`);
     revalidatePath("/services");

+ 1 - 10
src/features/vehicles/Components/invoice-pdf/InvoicePDF.tsx

@@ -7,7 +7,7 @@ import { Header } from './Header'
 import { CustomerSection, VehicleSection, ServiceSection } from './InfoSection'
 import { PartsTable, LaborTable, FindingsPdfSection } from './Tables'
 import { Totals } from './Totals'
-import { NotesOnly, BankAccountSection, DiagnosticNotesSection } from './Notes'
+import { NotesOnly, BankAccountSection } from './Notes'
 import { WarrantySection } from './Warranty'
 import { CustomFields } from './CustomFields'
 import { Footer, AttachmentsFooter } from './Footer'
@@ -276,15 +276,6 @@ export function InvoicePDF({
       />
     ),
 
-    diagnostic_notes: (
-      <DiagnosticNotesSection
-        diagnosticNotes={data.diagnosticNotes}
-        fontFamily={fontFamily}
-        styles={styles}
-        labels={labels}
-      />
-    ),
-
     findings: (
       <FindingsPdfSection
         findings={data.findings || []}

+ 0 - 72
src/features/vehicles/Components/invoice-pdf/Notes.tsx

@@ -12,17 +12,6 @@ function fillTemplate(template: string, values: Record<string, string>): string
   )
 }
 
-interface NotesProps {
-  invoiceNotes: string | null
-  diagnosticNotes: string | null
-  invoiceSettings?: InvoiceSettingsProps
-  otherAttachments: OtherAttachment[]
-  pdfAttachmentNames: string[]
-  fontFamily: string
-  styles: Record<string, Style>
-  labels: Record<string, string>
-}
-
 // Simple HTML token types
 type Token =
   | { type: 'open'; tag: string }
@@ -336,64 +325,3 @@ export function BankAccountSection({
   )
 }
 
-export function DiagnosticNotesSection({
-  diagnosticNotes,
-  fontFamily,
-  styles,
-  labels,
-}: {
-  diagnosticNotes: string | null
-  fontFamily: string
-  styles: Record<string, Style>
-  labels: Record<string, string>
-}) {
-  const fontBold = getFontBold(fontFamily)
-  if (!hasContent(diagnosticNotes)) return null
-  return (
-    <View wrap={false} style={{ ...styles.notesSection, marginTop: 8 }}>
-      <Text style={styles.notesLabel}>{labels.diagnosticNotes || 'Diagnostic Notes'}</Text>
-      <HtmlToPdf html={diagnosticNotes!} baseStyle={styles.notesText} fontBold={fontBold} />
-    </View>
-  )
-}
-
-
-// ---------------------------------------------------------------------------
-// Composite Notes component – kept for backward compatibility
-// ---------------------------------------------------------------------------
-
-export function Notes({
-  invoiceNotes,
-  diagnosticNotes,
-  invoiceSettings,
-  otherAttachments,
-  pdfAttachmentNames,
-  fontFamily,
-  styles,
-  labels,
-}: NotesProps) {
-  return (
-    <>
-      <NotesOnly
-        invoiceNotes={invoiceNotes}
-        otherAttachments={otherAttachments}
-        pdfAttachmentNames={pdfAttachmentNames}
-        fontFamily={fontFamily}
-        styles={styles}
-        labels={labels}
-      />
-      <BankAccountSection
-        invoiceSettings={invoiceSettings}
-        fontFamily={fontFamily}
-        styles={styles}
-        labels={labels}
-      />
-      <DiagnosticNotesSection
-        diagnosticNotes={diagnosticNotes}
-        fontFamily={fontFamily}
-        styles={styles}
-        labels={labels}
-      />
-    </>
-  )
-}

+ 1 - 1
src/features/vehicles/Components/service-detail/ShareDialog.tsx

@@ -136,7 +136,7 @@ export function ShareDialog({
 
   return (
     <Dialog open={open} onOpenChange={onOpenChange}>
-      <DialogContent className="max-w-md">
+      <DialogContent className="max-w-md" aria-describedby={undefined}>
         <DialogHeader>
           <DialogTitle className="flex items-center gap-2">
             <Link2 className="h-5 w-5" />

+ 28 - 3
src/features/vehicles/Components/service-edit/InventoryPickerDialog.tsx

@@ -17,6 +17,10 @@ interface InventoryPickerDialogProps {
   inventoryParts: InventoryPartOption[]
   currencyCode: string
   onSelectPart: (part: ServicePartInput) => void
+  /** Default markup % to apply when markupAppliesToInventory is true. */
+  defaultMarkupPercent?: number
+  /** When true, picked inventory parts use cost + default markup instead of sellPrice. */
+  markupAppliesToInventory?: boolean
 }
 
 export function InventoryPickerDialog({
@@ -25,6 +29,8 @@ export function InventoryPickerDialog({
   inventoryParts,
   currencyCode,
   onSelectPart,
+  defaultMarkupPercent = 0,
+  markupAppliesToInventory = false,
 }: InventoryPickerDialogProps) {
   const formatCurrency = useFormatCurrency()
   const t = useTranslations('service.parts')
@@ -69,7 +75,7 @@ export function InventoryPickerDialog({
         if (!v) setSearch('')
       }}
     >
-      <DialogContent className="sm:max-w-2xl">
+      <DialogContent className="sm:max-w-2xl" aria-describedby={undefined}>
         <DialogHeader>
           <DialogTitle>{t('inventoryTitle')}</DialogTitle>
         </DialogHeader>
@@ -89,7 +95,15 @@ export function InventoryPickerDialog({
               type="button"
               className="w-full text-left rounded-md px-2.5 py-1.5 hover:bg-accent transition-colors flex items-center justify-between gap-4"
               onClick={() => {
-                const price = ip.sellPrice > 0 ? ip.sellPrice : ip.unitCost
+                // When markup-applies-to-inventory is enabled, recompute the sell price
+                // from cost + default markup. Otherwise fall back to the inventory's
+                // own sellPrice (which has its own separate markup system).
+                const useGlobalMarkup = markupAppliesToInventory && defaultMarkupPercent > 0
+                const price = useGlobalMarkup
+                  ? Math.round(ip.unitCost * (1 + defaultMarkupPercent / 100) * 100) / 100
+                  : ip.sellPrice > 0
+                    ? ip.sellPrice
+                    : ip.unitCost
                 onSelectPart({
                   partNumber: ip.partNumber || '',
                   name: ip.name,
@@ -97,6 +111,7 @@ export function InventoryPickerDialog({
                   unitPrice: price,
                   total: price,
                   unitCost: ip.unitCost,
+                  markupPercent: useGlobalMarkup ? defaultMarkupPercent : 0,
                   inventoryPartId: ip.id,
                 })
                 onOpenChange(false)
@@ -136,7 +151,17 @@ export function InventoryPickerDialog({
                 ) : (
                   <span>{formatCurrency(ip.unitCost, currencyCode)}</span>
                 )}
-                <span>{t('inStock', { quantity: ip.quantity })}</span>
+                {ip.quantity > 0 ? (
+                  <span>{t('inStock', { quantity: ip.quantity })}</span>
+                ) : ip.quantity === 0 ? (
+                  <span className="font-medium text-amber-600 dark:text-amber-500">
+                    {t('outOfStock')}
+                  </span>
+                ) : (
+                  <span className="font-medium text-red-600 dark:text-red-500">
+                    {t('onBackorder', { count: Math.abs(ip.quantity) })}
+                  </span>
+                )}
               </div>
             </button>
           ))}

+ 13 - 1
src/features/vehicles/Components/service-edit/InvoiceDetailsSection.tsx

@@ -1,6 +1,6 @@
 'use client'
 
-import { useState } from 'react'
+import { useEffect, useState } from 'react'
 import { useTranslations } from 'next-intl'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
@@ -49,6 +49,18 @@ export function InvoiceDetailsSection({
     initialData.invoiceDueDate ? new Date(initialData.invoiceDueDate + 'T00:00:00') : undefined
   )
 
+  useEffect(() => {
+    setInvoiceDate(
+      initialData.invoiceDate ? new Date(initialData.invoiceDate + 'T00:00:00') : undefined
+    )
+  }, [initialData.invoiceDate])
+
+  useEffect(() => {
+    setInvoiceDueDate(
+      initialData.invoiceDueDate ? new Date(initialData.invoiceDueDate + 'T00:00:00') : undefined
+    )
+  }, [initialData.invoiceDueDate])
+
   const formatDate = (date: Date | undefined) => {
     if (!date) return ''
     return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })

+ 77 - 8
src/features/vehicles/Components/service-edit/PartsEditor.tsx

@@ -4,7 +4,7 @@ import { useRef, useCallback, useState, useEffect } from 'react'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Textarea } from '@/components/ui/textarea'
-import { GripVertical, Package, Plus, ScanBarcode, Trash2 } from 'lucide-react'
+import { GripVertical, Package, Percent, Plus, ScanBarcode, Trash2 } from 'lucide-react'
 import { useFormatCurrency } from '@/components/currency-settings-context'
 import { useTranslations } from 'next-intl'
 import type { ServicePartInput } from '@/features/vehicles/Schema/serviceSchema'
@@ -36,6 +36,10 @@ interface PartsEditorProps {
   hasInventory: boolean
   onOpenInventory: () => void
   onScanBarcode?: () => void
+  /** Default markup % applied to new manually-added rows. Hidden from customers. */
+  defaultMarkupPercent?: number
+  /** When true, the bulk "Apply markup" button affects inventory-sourced rows too. */
+  markupAppliesToInventory?: boolean
 }
 
 function SortablePartRow({
@@ -76,7 +80,7 @@ function SortablePartRow({
     <div
       ref={dragEnabled ? setNodeRef : undefined}
       style={style}
-      className={`grid grid-cols-[auto_1fr] gap-2 sm:grid-cols-[auto_1fr_2fr_0.7fr_1fr_1fr_auto] ${isDragging && dragEnabled ? 'z-10 opacity-75' : ''}`}
+      className={`grid grid-cols-[auto_1fr] gap-2 sm:grid-cols-[auto_1fr_2fr_0.6fr_0.9fr_0.7fr_0.9fr_0.9fr_auto] ${isDragging && dragEnabled ? 'z-10 opacity-75' : ''}`}
     >
       <button
         type="button"
@@ -101,10 +105,32 @@ function SortablePartRow({
         <Input
           type="number"
           min="0"
-          step="0.01"
+          step="0.1"
           value={part.quantity}
           onChange={(e) => updatePart(index, 'quantity', e.target.value)}
         />
+        <Input
+          type="number"
+          min="0"
+          step="0.01"
+          placeholder={t('unitCost')}
+          title={t('unitCostHint')}
+          value={part.unitCost ?? 0}
+          onChange={(e) => updatePart(index, 'unitCost', e.target.value)}
+        />
+        <div className="relative">
+          <Input
+            type="number"
+            min="0"
+            step="0.1"
+            placeholder={t('markupPercent')}
+            title={t('markupPercentHint')}
+            value={part.markupPercent ?? 0}
+            onChange={(e) => updatePart(index, 'markupPercent', e.target.value)}
+            className="pr-6"
+          />
+          <Percent className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
+        </div>
         <Input
           type="number"
           min="0"
@@ -139,6 +165,8 @@ export function PartsEditor({
   hasInventory,
   onOpenInventory,
   onScanBarcode,
+  defaultMarkupPercent = 0,
+  markupAppliesToInventory = false,
 }: PartsEditorProps) {
   const formatCurrency = useFormatCurrency()
   const t = useTranslations('service.parts')
@@ -173,25 +201,64 @@ export function PartsEditor({
   const addPartAtStart = useCallback(() => {
     const key = `part-${keyCounterRef.current++}`
     keysRef.current = [key, ...keysRef.current]
-    setPartItems((prev) => [emptyPart(), ...prev])
-  }, [setPartItems])
+    setPartItems((prev) => [emptyPart(defaultMarkupPercent), ...prev])
+  }, [setPartItems, defaultMarkupPercent])
 
   const addPartAtEnd = useCallback(() => {
     const key = `part-${keyCounterRef.current++}`
     keysRef.current = [...keysRef.current, key]
-    setPartItems((prev) => [...prev, emptyPart()])
-  }, [setPartItems])
+    setPartItems((prev) => [...prev, emptyPart(defaultMarkupPercent)])
+  }, [setPartItems, defaultMarkupPercent])
 
   const deletePart = useCallback((index: number) => {
     keysRef.current = keysRef.current.filter((_, j) => j !== index)
     setPartItems((prev) => prev.filter((_, j) => j !== index))
   }, [setPartItems])
 
+  /**
+   * Apply the default markup % to every eligible row. Eligible means:
+   *  - non-inventory rows always, OR
+   *  - all rows when `markupAppliesToInventory` is true.
+   * Recomputes unitPrice = unitCost * (1 + markup/100) and total = qty * unitPrice.
+   * Customer never sees cost or markup — only unitPrice/total.
+   */
+  const applyMarkupToAll = useCallback(() => {
+    setPartItems((prev) =>
+      prev.map((p) => {
+        const eligible = markupAppliesToInventory || !p.inventoryPartId
+        if (!eligible) return p
+        const cost = Number(p.unitCost) || 0
+        const markup = defaultMarkupPercent
+        const unitPrice = Math.round(cost * (1 + markup / 100) * 100) / 100
+        return {
+          ...p,
+          markupPercent: markup,
+          unitPrice,
+          total: Number(p.quantity) * unitPrice,
+        }
+      })
+    )
+  }, [setPartItems, defaultMarkupPercent, markupAppliesToInventory])
+
   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('title')}</h3>
         <div className="flex gap-1.5">
+          {defaultMarkupPercent > 0 && partItems.length > 0 && (
+            <Button
+              type="button"
+              variant="outline"
+              size="sm"
+              onClick={applyMarkupToAll}
+              title={t('applyMarkupHint', { percent: defaultMarkupPercent })}
+            >
+              <Percent className="h-3.5 w-3.5 sm:mr-1" />
+              <span className="hidden sm:inline">
+                {t('applyMarkup', { percent: defaultMarkupPercent })}
+              </span>
+            </Button>
+          )}
           {hasInventory && (
             <Button type="button" variant="outline" size="sm" onClick={onOpenInventory}>
               <Package className="h-3.5 w-3.5 sm:mr-1" />
@@ -218,11 +285,13 @@ export function PartsEditor({
 
       {partItems.length > 0 && (
         <>
-          <div className="hidden grid-cols-[auto_1fr_2fr_0.7fr_1fr_1fr_auto] gap-2 text-xs font-medium text-muted-foreground sm:grid">
+          <div className="hidden grid-cols-[auto_1fr_2fr_0.6fr_0.9fr_0.7fr_0.9fr_0.9fr_auto] gap-2 text-xs font-medium text-muted-foreground sm:grid">
             <span className="w-6" />
             <span>{t('partNumber')}</span>
             <span>{t('name')}</span>
             <span>{t('qty')}</span>
+            <span title={t('unitCostHint')}>{t('unitCost')}</span>
+            <span title={t('markupPercentHint')}>{t('markupPercent')}</span>
             <span>{t('unitPrice')}</span>
             <span>{t('total')}</span>
             <span />

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

@@ -72,13 +72,14 @@ export interface ServiceRecordFormProps {
   currentUserName?: string
 }
 
-export const emptyPart = (): ServicePartInput => ({
+export const emptyPart = (defaultMarkupPercent: number = 0): ServicePartInput => ({
   partNumber: '',
   name: '',
   quantity: 1,
   unitPrice: 0,
   total: 0,
   unitCost: 0,
+  markupPercent: defaultMarkupPercent,
 })
 
 export const makeEmptyLabor = (defaultRate: number): ServiceLaborInput => ({

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

@@ -16,6 +16,8 @@ interface DetailsLeftColumnProps {
   currencyCode: string
   defaultLaborRate: number
   inventoryParts: InventoryPartOption[]
+  defaultMarkupPercent?: number
+  markupAppliesToInventory?: boolean
   hasPresets?: boolean
   onOpenPresets?: () => void
   onScanBarcode?: () => void
@@ -35,6 +37,8 @@ export function DetailsLeftColumn({
   currencyCode,
   defaultLaborRate,
   inventoryParts,
+  defaultMarkupPercent = 0,
+  markupAppliesToInventory = false,
   hasPresets,
   onOpenPresets,
   onScanBarcode,
@@ -57,6 +61,8 @@ export function DetailsLeftColumn({
         hasInventory={inventoryParts.length > 0}
         onOpenInventory={() => formState.setShowInventoryPicker(true)}
         onScanBarcode={onScanBarcode}
+        defaultMarkupPercent={defaultMarkupPercent}
+        markupAppliesToInventory={markupAppliesToInventory}
       />
       <LaborEditor
         laborItems={formState.laborItems}

+ 150 - 98
src/features/vehicles/Components/service-page/ServicePageClient.tsx

@@ -77,6 +77,8 @@ export function ServicePageClient({
   telegramEnabled = false,
   aiEnabled = false,
   defaultDueDays = 0,
+  defaultMarkupPercent = 0,
+  markupAppliesToInventory = false,
   statusReports = [],
   initialTab,
   findings = [],
@@ -111,6 +113,7 @@ export function ServicePageClient({
   const [pendingInvoiceDate, setPendingInvoiceDate] = useState<Date>(today)
   const [pendingDueDate, setPendingDueDate] = useState<Date>(suggestedDueDate)
   const [updatingDates, setUpdatingDates] = useState(false)
+  const [customizingDates, setCustomizingDates] = useState(false)
 
   const formatDate = (date: Date) =>
     date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
@@ -159,7 +162,13 @@ export function ServicePageClient({
       const result = await lookupPartByBarcode(barcode)
       if (result.success && result.data) {
         const part = result.data
-        const price = part.sellPrice > 0 ? part.sellPrice : part.unitCost
+        // Same rule as the inventory picker — opt-in markup applies to inventory parts too.
+        const useGlobalMarkup = markupAppliesToInventory && defaultMarkupPercent > 0
+        const price = useGlobalMarkup
+          ? Math.round(part.unitCost * (1 + defaultMarkupPercent / 100) * 100) / 100
+          : part.sellPrice > 0
+            ? part.sellPrice
+            : part.unitCost
         formState.dirtySetPartItems((prev) => [
           {
             partNumber: part.partNumber || '',
@@ -168,6 +177,7 @@ export function ServicePageClient({
             unitPrice: price,
             total: price,
             unitCost: part.unitCost,
+            markupPercent: useGlobalMarkup ? defaultMarkupPercent : 0,
             inventoryPartId: part.id,
           },
           ...prev,
@@ -177,7 +187,7 @@ export function ServicePageClient({
         toast.error(t('parts.partNotFound', { barcode }))
       }
     },
-    [formState, t]
+    [formState, t, defaultMarkupPercent, markupAppliesToInventory]
   )
 
   useHardwareScanner({ onScan: handleBarcodeScan, enabled: activeTab === 'details' })
@@ -260,6 +270,7 @@ export function ServicePageClient({
         unitPrice: part.unitPrice,
         total: part.quantity * part.unitPrice,
         unitCost: 0,
+        markupPercent: 0,
         inventoryPartId: part.inventoryPartId || '',
       }))
       formState.dirtySetPartItems((prev) => [...newParts, ...prev])
@@ -335,6 +346,8 @@ export function ServicePageClient({
                   currencyCode={currencyCode}
                   defaultLaborRate={defaultLaborRate}
                   inventoryParts={inventoryParts}
+                  defaultMarkupPercent={defaultMarkupPercent}
+                  markupAppliesToInventory={markupAppliesToInventory}
                   hasPresets={laborPresets.length > 0}
                   onOpenPresets={() => formState.setShowPresetPicker(true)}
                   onScanBarcode={() => formState.setShowBarcodeScanner(true)}
@@ -437,6 +450,8 @@ export function ServicePageClient({
         inventoryParts={inventoryParts}
         currencyCode={currencyCode}
         onSelectPart={(part) => formState.dirtySetPartItems((prev) => [part, ...prev])}
+        defaultMarkupPercent={defaultMarkupPercent}
+        markupAppliesToInventory={markupAppliesToInventory}
       />
 
       <LaborPresetPickerDialog
@@ -515,6 +530,7 @@ export function ServicePageClient({
           if (!open) {
             dateCheckResolveRef.current?.(false)
             dateCheckResolveRef.current = null
+            setCustomizingDates(false)
           }
           setShowDateCheck(open)
         }}
@@ -528,105 +544,141 @@ export function ServicePageClient({
             <DialogDescription>{t('page.datesExpiredDescription')}</DialogDescription>
           </DialogHeader>
 
-          <div className="space-y-3">
-            <Button
-              variant="secondary"
-              size="sm"
-              className="w-full"
-              onClick={() => {
-                const now = new Date(new Date().toISOString().split('T')[0])
-                setPendingInvoiceDate(now)
-                setPendingDueDate(
-                  defaultDueDays > 0
-                    ? new Date(now.getTime() + defaultDueDays * 86400000)
-                    : new Date(now.getTime() + 14 * 86400000)
-                )
-              }}
-            >
-              {t('page.datesExpiredSetToday')}
-            </Button>
-
-            <div className="space-y-1">
-              <Label className="text-xs">{t('basicInfo.invoiceDate')}</Label>
-              <Popover>
-                <PopoverTrigger asChild>
-                  <Button
-                    variant="outline"
-                    className="w-full justify-start text-left font-normal h-9 text-sm"
-                  >
-                    <CalendarIcon className="mr-2 h-3.5 w-3.5" />
-                    <span suppressHydrationWarning>{formatDate(pendingInvoiceDate)}</span>
-                  </Button>
-                </PopoverTrigger>
-                <PopoverContent className="w-auto p-0" align="start">
-                  <Calendar
-                    mode="single"
-                    selected={pendingInvoiceDate}
-                    onSelect={(d) => d && setPendingInvoiceDate(d)}
-                  />
-                </PopoverContent>
-              </Popover>
-            </div>
-
-            <div className="space-y-1">
-              <Label className="text-xs">{t('basicInfo.invoiceDueDate')}</Label>
-              <Popover>
-                <PopoverTrigger asChild>
-                  <Button
-                    variant="outline"
-                    className="w-full justify-start text-left font-normal h-9 text-sm"
-                  >
-                    <CalendarIcon className="mr-2 h-3.5 w-3.5" />
-                    <span suppressHydrationWarning>{formatDate(pendingDueDate)}</span>
-                  </Button>
-                </PopoverTrigger>
-                <PopoverContent className="w-auto p-0" align="start">
-                  <Calendar
-                    mode="single"
-                    selected={pendingDueDate}
-                    onSelect={(d) => d && setPendingDueDate(d)}
-                  />
-                </PopoverContent>
-              </Popover>
-            </div>
-          </div>
-
-          <div className="flex flex-col gap-2 pt-2">
-            <Button
-              disabled={updatingDates}
-              onClick={async () => {
-                if (updatingDates) return
-                setUpdatingDates(true)
-                try {
-                  await updateServiceRecord({
-                    id: record.id,
-                    invoiceDate: toISODate(pendingInvoiceDate),
-                    invoiceDueDate: toISODate(pendingDueDate),
-                  })
+          {!customizingDates ? (
+            <div className="flex flex-col gap-2 pt-2">
+              <Button
+                disabled={updatingDates}
+                onClick={async () => {
+                  if (updatingDates) return
+                  const now = new Date(new Date().toISOString().split('T')[0])
+                  const due =
+                    defaultDueDays > 0
+                      ? new Date(now.getTime() + defaultDueDays * 86400000)
+                      : new Date(now.getTime() + 14 * 86400000)
+                  setPendingInvoiceDate(now)
+                  setPendingDueDate(due)
+                  setUpdatingDates(true)
+                  try {
+                    await updateServiceRecord({
+                      id: record.id,
+                      invoiceDate: toISODate(now),
+                      invoiceDueDate: toISODate(due),
+                    })
+                    setShowDateCheck(false)
+                    dateCheckResolveRef.current?.(true)
+                    dateCheckResolveRef.current = null
+                    router.refresh()
+                  } finally {
+                    setUpdatingDates(false)
+                  }
+                }}
+              >
+                {updatingDates && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+                {t('page.datesExpiredUseToday')}
+              </Button>
+              <Button
+                variant="outline"
+                disabled={updatingDates}
+                onClick={() => {
                   setShowDateCheck(false)
                   dateCheckResolveRef.current?.(true)
                   dateCheckResolveRef.current = null
-                  router.refresh()
-                } finally {
-                  setUpdatingDates(false)
-                }
-              }}
-            >
-              {updatingDates && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-              {t('page.datesExpiredUpdate')}
-            </Button>
-            <Button
-              variant="outline"
-              disabled={updatingDates}
-              onClick={() => {
-                setShowDateCheck(false)
-                dateCheckResolveRef.current?.(true)
-                dateCheckResolveRef.current = null
-              }}
-            >
-              {t('page.datesExpiredProceed')}
-            </Button>
-          </div>
+                }}
+              >
+                {t('page.datesExpiredProceed')}
+              </Button>
+              <Button
+                variant="ghost"
+                size="sm"
+                disabled={updatingDates}
+                onClick={() => setCustomizingDates(true)}
+              >
+                {t('page.datesExpiredCustomize')}
+              </Button>
+            </div>
+          ) : (
+            <>
+              <div className="space-y-3">
+                <div className="space-y-1">
+                  <Label className="text-xs">{t('basicInfo.invoiceDate')}</Label>
+                  <Popover>
+                    <PopoverTrigger asChild>
+                      <Button
+                        variant="outline"
+                        className="w-full justify-start text-left font-normal h-9 text-sm"
+                      >
+                        <CalendarIcon className="mr-2 h-3.5 w-3.5" />
+                        <span suppressHydrationWarning>{formatDate(pendingInvoiceDate)}</span>
+                      </Button>
+                    </PopoverTrigger>
+                    <PopoverContent className="w-auto p-0" align="start">
+                      <Calendar
+                        mode="single"
+                        selected={pendingInvoiceDate}
+                        onSelect={(d) => d && setPendingInvoiceDate(d)}
+                      />
+                    </PopoverContent>
+                  </Popover>
+                </div>
+
+                <div className="space-y-1">
+                  <Label className="text-xs">{t('basicInfo.invoiceDueDate')}</Label>
+                  <Popover>
+                    <PopoverTrigger asChild>
+                      <Button
+                        variant="outline"
+                        className="w-full justify-start text-left font-normal h-9 text-sm"
+                      >
+                        <CalendarIcon className="mr-2 h-3.5 w-3.5" />
+                        <span suppressHydrationWarning>{formatDate(pendingDueDate)}</span>
+                      </Button>
+                    </PopoverTrigger>
+                    <PopoverContent className="w-auto p-0" align="start">
+                      <Calendar
+                        mode="single"
+                        selected={pendingDueDate}
+                        onSelect={(d) => d && setPendingDueDate(d)}
+                      />
+                    </PopoverContent>
+                  </Popover>
+                </div>
+              </div>
+
+              <div className="flex flex-col gap-2 pt-2">
+                <Button
+                  disabled={updatingDates}
+                  onClick={async () => {
+                    if (updatingDates) return
+                    setUpdatingDates(true)
+                    try {
+                      await updateServiceRecord({
+                        id: record.id,
+                        invoiceDate: toISODate(pendingInvoiceDate),
+                        invoiceDueDate: toISODate(pendingDueDate),
+                      })
+                      setShowDateCheck(false)
+                      setCustomizingDates(false)
+                      dateCheckResolveRef.current?.(true)
+                      dateCheckResolveRef.current = null
+                      router.refresh()
+                    } finally {
+                      setUpdatingDates(false)
+                    }
+                  }}
+                >
+                  {updatingDates && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+                  {t('page.datesExpiredUpdate')}
+                </Button>
+                <Button
+                  variant="outline"
+                  disabled={updatingDates}
+                  onClick={() => setCustomizingDates(false)}
+                >
+                  {t('page.datesExpiredBack')}
+                </Button>
+              </div>
+            </>
+          )}
         </DialogContent>
       </Dialog>
     </div>

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

@@ -54,6 +54,8 @@ export interface ServicePageClientProps {
   telegramEnabled?: boolean
   aiEnabled?: boolean
   defaultDueDays?: number
+  defaultMarkupPercent?: number
+  markupAppliesToInventory?: boolean
   statusReports?: { id: string; title: string | null; message: string | null; status: string; videoUrl: string | null; createdAt: string; publicToken: string; expiresAt: string | null; customerFeedback: string | null; feedbackAt: string | null; sentVia: string | null; sentAt: string | null }[]
   initialTab?: string
   findings?: { id: string; description: string; severity: string; status: string; notes: string | null }[]

+ 30 - 1
src/features/vehicles/Components/service-page/useServiceFormState.ts

@@ -135,12 +135,41 @@ export function useServiceFormState({
   const vehicleName = `${record.vehicle.year} ${record.vehicle.make} ${record.vehicle.model}`
 
   // Part/labor update helpers
+  //
+  // The three pricing fields (unitCost, markupPercent, unitPrice) are linked by:
+  //   unitPrice = unitCost × (1 + markupPercent / 100)
+  // The user can edit any one and the others auto-sync so the row stays
+  // mathematically consistent — Markup % never lies about real margin.
   const updatePart = useCallback(
     (index: number, field: keyof ServicePartInput, value: string | number) => {
       setPartItems((prev) => {
         const updated = [...prev]
         const part = { ...updated[index], [field]: value }
-        if (field === 'quantity' || field === 'unitPrice') {
+        const cost = Number(part.unitCost) || 0
+
+        if (field === 'unitCost' || field === 'markupPercent') {
+          // Cost or markup changed → recompute the customer-facing price.
+          const markup = Number(part.markupPercent) || 0
+          part.unitPrice = Math.round(cost * (1 + markup / 100) * 100) / 100
+        } else if (field === 'unitPrice') {
+          // Price was edited directly → derive markup back from cost so the
+          // displayed margin matches reality. If cost is 0 there's no
+          // meaningful percentage; leave markup at 0 and treat price as a
+          // free override.
+          const price = Number(part.unitPrice) || 0
+          if (cost > 0) {
+            part.markupPercent = Math.round(((price / cost) - 1) * 1000) / 10
+          } else {
+            part.markupPercent = 0
+          }
+        }
+
+        if (
+          field === 'quantity' ||
+          field === 'unitPrice' ||
+          field === 'unitCost' ||
+          field === 'markupPercent'
+        ) {
           part.total = Number(part.quantity) * Number(part.unitPrice)
         }
         updated[index] = part

+ 1 - 0
src/features/vehicles/Schema/serviceSchema.ts

@@ -7,6 +7,7 @@ export const servicePartSchema = z.object({
   unitPrice: z.coerce.number().min(0).default(0),
   total: z.coerce.number().min(0).default(0),
   unitCost: z.coerce.number().min(0).default(0),
+  markupPercent: z.coerce.number().min(0).default(0),
   inventoryPartId: z.string().optional(),
 });
 

+ 24 - 0
src/features/workboard/Actions/boardActions/assignments.ts

@@ -214,6 +214,12 @@ export async function assignTechnician(input: unknown) {
         : {};
 
       if (data.type === "serviceRecord") {
+        const owned = await db.serviceRecord.findFirst({
+          where: { id: data.id, vehicle: { organizationId } },
+          select: { id: true },
+        });
+        if (!owned) throw new Error("Service record not found");
+
         const sr = await db.serviceRecord.update({
           where: { id: data.id },
           data: { technicianId: data.technicianId, techName: tech.name, ...timeData },
@@ -230,6 +236,12 @@ export async function assignTechnician(input: unknown) {
         });
         job = serviceRecordToJob(sr);
       } else {
+        const owned = await db.inspection.findFirst({
+          where: { id: data.id, organizationId },
+          select: { id: true },
+        });
+        if (!owned) throw new Error("Inspection not found");
+
         const insp = await db.inspection.update({
           where: { id: data.id },
           data: { technicianId: data.technicianId, ...timeData },
@@ -277,6 +289,12 @@ export async function moveJob(input: unknown) {
       let job: WorkBoardJob;
 
       if (data.type === "serviceRecord") {
+        const owned = await db.serviceRecord.findFirst({
+          where: { id: data.id, vehicle: { organizationId } },
+          select: { id: true },
+        });
+        if (!owned) throw new Error("Service record not found");
+
         const sr = await db.serviceRecord.update({
           where: { id: data.id },
           data: {
@@ -297,6 +315,12 @@ export async function moveJob(input: unknown) {
         });
         job = serviceRecordToJob(sr);
       } else {
+        const owned = await db.inspection.findFirst({
+          where: { id: data.id, organizationId },
+          select: { id: true },
+        });
+        if (!owned) throw new Error("Inspection not found");
+
         const insp = await db.inspection.update({
           where: { id: data.id },
           data: {

Некоторые файлы не были показаны из-за большого количества измененных файлов