Forráskód Böngészése

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 hónapja
szülő
commit
f6f6092d2a

+ 5 - 2
messages/de/customers.json

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

+ 8 - 1
messages/de/settings.json

@@ -418,7 +418,14 @@
     "layoutDocInvoice": "Rechnung",
     "layoutDocInvoice": "Rechnung",
     "layoutDocQuote": "Angebot",
     "layoutDocQuote": "Angebot",
     "saveLayout": "Layout speichern",
     "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": {
   "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.",
     "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",
       "company": "Company",
       "phone": "Phone",
       "phone": "Phone",
       "email": "Email",
       "email": "Email",
-      "vehicles": "Vehicles"
+      "vehicles": "Vehicles",
+      "number": "No."
     },
     },
     "emptySearch": "No customers match your search.",
     "emptySearch": "No customers match your search.",
     "empty": "No customers yet.",
     "empty": "No customers yet.",
@@ -65,7 +66,9 @@
     "notesPlaceholder": "Additional notes...",
     "notesPlaceholder": "Additional notes...",
     "customerUpdated": "Customer updated",
     "customerUpdated": "Customer updated",
     "customerCreated": "Customer created",
     "customerCreated": "Customer created",
-    "saveError": "Failed to save customer"
+    "saveError": "Failed to save customer",
+    "customerNumber": "Customer no.",
+    "customerNumberAuto": "Auto"
   },
   },
   "serviceRequests": {
   "serviceRequests": {
     "statusNew": "New",
     "statusNew": "New",

+ 8 - 1
messages/en/settings.json

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

+ 8 - 1
messages/es/settings.json

@@ -418,7 +418,14 @@
     "layoutDocInvoice": "Factura",
     "layoutDocInvoice": "Factura",
     "layoutDocQuote": "Presupuesto",
     "layoutDocQuote": "Presupuesto",
     "saveLayout": "Guardar diseño",
     "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": {
   "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.",
     "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",
       "company": "Entreprise",
       "phone": "Téléphone",
       "phone": "Téléphone",
       "email": "E-mail",
       "email": "E-mail",
-      "vehicles": "Véhicules"
+      "vehicles": "Véhicules",
+      "number": "N°"
     },
     },
     "emptySearch": "Aucun client ne correspond à votre recherche.",
     "emptySearch": "Aucun client ne correspond à votre recherche.",
     "empty": "Aucun client pour le moment.",
     "empty": "Aucun client pour le moment.",
@@ -65,7 +66,9 @@
     "notesPlaceholder": "Notes supplémentaires...",
     "notesPlaceholder": "Notes supplémentaires...",
     "customerUpdated": "Client mis à jour",
     "customerUpdated": "Client mis à jour",
     "customerCreated": "Client créé",
     "customerCreated": "Client créé",
-    "saveError": "Échec de l'enregistrement du client"
+    "saveError": "Échec de l'enregistrement du client",
+    "customerNumber": "N° client",
+    "customerNumberAuto": "Auto"
   },
   },
   "serviceRequests": {
   "serviceRequests": {
     "statusNew": "Nouveau",
     "statusNew": "Nouveau",

+ 8 - 1
messages/fr/settings.json

@@ -418,7 +418,14 @@
     "layoutDocInvoice": "Facture",
     "layoutDocInvoice": "Facture",
     "layoutDocQuote": "Devis",
     "layoutDocQuote": "Devis",
     "saveLayout": "Enregistrer la mise en page",
     "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": {
   "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.",
     "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",
       "company": "Azienda",
       "phone": "Telefono",
       "phone": "Telefono",
       "email": "Email",
       "email": "Email",
-      "vehicles": "Veicoli"
+      "vehicles": "Veicoli",
+      "number": "N."
     },
     },
     "emptySearch": "Nessun cliente corrisponde alla ricerca.",
     "emptySearch": "Nessun cliente corrisponde alla ricerca.",
     "empty": "Nessun cliente presente.",
     "empty": "Nessun cliente presente.",
@@ -65,7 +66,9 @@
     "notesPlaceholder": "Note aggiuntive...",
     "notesPlaceholder": "Note aggiuntive...",
     "customerUpdated": "Cliente aggiornato",
     "customerUpdated": "Cliente aggiornato",
     "customerCreated": "Cliente creato",
     "customerCreated": "Cliente creato",
-    "saveError": "Salvataggio del cliente non riuscito"
+    "saveError": "Salvataggio del cliente non riuscito",
+    "customerNumber": "N. cliente",
+    "customerNumberAuto": "Auto"
   },
   },
   "serviceRequests": {
   "serviceRequests": {
     "statusNew": "Nuova",
     "statusNew": "Nuova",

+ 8 - 1
messages/it/settings.json

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

+ 8 - 1
messages/lt/settings.json

@@ -418,7 +418,14 @@
     "layoutDocInvoice": "Sąskaita",
     "layoutDocInvoice": "Sąskaita",
     "layoutDocQuote": "Pasiūlymas",
     "layoutDocQuote": "Pasiūlymas",
     "saveLayout": "Išsaugoti išdėstymą",
     "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": {
   "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.",
     "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",
       "company": "Bedrift",
       "phone": "Telefon",
       "phone": "Telefon",
       "email": "E-post",
       "email": "E-post",
-      "vehicles": "Kjøretøy"
+      "vehicles": "Kjøretøy",
+      "number": "Nr."
     },
     },
     "emptySearch": "Ingen kunder samsvarer med søket ditt.",
     "emptySearch": "Ingen kunder samsvarer med søket ditt.",
     "empty": "Ingen kunder ennå.",
     "empty": "Ingen kunder ennå.",
@@ -65,7 +66,9 @@
     "notesPlaceholder": "Ytterligere notater...",
     "notesPlaceholder": "Ytterligere notater...",
     "customerUpdated": "Kunde oppdatert",
     "customerUpdated": "Kunde oppdatert",
     "customerCreated": "Kunde opprettet",
     "customerCreated": "Kunde opprettet",
-    "saveError": "Kunne ikke lagre kunde"
+    "saveError": "Kunne ikke lagre kunde",
+    "customerNumber": "Kundenr.",
+    "customerNumberAuto": "Auto"
   },
   },
   "serviceRequests": {
   "serviceRequests": {
     "statusNew": "Ny",
     "statusNew": "Ny",

+ 8 - 1
messages/nb/settings.json

@@ -418,7 +418,14 @@
     "layoutDocInvoice": "Faktura",
     "layoutDocInvoice": "Faktura",
     "layoutDocQuote": "Tilbud",
     "layoutDocQuote": "Tilbud",
     "saveLayout": "Lagre oppsett",
     "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": {
   "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.",
     "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",
       "company": "Bedrijf",
       "phone": "Telefoon",
       "phone": "Telefoon",
       "email": "E-mail",
       "email": "E-mail",
-      "vehicles": "Voertuigen"
+      "vehicles": "Voertuigen",
+      "number": "Nr."
     },
     },
     "emptySearch": "Geen klanten gevonden voor uw zoekopdracht.",
     "emptySearch": "Geen klanten gevonden voor uw zoekopdracht.",
     "empty": "Nog geen klanten.",
     "empty": "Nog geen klanten.",
@@ -65,7 +66,9 @@
     "notesPlaceholder": "Aanvullende opmerkingen...",
     "notesPlaceholder": "Aanvullende opmerkingen...",
     "customerUpdated": "Klant bijgewerkt",
     "customerUpdated": "Klant bijgewerkt",
     "customerCreated": "Klant aangemaakt",
     "customerCreated": "Klant aangemaakt",
-    "saveError": "Opslaan van klant mislukt"
+    "saveError": "Opslaan van klant mislukt",
+    "customerNumber": "Klantnr.",
+    "customerNumberAuto": "Auto"
   },
   },
   "serviceRequests": {
   "serviceRequests": {
     "statusNew": "Nieuw",
     "statusNew": "Nieuw",

+ 8 - 1
messages/nl/settings.json

@@ -418,7 +418,14 @@
     "layoutDocInvoice": "Factuur",
     "layoutDocInvoice": "Factuur",
     "layoutDocQuote": "Offerte",
     "layoutDocQuote": "Offerte",
     "saveLayout": "Layout opslaan",
     "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": {
   "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.",
     "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",
       "company": "Firma",
       "phone": "Telefon",
       "phone": "Telefon",
       "email": "E-mail",
       "email": "E-mail",
-      "vehicles": "Pojazdy"
+      "vehicles": "Pojazdy",
+      "number": "Nr"
     },
     },
     "emptySearch": "Brak klientów pasujących do wyszukiwania.",
     "emptySearch": "Brak klientów pasujących do wyszukiwania.",
     "empty": "Brak klientów.",
     "empty": "Brak klientów.",
@@ -65,7 +66,9 @@
     "notesPlaceholder": "Dodatkowe uwagi...",
     "notesPlaceholder": "Dodatkowe uwagi...",
     "customerUpdated": "Klient zaktualizowany",
     "customerUpdated": "Klient zaktualizowany",
     "customerCreated": "Klient utworzony",
     "customerCreated": "Klient utworzony",
-    "saveError": "Nie udało się zapisać klienta"
+    "saveError": "Nie udało się zapisać klienta",
+    "customerNumber": "Nr klienta",
+    "customerNumberAuto": "Auto"
   },
   },
   "serviceRequests": {
   "serviceRequests": {
     "statusNew": "Nowe",
     "statusNew": "Nowe",

+ 8 - 1
messages/pl/settings.json

@@ -418,7 +418,14 @@
     "layoutDocInvoice": "Faktura",
     "layoutDocInvoice": "Faktura",
     "layoutDocQuote": "Wycena",
     "layoutDocQuote": "Wycena",
     "saveLayout": "Zapisz układ",
     "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": {
   "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.",
     "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",
       "company": "Empresa",
       "phone": "Telefone",
       "phone": "Telefone",
       "email": "E-mail",
       "email": "E-mail",
-      "vehicles": "Veículos"
+      "vehicles": "Veículos",
+      "number": "Nº"
     },
     },
     "emptySearch": "Nenhum cliente encontrado para sua busca.",
     "emptySearch": "Nenhum cliente encontrado para sua busca.",
     "empty": "Nenhum cliente cadastrado.",
     "empty": "Nenhum cliente cadastrado.",
@@ -65,7 +66,9 @@
     "notesPlaceholder": "Observações adicionais...",
     "notesPlaceholder": "Observações adicionais...",
     "customerUpdated": "Cliente atualizado",
     "customerUpdated": "Cliente atualizado",
     "customerCreated": "Cliente cadastrado",
     "customerCreated": "Cliente cadastrado",
-    "saveError": "Falha ao salvar cliente"
+    "saveError": "Falha ao salvar cliente",
+    "customerNumber": "Nº do cliente",
+    "customerNumberAuto": "Auto"
   },
   },
   "serviceRequests": {
   "serviceRequests": {
     "statusNew": "Novo",
     "statusNew": "Novo",

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

@@ -418,7 +418,14 @@
     "layoutDocInvoice": "Fatura",
     "layoutDocInvoice": "Fatura",
     "layoutDocQuote": "Orçamento",
     "layoutDocQuote": "Orçamento",
     "saveLayout": "Salvar layout",
     "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": {
   "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.",
     "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": "Компания",
       "company": "Компания",
       "phone": "Телефон",
       "phone": "Телефон",
       "email": "Эл. почта",
       "email": "Эл. почта",
-      "vehicles": "Транспортные средства"
+      "vehicles": "Транспортные средства",
+      "number": "№"
     },
     },
     "emptySearch": "Нет клиентов, соответствующих поиску.",
     "emptySearch": "Нет клиентов, соответствующих поиску.",
     "empty": "Клиентов пока нет.",
     "empty": "Клиентов пока нет.",
@@ -65,7 +66,9 @@
     "notesPlaceholder": "Дополнительные заметки...",
     "notesPlaceholder": "Дополнительные заметки...",
     "customerUpdated": "Клиент обновлён",
     "customerUpdated": "Клиент обновлён",
     "customerCreated": "Клиент создан",
     "customerCreated": "Клиент создан",
-    "saveError": "Не удалось сохранить клиента"
+    "saveError": "Не удалось сохранить клиента",
+    "customerNumber": "№ клиента",
+    "customerNumberAuto": "Авто"
   },
   },
   "serviceRequests": {
   "serviceRequests": {
     "statusNew": "Новая",
     "statusNew": "Новая",

+ 8 - 1
messages/ru/settings.json

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

+ 5 - 2
messages/tr/customers.json

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

+ 8 - 1
messages/tr/settings.json

@@ -418,7 +418,14 @@
     "layoutDocInvoice": "Fatura",
     "layoutDocInvoice": "Fatura",
     "layoutDocQuote": "Teklif",
     "layoutDocQuote": "Teklif",
     "saveLayout": "Düzeni kaydet",
     "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": {
   "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.",
     "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 {
 model Customer {
   id             String   @id @default(cuid())
   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
   name           String
   email          String?
   email          String?
   phone          String?
   phone          String?
@@ -354,6 +358,7 @@ model Customer {
   serviceRequests  ServiceRequest[]
   serviceRequests  ServiceRequest[]
 
 
   @@index([organizationId])
   @@index([organizationId])
+  @@unique([organizationId, customerNumber])
   @@map("customers")
   @@map("customers")
 }
 }
 
 

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

@@ -58,6 +58,7 @@ interface ServiceRequestItem {
 
 
 interface CustomerDetail {
 interface CustomerDetail {
   id: string;
   id: string;
+  customerNumber: string | null;
   name: string;
   name: string;
   email: string | null;
   email: string | null;
   phone: string | null;
   phone: string | null;
@@ -163,7 +164,14 @@ export function CustomerDetailClient({
             <Users className="h-7 w-7 text-primary" />
             <Users className="h-7 w-7 text-primary" />
           </div>
           </div>
           <div className="flex-1">
           <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 && (
             {hasContactInfo && (
               <div className="mt-1.5 flex flex-wrap gap-x-5 gap-y-1.5 text-sm">
               <div className="mt-1.5 flex flex-wrap gap-x-5 gap-y-1.5 text-sm">
                 {customer.company && (
                 {customer.company && (

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

@@ -44,6 +44,7 @@ import {
 } from "lucide-react";
 } from "lucide-react";
 
 
 interface Customer {
 interface Customer {
+  customerNumber: string | null;
   id: string;
   id: string;
   name: string;
   name: string;
   email: string | null;
   email: string | null;
@@ -265,6 +266,11 @@ export function CustomersClient({
                   onClick={(e) => e.stopPropagation()}
                   onClick={(e) => e.stopPropagation()}
                 />
                 />
               </TableHead>
               </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>
               <TableHead>
                 <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("name")}>
                 <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("name")}>
                   {t("table.name")}<SortIcon column="name" />
                   {t("table.name")}<SortIcon column="name" />
@@ -296,7 +302,7 @@ export function CustomersClient({
           <TableBody>
           <TableBody>
             {data.customers.length === 0 ? (
             {data.customers.length === 0 ? (
               <TableRow>
               <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")}
                   {search ? t("emptySearch") : t("empty")}
                 </TableCell>
                 </TableCell>
               </TableRow>
               </TableRow>
@@ -314,6 +320,9 @@ export function CustomersClient({
                       onClick={(e) => e.stopPropagation()}
                       onClick={(e) => e.stopPropagation()}
                     />
                     />
                   </TableCell>
                   </TableCell>
+                  <TableCell className="font-mono text-xs text-muted-foreground">
+                    {c.customerNumber || "-"}
+                  </TableCell>
                   <TableCell>
                   <TableCell>
                     <div className="flex items-center gap-2">
                     <div className="flex items-center gap-2">
                       <Users className="h-4 w-4 text-muted-foreground shrink-0" />
                       <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 { Textarea } from '@/components/ui/textarea'
 import { toast } from 'sonner'
 import { toast } from 'sonner'
 import { setSettings } from '@/features/settings/Actions/settingsActions'
 import { setSettings } from '@/features/settings/Actions/settingsActions'
+import { backfillCustomerNumbers } from '@/features/customers/Actions/customerActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { FileText, Loader2, Save } from 'lucide-react'
 import { FileText, Loader2, Save } from 'lucide-react'
 import { ReadOnlyBanner, SaveButton, ReadOnlyWrapper } from '../read-only-guard'
 import { ReadOnlyBanner, SaveButton, ReadOnlyWrapper } from '../read-only-guard'
 import { cn } from '@/lib/utils'
 import { cn } from '@/lib/utils'
+import { useConfirm } from '@/components/confirm-dialog'
 import { InvoiceLayoutEditor } from '@/features/settings/Components/InvoiceLayoutEditor'
 import { InvoiceLayoutEditor } from '@/features/settings/Components/InvoiceLayoutEditor'
 import { InvoiceLayoutPreview } from '@/features/settings/Components/InvoiceLayoutPreview'
 import { InvoiceLayoutPreview } from '@/features/settings/Components/InvoiceLayoutPreview'
 import {
 import {
@@ -44,6 +46,7 @@ interface FieldDef {
 
 
 interface InvoiceSettingsProps {
 interface InvoiceSettingsProps {
   settings: Record<string, string>
   settings: Record<string, string>
+  unnumberedCustomers?: number
   initialInvoiceLayout?: InvoiceLayoutConfig
   initialInvoiceLayout?: InvoiceLayoutConfig
   initialQuoteLayout?: InvoiceLayoutConfig
   initialQuoteLayout?: InvoiceLayoutConfig
   customFields: FieldDef[]
   customFields: FieldDef[]
@@ -53,6 +56,7 @@ interface InvoiceSettingsProps {
 
 
 export function InvoiceSettings({
 export function InvoiceSettings({
   settings,
   settings,
+  unnumberedCustomers = 0,
   initialInvoiceLayout,
   initialInvoiceLayout,
   initialQuoteLayout,
   initialQuoteLayout,
   customFields,
   customFields,
@@ -142,6 +146,28 @@ export function InvoiceSettings({
     toast.success(t('invoice.saved'))
     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 () => {
   const handleSaveLayout = async () => {
     setSaving(true)
     setSaving(true)
     try {
     try {
@@ -331,6 +357,29 @@ export function InvoiceSettings({
 
 
               <Separator />
               <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 className="space-y-3">
                 <div>
                 <div>
                   <h3 className="text-sm font-semibold">{t('invoice.partsMarkupTitle')}</h3>
                   <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 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 : [];
   const customFields = customFieldsResult.success && customFieldsResult.data ? customFieldsResult.data : [];
 
 
   // Check if Telegram is enabled (plan feature + user setting)
   // Check if Telegram is enabled (plan feature + user setting)
@@ -38,6 +41,7 @@ export default async function InvoiceSettingsPage() {
   return (
   return (
     <InvoiceSettings
     <InvoiceSettings
       settings={settings}
       settings={settings}
+      unnumberedCustomers={unnumberedCustomers}
       initialInvoiceLayout={invoiceLayoutResult.success ? invoiceLayoutResult.data : undefined}
       initialInvoiceLayout={invoiceLayoutResult.success ? invoiceLayoutResult.data : undefined}
       initialQuoteLayout={quoteLayoutResult.success ? quoteLayoutResult.data : undefined}
       initialQuoteLayout={quoteLayoutResult.success ? quoteLayoutResult.data : undefined}
       customFields={customFields}
       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 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 }],
     requiredPermissions: [{ action: PermissionAction.CREATE, subject: PermissionSubject.CUSTOMERS }],
     audit: ({ result }) => ({
     audit: ({ result }) => ({
@@ -83,16 +107,28 @@ export async function createCustomer(input: unknown) {
 export async function updateCustomer(input: unknown) {
 export async function updateCustomer(input: unknown) {
   return withAuth(async ({ userId, organizationId }) => {
   return withAuth(async ({ userId, organizationId }) => {
     const { id, ...data } = updateCustomerSchema.parse(input);
     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");
     if (result.count === 0) throw new Error("Customer not found");
     revalidatePath("/customers");
     revalidatePath("/customers");
     revalidatePath(`/customers/${id}`);
     revalidatePath(`/customers/${id}`);
@@ -138,6 +174,53 @@ export async function deleteCustomers(customerIds: string[]) {
   }, { requiredPermissions: [{ action: PermissionAction.DELETE, subject: PermissionSubject.CUSTOMERS }] });
   }, { 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: {
 export async function getCustomersPaginated(params: {
   page?: number;
   page?: number;
   pageSize?: number;
   pageSize?: number;
@@ -162,6 +245,7 @@ export async function getCustomersPaginated(params: {
             { email: { contains: word, mode: "insensitive" } },
             { email: { contains: word, mode: "insensitive" } },
             { phone: { contains: word, mode: "insensitive" } },
             { phone: { contains: word, mode: "insensitive" } },
             { company: { contains: word, mode: "insensitive" } },
             { company: { contains: word, mode: "insensitive" } },
+            { customerNumber: { contains: word, mode: "insensitive" } },
           ],
           ],
         }));
         }));
       } else {
       } else {
@@ -170,6 +254,7 @@ export async function getCustomersPaginated(params: {
           { email: { contains: params.search, mode: "insensitive" } },
           { email: { contains: params.search, mode: "insensitive" } },
           { phone: { contains: params.search, mode: "insensitive" } },
           { phone: { contains: params.search, mode: "insensitive" } },
           { company: { 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 dir = params.sortOrder || "desc";
           const nullable = { sort: dir, nulls: "last" } as const;
           const nullable = { sort: dir, nulls: "last" } as const;
           switch (params.sortBy) {
           switch (params.sortBy) {
+            case "number": return { customerNumber: { sort: dir, nulls: "last" as const } };
             case "name": return { name: dir };
             case "name": return { name: dir };
             case "company": return { company: nullable };
             case "company": return { company: nullable };
             case "phone": return { phone: 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" } },
             { email: { contains: word, mode: "insensitive" } },
             { phone: { contains: word, mode: "insensitive" } },
             { phone: { contains: word, mode: "insensitive" } },
             { company: { contains: word, mode: "insensitive" } },
             { company: { contains: word, mode: "insensitive" } },
+            { customerNumber: { contains: word, mode: "insensitive" } },
           ],
           ],
         }));
         }));
       } else {
       } else {

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

@@ -25,6 +25,7 @@ interface CustomerFormProps {
   onOpenChange: (open: boolean) => void;
   onOpenChange: (open: boolean) => void;
   customer?: {
   customer?: {
     id: string;
     id: string;
+    customerNumber?: string | null;
     name: string;
     name: string;
     email?: string | null;
     email?: string | null;
     phone?: string | null;
     phone?: string | null;
@@ -52,6 +53,7 @@ export function CustomerForm({ open, onOpenChange, customer, onCreated }: Custom
     const formData = new FormData(e.currentTarget);
     const formData = new FormData(e.currentTarget);
     const data = {
     const data = {
       name: formData.get("name") as string,
       name: formData.get("name") as string,
+      customerNumber: (formData.get("customerNumber") as string) || undefined,
       email: (formData.get("email") as string) || undefined,
       email: (formData.get("email") as string) || undefined,
       phone: (formData.get("phone") as string) || undefined,
       phone: (formData.get("phone") as string) || undefined,
       address: (formData.get("address") as string) || undefined,
       address: (formData.get("address") as string) || undefined,
@@ -93,15 +95,27 @@ export function CustomerForm({ open, onOpenChange, customer, onCreated }: Custom
         </DialogHeader>
         </DialogHeader>
 
 
         <form onSubmit={handleSubmit} className="space-y-4">
         <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>
 
 
           <div className="grid grid-cols-2 gap-4">
           <div className="grid grid-cols-2 gap-4">

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

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