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

Add editable per-organization customer numbers (#226)

* Add editable per-organization customer numbers

New customers get the next sequential number automatically (from 1001)
and the number is editable on the customer form, unique per org with a
friendly duplicate error. Shown on the customer list (sortable,
searchable) and the customer page header. Idempotent migration.

* Add one-click backfill for unnumbered customers

Settings -> Invoice & Quotes gains a Customers section: when customers
without a number exist, a button assigns them sequential numbers
(oldest first, continuing after the highest existing number). Customers
that already have a number are never touched. Audited, permission-
checked, labels in 12 languages.

* Confirm before backfilling customer numbers

The assign button now opens a confirmation explaining exactly what
happens: sequential numbers for the N unnumbered customers in creation
order, existing numbers untouched, editable afterwards.
Bernt Christian Egeland 1 месяц назад
Родитель
Сommit
f6f6092d2a
33 измененных файлов с 368 добавлено и 67 удалено
  1. 5 2
      messages/de/customers.json
  2. 8 1
      messages/de/settings.json
  3. 5 2
      messages/en/customers.json
  4. 8 1
      messages/en/settings.json
  5. 5 2
      messages/es/customers.json
  6. 8 1
      messages/es/settings.json
  7. 5 2
      messages/fr/customers.json
  8. 8 1
      messages/fr/settings.json
  9. 5 2
      messages/it/customers.json
  10. 8 1
      messages/it/settings.json
  11. 5 2
      messages/lt/customers.json
  12. 8 1
      messages/lt/settings.json
  13. 5 2
      messages/nb/customers.json
  14. 8 1
      messages/nb/settings.json
  15. 5 2
      messages/nl/customers.json
  16. 8 1
      messages/nl/settings.json
  17. 5 2
      messages/pl/customers.json
  18. 8 1
      messages/pl/settings.json
  19. 5 2
      messages/pt-BR/customers.json
  20. 8 1
      messages/pt-BR/settings.json
  21. 5 2
      messages/ru/customers.json
  22. 8 1
      messages/ru/settings.json
  23. 5 2
      messages/tr/customers.json
  24. 8 1
      messages/tr/settings.json
  25. 4 0
      prisma/migrations/20260811180000_customer_number/migration.sql
  26. 5 0
      prisma/schema.prisma
  27. 9 1
      src/app/(authenticated)/customers/[id]/customer-detail-client.tsx
  28. 10 1
      src/app/(authenticated)/customers/customers-client.tsx
  29. 49 0
      src/app/(authenticated)/settings/invoice/invoice-settings.tsx
  30. 4 0
      src/app/(authenticated)/settings/invoice/page.tsx
  31. 107 20
      src/features/customers/Actions/customerActions.ts
  32. 23 9
      src/features/customers/Components/CustomerForm.tsx
  33. 1 0
      src/features/customers/Schema/customerSchema.ts

+ 5 - 2
messages/de/customers.json

@@ -7,7 +7,8 @@
       "company": "Unternehmen",
       "phone": "Telefon",
       "email": "E-Mail",
-      "vehicles": "Fahrzeuge"
+      "vehicles": "Fahrzeuge",
+      "number": "Nr."
     },
     "emptySearch": "Keine Kunden entsprechen Ihrer Suche.",
     "empty": "Noch keine Kunden vorhanden.",
@@ -65,7 +66,9 @@
     "notesPlaceholder": "Weitere Anmerkungen...",
     "customerUpdated": "Kunde aktualisiert",
     "customerCreated": "Kunde angelegt",
-    "saveError": "Kunde konnte nicht gespeichert werden"
+    "saveError": "Kunde konnte nicht gespeichert werden",
+    "customerNumber": "Kundennr.",
+    "customerNumberAuto": "Auto"
   },
   "serviceRequests": {
     "statusNew": "Neu",

+ 8 - 1
messages/de/settings.json

@@ -418,7 +418,14 @@
     "layoutDocInvoice": "Rechnung",
     "layoutDocQuote": "Angebot",
     "saveLayout": "Layout speichern",
-    "resetLayout": "Zurücksetzen"
+    "resetLayout": "Zurücksetzen",
+    "sectionCustomers": "Kunden",
+    "assignCustomerNumbers": "Kundennummern vergeben",
+    "assignCustomerNumbersHint": "{count} Kunden haben noch keine Nummer. Fortlaufende Nummern vergeben (bestehende bleiben erhalten).",
+    "customerNumbersAssigned": "{count} Kunden nummeriert",
+    "allCustomersNumbered": "Alle Kunden haben eine Nummer.",
+    "assignCustomerNumbersConfirmTitle": "Kundennummern vergeben?",
+    "assignCustomerNumbersConfirmDescription": "Die {count} Kunden ohne Nummer erhalten fortlaufende Nummern in der Reihenfolge ihrer Erstellung, beginnend nach der höchsten vorhandenen Nummer. Kunden mit Nummer bleiben unverändert. Nummern können danach bearbeitet werden."
   },
   "layoutEditor": {
     "helpText": "Ziehen zum Neuordnen. Verwenden Sie den Breiten-Umschalter, um Abschnitte nebeneinander zu platzieren. Aufeinanderfolgende Halbbreite-Abschnitte (L/R) werden in zwei Spalten dargestellt.",

+ 5 - 2
messages/en/customers.json

@@ -7,7 +7,8 @@
       "company": "Company",
       "phone": "Phone",
       "email": "Email",
-      "vehicles": "Vehicles"
+      "vehicles": "Vehicles",
+      "number": "No."
     },
     "emptySearch": "No customers match your search.",
     "empty": "No customers yet.",
@@ -65,7 +66,9 @@
     "notesPlaceholder": "Additional notes...",
     "customerUpdated": "Customer updated",
     "customerCreated": "Customer created",
-    "saveError": "Failed to save customer"
+    "saveError": "Failed to save customer",
+    "customerNumber": "Customer no.",
+    "customerNumberAuto": "Auto"
   },
   "serviceRequests": {
     "statusNew": "New",

+ 8 - 1
messages/en/settings.json

@@ -418,7 +418,14 @@
     "layoutDocInvoice": "Invoice",
     "layoutDocQuote": "Quote",
     "saveLayout": "Save Layout",
-    "resetLayout": "Reset to Default"
+    "resetLayout": "Reset to Default",
+    "sectionCustomers": "Customers",
+    "assignCustomerNumbers": "Assign customer numbers",
+    "assignCustomerNumbersHint": "{count} customers have no number yet. Assign sequential numbers to them (existing numbers are kept).",
+    "customerNumbersAssigned": "Assigned numbers to {count} customers",
+    "allCustomersNumbered": "All customers have a number.",
+    "assignCustomerNumbersConfirmTitle": "Assign customer numbers?",
+    "assignCustomerNumbersConfirmDescription": "This will give sequential numbers to the {count} customers that have no number yet, in the order they were created, continuing after your highest existing number. Customers that already have a number are not changed. Numbers can be edited afterwards."
   },
   "layoutEditor": {
     "helpText": "Drag to reorder. Use the width toggle to place sections side-by-side. Consecutive half-width sections (L/R) will render in two columns.",

+ 5 - 2
messages/es/customers.json

@@ -7,7 +7,8 @@
       "company": "Empresa",
       "phone": "Teléfono",
       "email": "Correo electrónico",
-      "vehicles": "Vehículos"
+      "vehicles": "Vehículos",
+      "number": "N.º"
     },
     "emptySearch": "Ningún cliente coincide con tu búsqueda.",
     "empty": "Aún no hay clientes.",
@@ -65,7 +66,9 @@
     "notesPlaceholder": "Notas adicionales...",
     "customerUpdated": "Cliente actualizado",
     "customerCreated": "Cliente creado",
-    "saveError": "Error al guardar el cliente"
+    "saveError": "Error al guardar el cliente",
+    "customerNumber": "N.º de cliente",
+    "customerNumberAuto": "Auto"
   },
   "serviceRequests": {
     "statusNew": "Nuevo",

+ 8 - 1
messages/es/settings.json

@@ -418,7 +418,14 @@
     "layoutDocInvoice": "Factura",
     "layoutDocQuote": "Presupuesto",
     "saveLayout": "Guardar diseño",
-    "resetLayout": "Restablecer"
+    "resetLayout": "Restablecer",
+    "sectionCustomers": "Clientes",
+    "assignCustomerNumbers": "Asignar números de cliente",
+    "assignCustomerNumbersHint": "{count} clientes aún no tienen número. Asignarles números secuenciales (los existentes se conservan).",
+    "customerNumbersAssigned": "Números asignados a {count} clientes",
+    "allCustomersNumbered": "Todos los clientes tienen número.",
+    "assignCustomerNumbersConfirmTitle": "¿Asignar números de cliente?",
+    "assignCustomerNumbersConfirmDescription": "Los {count} clientes sin número recibirán números secuenciales en el orden en que se crearon, continuando después del número existente más alto. Los clientes que ya tienen número no cambian. Los números se pueden editar después."
   },
   "layoutEditor": {
     "helpText": "Arrastra para reordenar. Usa el selector de ancho para colocar secciones lado a lado. Las secciones consecutivas de medio ancho (I/D) se mostrarán en dos columnas.",

+ 5 - 2
messages/fr/customers.json

@@ -7,7 +7,8 @@
       "company": "Entreprise",
       "phone": "Téléphone",
       "email": "E-mail",
-      "vehicles": "Véhicules"
+      "vehicles": "Véhicules",
+      "number": "N°"
     },
     "emptySearch": "Aucun client ne correspond à votre recherche.",
     "empty": "Aucun client pour le moment.",
@@ -65,7 +66,9 @@
     "notesPlaceholder": "Notes supplémentaires...",
     "customerUpdated": "Client mis à jour",
     "customerCreated": "Client créé",
-    "saveError": "Échec de l'enregistrement du client"
+    "saveError": "Échec de l'enregistrement du client",
+    "customerNumber": "N° client",
+    "customerNumberAuto": "Auto"
   },
   "serviceRequests": {
     "statusNew": "Nouveau",

+ 8 - 1
messages/fr/settings.json

@@ -418,7 +418,14 @@
     "layoutDocInvoice": "Facture",
     "layoutDocQuote": "Devis",
     "saveLayout": "Enregistrer la mise en page",
-    "resetLayout": "Réinitialiser"
+    "resetLayout": "Réinitialiser",
+    "sectionCustomers": "Clients",
+    "assignCustomerNumbers": "Attribuer des numéros clients",
+    "assignCustomerNumbersHint": "{count} clients n'ont pas encore de numéro. Leur attribuer des numéros séquentiels (les existants sont conservés).",
+    "customerNumbersAssigned": "Numéros attribués à {count} clients",
+    "allCustomersNumbered": "Tous les clients ont un numéro.",
+    "assignCustomerNumbersConfirmTitle": "Attribuer des numéros clients ?",
+    "assignCustomerNumbersConfirmDescription": "Les {count} clients sans numéro recevront des numéros séquentiels dans leur ordre de création, à la suite du numéro existant le plus élevé. Les clients ayant déjà un numéro ne sont pas modifiés. Les numéros restent modifiables ensuite."
   },
   "layoutEditor": {
     "helpText": "Glissez pour réorganiser. Utilisez le sélecteur de largeur pour placer les sections côte à côte. Les sections consécutives en demi-largeur (G/D) s'afficheront en deux colonnes.",

+ 5 - 2
messages/it/customers.json

@@ -7,7 +7,8 @@
       "company": "Azienda",
       "phone": "Telefono",
       "email": "Email",
-      "vehicles": "Veicoli"
+      "vehicles": "Veicoli",
+      "number": "N."
     },
     "emptySearch": "Nessun cliente corrisponde alla ricerca.",
     "empty": "Nessun cliente presente.",
@@ -65,7 +66,9 @@
     "notesPlaceholder": "Note aggiuntive...",
     "customerUpdated": "Cliente aggiornato",
     "customerCreated": "Cliente creato",
-    "saveError": "Salvataggio del cliente non riuscito"
+    "saveError": "Salvataggio del cliente non riuscito",
+    "customerNumber": "N. cliente",
+    "customerNumberAuto": "Auto"
   },
   "serviceRequests": {
     "statusNew": "Nuova",

+ 8 - 1
messages/it/settings.json

@@ -418,7 +418,14 @@
     "layoutDocInvoice": "Fattura",
     "layoutDocQuote": "Preventivo",
     "saveLayout": "Salva layout",
-    "resetLayout": "Ripristina"
+    "resetLayout": "Ripristina",
+    "sectionCustomers": "Clienti",
+    "assignCustomerNumbers": "Assegna numeri cliente",
+    "assignCustomerNumbersHint": "{count} clienti non hanno ancora un numero. Assegna numeri sequenziali (quelli esistenti restano invariati).",
+    "customerNumbersAssigned": "Numeri assegnati a {count} clienti",
+    "allCustomersNumbered": "Tutti i clienti hanno un numero.",
+    "assignCustomerNumbersConfirmTitle": "Assegnare i numeri cliente?",
+    "assignCustomerNumbersConfirmDescription": "I {count} clienti senza numero riceveranno numeri sequenziali nell'ordine di creazione, proseguendo dal numero esistente più alto. I clienti che hanno già un numero non vengono modificati. I numeri restano modificabili."
   },
   "layoutEditor": {
     "helpText": "Trascina per riordinare. Usa il selettore di larghezza per posizionare le sezioni affiancate. Le sezioni consecutive a mezza larghezza (S/D) verranno visualizzate in due colonne.",

+ 5 - 2
messages/lt/customers.json

@@ -7,7 +7,8 @@
       "company": "Įmonė",
       "phone": "Telefonas",
       "email": "El. paštas",
-      "vehicles": "Transporto priemonės"
+      "vehicles": "Transporto priemonės",
+      "number": "Nr."
     },
     "emptySearch": "Nėra klientų, atitinkančių paiešką.",
     "empty": "Klientų dar nėra.",
@@ -65,7 +66,9 @@
     "notesPlaceholder": "Papildomos pastabos...",
     "customerUpdated": "Klientas atnaujintas",
     "customerCreated": "Klientas sukurtas",
-    "saveError": "Nepavyko išsaugoti kliento"
+    "saveError": "Nepavyko išsaugoti kliento",
+    "customerNumber": "Kliento nr.",
+    "customerNumberAuto": "Autom."
   },
   "serviceRequests": {
     "statusNew": "Nauja",

+ 8 - 1
messages/lt/settings.json

@@ -418,7 +418,14 @@
     "layoutDocInvoice": "Sąskaita",
     "layoutDocQuote": "Pasiūlymas",
     "saveLayout": "Išsaugoti išdėstymą",
-    "resetLayout": "Atstatyti numatytuosius"
+    "resetLayout": "Atstatyti numatytuosius",
+    "sectionCustomers": "Klientai",
+    "assignCustomerNumbers": "Priskirti klientų numerius",
+    "assignCustomerNumbersHint": "{count} klientai dar neturi numerio. Priskirti eilės numerius (esami išlieka).",
+    "customerNumbersAssigned": "Numeriai priskirti {count} klientams",
+    "allCustomersNumbered": "Visi klientai turi numerį.",
+    "assignCustomerNumbersConfirmTitle": "Priskirti klientų numerius?",
+    "assignCustomerNumbersConfirmDescription": "{count} klientams be numerio bus priskirti eilės numeriai sukūrimo tvarka, tęsiant nuo didžiausio esamo numerio. Klientai, jau turintys numerį, nekeičiami. Numerius galima redaguoti vėliau."
   },
   "layoutEditor": {
     "helpText": "Vilkite, kad pertvarkytumėte. Naudokite pločio perjungiklį, kad išdėstytumėte sekcijas greta. Iš eilės einančios pusės pločio sekcijos (K/D) bus vaizduojamos dviejuose stulpeliuose.",

+ 5 - 2
messages/nb/customers.json

@@ -7,7 +7,8 @@
       "company": "Bedrift",
       "phone": "Telefon",
       "email": "E-post",
-      "vehicles": "Kjøretøy"
+      "vehicles": "Kjøretøy",
+      "number": "Nr."
     },
     "emptySearch": "Ingen kunder samsvarer med søket ditt.",
     "empty": "Ingen kunder ennå.",
@@ -65,7 +66,9 @@
     "notesPlaceholder": "Ytterligere notater...",
     "customerUpdated": "Kunde oppdatert",
     "customerCreated": "Kunde opprettet",
-    "saveError": "Kunne ikke lagre kunde"
+    "saveError": "Kunne ikke lagre kunde",
+    "customerNumber": "Kundenr.",
+    "customerNumberAuto": "Auto"
   },
   "serviceRequests": {
     "statusNew": "Ny",

+ 8 - 1
messages/nb/settings.json

@@ -418,7 +418,14 @@
     "layoutDocInvoice": "Faktura",
     "layoutDocQuote": "Tilbud",
     "saveLayout": "Lagre oppsett",
-    "resetLayout": "Tilbakestill"
+    "resetLayout": "Tilbakestill",
+    "sectionCustomers": "Kunder",
+    "assignCustomerNumbers": "Tildel kundenumre",
+    "assignCustomerNumbersHint": "{count} kunder mangler nummer. Tildel fortløpende numre (eksisterende beholdes).",
+    "customerNumbersAssigned": "Tildelte numre til {count} kunder",
+    "allCustomersNumbered": "Alle kunder har et nummer.",
+    "assignCustomerNumbersConfirmTitle": "Tildele kundenumre?",
+    "assignCustomerNumbersConfirmDescription": "De {count} kundene uten nummer får fortløpende numre i opprettelsesrekkefølge, fortsettende etter det høyeste eksisterende nummeret. Kunder som allerede har nummer endres ikke. Numrene kan redigeres etterpå."
   },
   "layoutEditor": {
     "helpText": "Dra for å sortere. Bruk bredde-veksler for å plassere seksjoner side om side. Påfølgende halvbredde-seksjoner (V/H) vises i to kolonner.",

+ 5 - 2
messages/nl/customers.json

@@ -7,7 +7,8 @@
       "company": "Bedrijf",
       "phone": "Telefoon",
       "email": "E-mail",
-      "vehicles": "Voertuigen"
+      "vehicles": "Voertuigen",
+      "number": "Nr."
     },
     "emptySearch": "Geen klanten gevonden voor uw zoekopdracht.",
     "empty": "Nog geen klanten.",
@@ -65,7 +66,9 @@
     "notesPlaceholder": "Aanvullende opmerkingen...",
     "customerUpdated": "Klant bijgewerkt",
     "customerCreated": "Klant aangemaakt",
-    "saveError": "Opslaan van klant mislukt"
+    "saveError": "Opslaan van klant mislukt",
+    "customerNumber": "Klantnr.",
+    "customerNumberAuto": "Auto"
   },
   "serviceRequests": {
     "statusNew": "Nieuw",

+ 8 - 1
messages/nl/settings.json

@@ -418,7 +418,14 @@
     "layoutDocInvoice": "Factuur",
     "layoutDocQuote": "Offerte",
     "saveLayout": "Layout opslaan",
-    "resetLayout": "Herstellen"
+    "resetLayout": "Herstellen",
+    "sectionCustomers": "Klanten",
+    "assignCustomerNumbers": "Klantnummers toewijzen",
+    "assignCustomerNumbersHint": "{count} klanten hebben nog geen nummer. Wijs opeenvolgende nummers toe (bestaande blijven behouden).",
+    "customerNumbersAssigned": "Nummers toegewezen aan {count} klanten",
+    "allCustomersNumbered": "Alle klanten hebben een nummer.",
+    "assignCustomerNumbersConfirmTitle": "Klantnummers toewijzen?",
+    "assignCustomerNumbersConfirmDescription": "De {count} klanten zonder nummer krijgen opeenvolgende nummers in volgorde van aanmaak, doorlopend na het hoogste bestaande nummer. Klanten met een nummer worden niet gewijzigd. Nummers zijn achteraf te bewerken."
   },
   "layoutEditor": {
     "helpText": "Sleep om te herordenen. Gebruik de breedte-schakelaar om secties naast elkaar te plaatsen. Opeenvolgende halve breedte secties (L/R) worden in twee kolommen weergegeven.",

+ 5 - 2
messages/pl/customers.json

@@ -7,7 +7,8 @@
       "company": "Firma",
       "phone": "Telefon",
       "email": "E-mail",
-      "vehicles": "Pojazdy"
+      "vehicles": "Pojazdy",
+      "number": "Nr"
     },
     "emptySearch": "Brak klientów pasujących do wyszukiwania.",
     "empty": "Brak klientów.",
@@ -65,7 +66,9 @@
     "notesPlaceholder": "Dodatkowe uwagi...",
     "customerUpdated": "Klient zaktualizowany",
     "customerCreated": "Klient utworzony",
-    "saveError": "Nie udało się zapisać klienta"
+    "saveError": "Nie udało się zapisać klienta",
+    "customerNumber": "Nr klienta",
+    "customerNumberAuto": "Auto"
   },
   "serviceRequests": {
     "statusNew": "Nowe",

+ 8 - 1
messages/pl/settings.json

@@ -418,7 +418,14 @@
     "layoutDocInvoice": "Faktura",
     "layoutDocQuote": "Wycena",
     "saveLayout": "Zapisz układ",
-    "resetLayout": "Resetuj"
+    "resetLayout": "Resetuj",
+    "sectionCustomers": "Klienci",
+    "assignCustomerNumbers": "Przydziel numery klientów",
+    "assignCustomerNumbersHint": "{count} klientów nie ma jeszcze numeru. Przydziel im kolejne numery (istniejące pozostają).",
+    "customerNumbersAssigned": "Przydzielono numery {count} klientom",
+    "allCustomersNumbered": "Wszyscy klienci mają numer.",
+    "assignCustomerNumbersConfirmTitle": "Przydzielić numery klientów?",
+    "assignCustomerNumbersConfirmDescription": "{count} klientów bez numeru otrzyma kolejne numery w kolejności utworzenia, kontynuując od najwyższego istniejącego numeru. Klienci z numerem nie zostaną zmienieni. Numery można później edytować."
   },
   "layoutEditor": {
     "helpText": "Przeciągnij, aby zmienić kolejność. Użyj przełącznika szerokości, aby umieścić sekcje obok siebie. Kolejne sekcje o połowie szerokości (L/P) będą wyświetlane w dwóch kolumnach.",

+ 5 - 2
messages/pt-BR/customers.json

@@ -7,7 +7,8 @@
       "company": "Empresa",
       "phone": "Telefone",
       "email": "E-mail",
-      "vehicles": "Veículos"
+      "vehicles": "Veículos",
+      "number": "Nº"
     },
     "emptySearch": "Nenhum cliente encontrado para sua busca.",
     "empty": "Nenhum cliente cadastrado.",
@@ -65,7 +66,9 @@
     "notesPlaceholder": "Observações adicionais...",
     "customerUpdated": "Cliente atualizado",
     "customerCreated": "Cliente cadastrado",
-    "saveError": "Falha ao salvar cliente"
+    "saveError": "Falha ao salvar cliente",
+    "customerNumber": "Nº do cliente",
+    "customerNumberAuto": "Auto"
   },
   "serviceRequests": {
     "statusNew": "Novo",

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

@@ -418,7 +418,14 @@
     "layoutDocInvoice": "Fatura",
     "layoutDocQuote": "Orçamento",
     "saveLayout": "Salvar layout",
-    "resetLayout": "Redefinir"
+    "resetLayout": "Redefinir",
+    "sectionCustomers": "Clientes",
+    "assignCustomerNumbers": "Atribuir números de cliente",
+    "assignCustomerNumbersHint": "{count} clientes ainda não têm número. Atribuir números sequenciais (os existentes são mantidos).",
+    "customerNumbersAssigned": "Números atribuídos a {count} clientes",
+    "allCustomersNumbered": "Todos os clientes têm número.",
+    "assignCustomerNumbersConfirmTitle": "Atribuir números de cliente?",
+    "assignCustomerNumbersConfirmDescription": "Os {count} clientes sem número receberão números sequenciais na ordem de criação, continuando após o maior número existente. Clientes que já têm número não são alterados. Os números podem ser editados depois."
   },
   "layoutEditor": {
     "helpText": "Arraste para reordenar. Use o alternador de largura para posicionar seções lado a lado. Seções consecutivas de meia largura (E/D) serão exibidas em duas colunas.",

+ 5 - 2
messages/ru/customers.json

@@ -7,7 +7,8 @@
       "company": "Компания",
       "phone": "Телефон",
       "email": "Эл. почта",
-      "vehicles": "Транспортные средства"
+      "vehicles": "Транспортные средства",
+      "number": "№"
     },
     "emptySearch": "Нет клиентов, соответствующих поиску.",
     "empty": "Клиентов пока нет.",
@@ -65,7 +66,9 @@
     "notesPlaceholder": "Дополнительные заметки...",
     "customerUpdated": "Клиент обновлён",
     "customerCreated": "Клиент создан",
-    "saveError": "Не удалось сохранить клиента"
+    "saveError": "Не удалось сохранить клиента",
+    "customerNumber": "№ клиента",
+    "customerNumberAuto": "Авто"
   },
   "serviceRequests": {
     "statusNew": "Новая",

+ 8 - 1
messages/ru/settings.json

@@ -418,7 +418,14 @@
     "layoutDocInvoice": "Счёт",
     "layoutDocQuote": "Предложение",
     "saveLayout": "Сохранить макет",
-    "resetLayout": "Сбросить по умолчанию"
+    "resetLayout": "Сбросить по умолчанию",
+    "sectionCustomers": "Клиенты",
+    "assignCustomerNumbers": "Присвоить номера клиентам",
+    "assignCustomerNumbersHint": "У {count} клиентов ещё нет номера. Присвоить им последовательные номера (существующие сохраняются).",
+    "customerNumbersAssigned": "Номера присвоены {count} клиентам",
+    "allCustomersNumbered": "У всех клиентов есть номер.",
+    "assignCustomerNumbersConfirmTitle": "Присвоить номера клиентам?",
+    "assignCustomerNumbersConfirmDescription": "{count} клиентов без номера получат последовательные номера в порядке создания, продолжая после наибольшего существующего номера. Клиенты с номером не изменяются. Номера можно редактировать позже."
   },
   "layoutEditor": {
     "helpText": "Перетаскивайте для изменения порядка. Используйте переключатель ширины для размещения разделов бок о бок. Последовательные разделы половинной ширины (Л/П) будут отображаться в два столбца.",

+ 5 - 2
messages/tr/customers.json

@@ -7,7 +7,8 @@
       "company": "Şirket",
       "phone": "Telefon",
       "email": "E-posta",
-      "vehicles": "Araçlar"
+      "vehicles": "Araçlar",
+      "number": "No"
     },
     "emptySearch": "Aramanızla eşleşen müşteri bulunamadı.",
     "empty": "Henüz müşteri yok.",
@@ -65,7 +66,9 @@
     "notesPlaceholder": "Ek notlar...",
     "customerUpdated": "Müşteri güncellendi",
     "customerCreated": "Müşteri oluşturuldu",
-    "saveError": "Müşteri kaydedilemedi"
+    "saveError": "Müşteri kaydedilemedi",
+    "customerNumber": "Müşteri no",
+    "customerNumberAuto": "Oto"
   },
   "serviceRequests": {
     "statusNew": "Yeni",

+ 8 - 1
messages/tr/settings.json

@@ -418,7 +418,14 @@
     "layoutDocInvoice": "Fatura",
     "layoutDocQuote": "Teklif",
     "saveLayout": "Düzeni kaydet",
-    "resetLayout": "Sıfırla"
+    "resetLayout": "Sıfırla",
+    "sectionCustomers": "Müşteriler",
+    "assignCustomerNumbers": "Müşteri numaralarını ata",
+    "assignCustomerNumbersHint": "{count} müşterinin henüz numarası yok. Sıralı numaralar atayın (mevcutlar korunur).",
+    "customerNumbersAssigned": "{count} müşteriye numara atandı",
+    "allCustomersNumbered": "Tüm müşterilerin numarası var.",
+    "assignCustomerNumbersConfirmTitle": "Müşteri numaraları atansın mı?",
+    "assignCustomerNumbersConfirmDescription": "Numarası olmayan {count} müşteriye, oluşturulma sırasına göre ve mevcut en yüksek numaradan devam eden sıralı numaralar verilecek. Numarası olan müşteriler değişmez. Numaralar sonradan düzenlenebilir."
   },
   "layoutEditor": {
     "helpText": "Sıralamak için sürükleyin. Bölümleri yan yana yerleştirmek için genişlik değiştiriciyi kullanın. Ardışık yarım genişlik bölümleri (S/S) iki sütun halinde görüntülenir.",

+ 4 - 0
prisma/migrations/20260811180000_customer_number/migration.sql

@@ -0,0 +1,4 @@
+-- Human-facing editable customer number, unique per organization
+ALTER TABLE "customers" ADD COLUMN IF NOT EXISTS "customerNumber" TEXT;
+CREATE UNIQUE INDEX IF NOT EXISTS "customers_organizationId_customerNumber_key"
+  ON "customers"("organizationId", "customerNumber");

+ 5 - 0
prisma/schema.prisma

@@ -328,6 +328,10 @@ model VehicleFinding {
 
 model Customer {
   id             String   @id @default(cuid())
+  /// Human-facing customer number: auto-assigned sequentially on create,
+  /// editable afterwards. Unique per organization (nulls allowed for
+  /// legacy customers).
+  customerNumber String?
   name           String
   email          String?
   phone          String?
@@ -354,6 +358,7 @@ model Customer {
   serviceRequests  ServiceRequest[]
 
   @@index([organizationId])
+  @@unique([organizationId, customerNumber])
   @@map("customers")
 }
 

+ 9 - 1
src/app/(authenticated)/customers/[id]/customer-detail-client.tsx

@@ -58,6 +58,7 @@ interface ServiceRequestItem {
 
 interface CustomerDetail {
   id: string;
+  customerNumber: string | null;
   name: string;
   email: string | null;
   phone: string | null;
@@ -163,7 +164,14 @@ export function CustomerDetailClient({
             <Users className="h-7 w-7 text-primary" />
           </div>
           <div className="flex-1">
-            <h1 className="text-2xl font-bold">{customer.name}</h1>
+            <h1 className="flex items-baseline gap-2 text-2xl font-bold">
+              {customer.name}
+              {customer.customerNumber && (
+                <span className="font-mono text-sm font-normal text-muted-foreground">
+                  #{customer.customerNumber}
+                </span>
+              )}
+            </h1>
             {hasContactInfo && (
               <div className="mt-1.5 flex flex-wrap gap-x-5 gap-y-1.5 text-sm">
                 {customer.company && (

+ 10 - 1
src/app/(authenticated)/customers/customers-client.tsx

@@ -44,6 +44,7 @@ import {
 } from "lucide-react";
 
 interface Customer {
+  customerNumber: string | null;
   id: string;
   name: string;
   email: string | null;
@@ -265,6 +266,11 @@ export function CustomersClient({
                   onClick={(e) => e.stopPropagation()}
                 />
               </TableHead>
+              <TableHead className="w-[80px]">
+                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("number")}>
+                  {t("table.number")}<SortIcon column="number" />
+                </button>
+              </TableHead>
               <TableHead>
                 <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("name")}>
                   {t("table.name")}<SortIcon column="name" />
@@ -296,7 +302,7 @@ export function CustomersClient({
           <TableBody>
             {data.customers.length === 0 ? (
               <TableRow>
-                <TableCell colSpan={7} className="h-32 text-center text-muted-foreground">
+                <TableCell colSpan={8} className="h-32 text-center text-muted-foreground">
                   {search ? t("emptySearch") : t("empty")}
                 </TableCell>
               </TableRow>
@@ -314,6 +320,9 @@ export function CustomersClient({
                       onClick={(e) => e.stopPropagation()}
                     />
                   </TableCell>
+                  <TableCell className="font-mono text-xs text-muted-foreground">
+                    {c.customerNumber || "-"}
+                  </TableCell>
                   <TableCell>
                     <div className="flex items-center gap-2">
                       <Users className="h-4 w-4 text-muted-foreground shrink-0" />

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

@@ -12,10 +12,12 @@ import { Switch } from '@/components/ui/switch'
 import { Textarea } from '@/components/ui/textarea'
 import { toast } from 'sonner'
 import { setSettings } from '@/features/settings/Actions/settingsActions'
+import { backfillCustomerNumbers } from '@/features/customers/Actions/customerActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { FileText, Loader2, Save } from 'lucide-react'
 import { ReadOnlyBanner, SaveButton, ReadOnlyWrapper } from '../read-only-guard'
 import { cn } from '@/lib/utils'
+import { useConfirm } from '@/components/confirm-dialog'
 import { InvoiceLayoutEditor } from '@/features/settings/Components/InvoiceLayoutEditor'
 import { InvoiceLayoutPreview } from '@/features/settings/Components/InvoiceLayoutPreview'
 import {
@@ -44,6 +46,7 @@ interface FieldDef {
 
 interface InvoiceSettingsProps {
   settings: Record<string, string>
+  unnumberedCustomers?: number
   initialInvoiceLayout?: InvoiceLayoutConfig
   initialQuoteLayout?: InvoiceLayoutConfig
   customFields: FieldDef[]
@@ -53,6 +56,7 @@ interface InvoiceSettingsProps {
 
 export function InvoiceSettings({
   settings,
+  unnumberedCustomers = 0,
   initialInvoiceLayout,
   initialQuoteLayout,
   customFields,
@@ -142,6 +146,28 @@ export function InvoiceSettings({
     toast.success(t('invoice.saved'))
   }
 
+  const confirm = useConfirm()
+  const [assigning, setAssigning] = useState(false)
+  const [unnumbered, setUnnumbered] = useState(unnumberedCustomers)
+  const handleAssignCustomerNumbers = async () => {
+    const ok = await confirm({
+      title: t('invoice.assignCustomerNumbersConfirmTitle'),
+      description: t('invoice.assignCustomerNumbersConfirmDescription', { count: unnumbered }),
+      confirmLabel: t('invoice.assignCustomerNumbers'),
+    })
+    if (!ok) return
+    setAssigning(true)
+    const result = await backfillCustomerNumbers()
+    setAssigning(false)
+    if (result.success && result.data) {
+      setUnnumbered(0)
+      toast.success(t('invoice.customerNumbersAssigned', { count: result.data.assigned }))
+      router.refresh()
+    } else {
+      toast.error(result.error || t('templates.failedSave'))
+    }
+  }
+
   const handleSaveLayout = async () => {
     setSaving(true)
     try {
@@ -331,6 +357,29 @@ export function InvoiceSettings({
 
               <Separator />
 
+              <div className="space-y-3">
+                <h3 className="text-sm font-semibold">{t('invoice.sectionCustomers')}</h3>
+                <div className="flex items-center justify-between gap-4">
+                  <p className="text-xs text-muted-foreground">
+                    {unnumbered > 0
+                      ? t('invoice.assignCustomerNumbersHint', { count: unnumbered })
+                      : t('invoice.allCustomersNumbered')}
+                  </p>
+                  <Button
+                    type="button"
+                    variant="outline"
+                    size="sm"
+                    disabled={assigning || unnumbered === 0}
+                    onClick={handleAssignCustomerNumbers}
+                  >
+                    {assigning && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
+                    {t('invoice.assignCustomerNumbers')}
+                  </Button>
+                </div>
+              </div>
+
+              <Separator />
+
               <div className="space-y-3">
                 <div>
                   <h3 className="text-sm font-semibold">{t('invoice.partsMarkupTitle')}</h3>

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

@@ -23,6 +23,9 @@ export default async function InvoiceSettingsPage() {
   ]);
 
   const settings = result.success && result.data ? result.data : {};
+  const unnumberedCustomers = await db.customer.count({
+    where: { organizationId: data.organizationId, customerNumber: null },
+  });
   const customFields = customFieldsResult.success && customFieldsResult.data ? customFieldsResult.data : [];
 
   // Check if Telegram is enabled (plan feature + user setting)
@@ -38,6 +41,7 @@ export default async function InvoiceSettingsPage() {
   return (
     <InvoiceSettings
       settings={settings}
+      unnumberedCustomers={unnumberedCustomers}
       initialInvoiceLayout={invoiceLayoutResult.success ? invoiceLayoutResult.data : undefined}
       initialQuoteLayout={quoteLayoutResult.success ? quoteLayoutResult.data : undefined}
       customFields={customFields}

+ 107 - 20
src/features/customers/Actions/customerActions.ts

@@ -58,16 +58,40 @@ export async function createCustomer(input: unknown) {
     }
 
     const data = createCustomerSchema.parse(input);
-    const customer = await db.customer.create({
-      data: {
-        ...data,
-        email: data.email || null,
-        userId,
-        organizationId,
-      },
-    });
-    revalidatePath("/customers");
-    return customer;
+
+    // Auto-assign the next sequential number when none was provided; the
+    // per-org unique index guards against races and manual duplicates.
+    let customerNumber = data.customerNumber?.trim() || null;
+    if (!customerNumber) {
+      const existing = await db.customer.findMany({
+        where: { organizationId, customerNumber: { not: null } },
+        select: { customerNumber: true },
+      });
+      const max = existing.reduce((acc, c) => {
+        const n = Number.parseInt(c.customerNumber ?? "", 10);
+        return Number.isFinite(n) && n > acc ? n : acc;
+      }, 1000);
+      customerNumber = String(max + 1);
+    }
+
+    try {
+      const customer = await db.customer.create({
+        data: {
+          ...data,
+          customerNumber,
+          email: data.email || null,
+          userId,
+          organizationId,
+        },
+      });
+      revalidatePath("/customers");
+      return customer;
+    } catch (err: unknown) {
+      if (err && typeof err === "object" && (err as { code?: string }).code === "P2002") {
+        throw new Error("Customer number is already in use");
+      }
+      throw err;
+    }
   }, {
     requiredPermissions: [{ action: PermissionAction.CREATE, subject: PermissionSubject.CUSTOMERS }],
     audit: ({ result }) => ({
@@ -83,16 +107,28 @@ export async function createCustomer(input: unknown) {
 export async function updateCustomer(input: unknown) {
   return withAuth(async ({ userId, organizationId }) => {
     const { id, ...data } = updateCustomerSchema.parse(input);
-    const result = await db.customer.updateMany({
-      where: { id, organizationId },
-      data: {
-        ...data,
-        email: data.email || null,
-        company: data.company || null,
-        phone: data.phone || null,
-        address: data.address || null,
-      },
-    });
+    let result;
+    try {
+      result = await db.customer.updateMany({
+        where: { id, organizationId },
+        data: {
+          ...data,
+          customerNumber:
+            data.customerNumber !== undefined
+              ? data.customerNumber.trim() || null
+              : undefined,
+          email: data.email || null,
+          company: data.company || null,
+          phone: data.phone || null,
+          address: data.address || null,
+        },
+      });
+    } catch (err: unknown) {
+      if (err && typeof err === "object" && (err as { code?: string }).code === "P2002") {
+        throw new Error("Customer number is already in use");
+      }
+      throw err;
+    }
     if (result.count === 0) throw new Error("Customer not found");
     revalidatePath("/customers");
     revalidatePath(`/customers/${id}`);
@@ -138,6 +174,53 @@ export async function deleteCustomers(customerIds: string[]) {
   }, { requiredPermissions: [{ action: PermissionAction.DELETE, subject: PermissionSubject.CUSTOMERS }] });
 }
 
+/**
+ * Assigns sequential numbers to every customer that has none, oldest first,
+ * continuing after the highest existing numeric number (min 1001). Customers
+ * that already have a number — auto or manual — are never touched.
+ */
+export async function backfillCustomerNumbers() {
+  return withAuth(async ({ organizationId }) => {
+    const [numbered, unnumbered] = await Promise.all([
+      db.customer.findMany({
+        where: { organizationId, customerNumber: { not: null } },
+        select: { customerNumber: true },
+      }),
+      db.customer.findMany({
+        where: { organizationId, customerNumber: null },
+        select: { id: true },
+        orderBy: { createdAt: "asc" },
+      }),
+    ]);
+
+    let next = numbered.reduce((acc, c) => {
+      const n = Number.parseInt(c.customerNumber ?? "", 10);
+      return Number.isFinite(n) && n > acc ? n : acc;
+    }, 1000) + 1;
+
+    await db.$transaction(
+      unnumbered.map((c) =>
+        db.customer.update({
+          where: { id: c.id },
+          data: { customerNumber: String(next++) },
+        })
+      )
+    );
+
+    revalidatePath("/customers");
+    revalidatePath("/settings/invoice");
+    return { assigned: unnumbered.length };
+  }, {
+    requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.CUSTOMERS }],
+    audit: ({ result }) => ({
+      action: "customer.backfillNumbers",
+      entity: "Customer",
+      message: `Assigned customer numbers to ${result.assigned} customers`,
+      metadata: { assigned: result.assigned },
+    }),
+  });
+}
+
 export async function getCustomersPaginated(params: {
   page?: number;
   pageSize?: number;
@@ -162,6 +245,7 @@ export async function getCustomersPaginated(params: {
             { email: { contains: word, mode: "insensitive" } },
             { phone: { contains: word, mode: "insensitive" } },
             { company: { contains: word, mode: "insensitive" } },
+            { customerNumber: { contains: word, mode: "insensitive" } },
           ],
         }));
       } else {
@@ -170,6 +254,7 @@ export async function getCustomersPaginated(params: {
           { email: { contains: params.search, mode: "insensitive" } },
           { phone: { contains: params.search, mode: "insensitive" } },
           { company: { contains: params.search, mode: "insensitive" } },
+          { customerNumber: { contains: params.search, mode: "insensitive" } },
         ];
       }
     }
@@ -184,6 +269,7 @@ export async function getCustomersPaginated(params: {
           const dir = params.sortOrder || "desc";
           const nullable = { sort: dir, nulls: "last" } as const;
           switch (params.sortBy) {
+            case "number": return { customerNumber: { sort: dir, nulls: "last" as const } };
             case "name": return { name: dir };
             case "company": return { company: nullable };
             case "phone": return { phone: nullable };
@@ -414,6 +500,7 @@ export async function searchCustomers(search?: string, limit = 20, offset = 0) {
             { email: { contains: word, mode: "insensitive" } },
             { phone: { contains: word, mode: "insensitive" } },
             { company: { contains: word, mode: "insensitive" } },
+            { customerNumber: { contains: word, mode: "insensitive" } },
           ],
         }));
       } else {

+ 23 - 9
src/features/customers/Components/CustomerForm.tsx

@@ -25,6 +25,7 @@ interface CustomerFormProps {
   onOpenChange: (open: boolean) => void;
   customer?: {
     id: string;
+    customerNumber?: string | null;
     name: string;
     email?: string | null;
     phone?: string | null;
@@ -52,6 +53,7 @@ export function CustomerForm({ open, onOpenChange, customer, onCreated }: Custom
     const formData = new FormData(e.currentTarget);
     const data = {
       name: formData.get("name") as string,
+      customerNumber: (formData.get("customerNumber") as string) || undefined,
       email: (formData.get("email") as string) || undefined,
       phone: (formData.get("phone") as string) || undefined,
       address: (formData.get("address") as string) || undefined,
@@ -93,15 +95,27 @@ export function CustomerForm({ open, onOpenChange, customer, onCreated }: Custom
         </DialogHeader>
 
         <form onSubmit={handleSubmit} className="space-y-4">
-          <div className="space-y-2">
-            <Label htmlFor="name">{t("nameRequired")}</Label>
-            <Input
-              id="name"
-              name="name"
-              placeholder={t("namePlaceholder")}
-              defaultValue={customer?.name}
-              required
-            />
+          <div className="grid grid-cols-[1fr_120px] gap-4">
+            <div className="space-y-2">
+              <Label htmlFor="name">{t("nameRequired")}</Label>
+              <Input
+                id="name"
+                name="name"
+                placeholder={t("namePlaceholder")}
+                defaultValue={customer?.name}
+                required
+              />
+            </div>
+            <div className="space-y-2">
+              <Label htmlFor="customerNumber">{t("customerNumber")}</Label>
+              <Input
+                id="customerNumber"
+                name="customerNumber"
+                placeholder={t("customerNumberAuto")}
+                defaultValue={customer?.customerNumber ?? ""}
+                maxLength={20}
+              />
+            </div>
           </div>
 
           <div className="grid grid-cols-2 gap-4">

+ 1 - 0
src/features/customers/Schema/customerSchema.ts

@@ -1,6 +1,7 @@
 import { z } from "zod";
 
 export const createCustomerSchema = z.object({
+  customerNumber: z.string().max(20).optional(),
   name: z.string().min(1, "Name is required"),
   email: z.string().email("Invalid email").optional().or(z.literal("")),
   phone: z.string().optional(),